diff options
author | Logan Hunt <loganhunt@simponic.xyz> | 2023-01-19 14:04:10 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-19 14:04:10 -0700 |
commit | 4666d7871a9e064a3b3033c7c1daa9c3c4972d98 (patch) | |
tree | 340ee6ae1dc6410f73bb7862a89c01b7039807de /lib/chessh/web/web.ex | |
parent | bdf99b4ee989df1813745e1dfd2983689b09ca85 (diff) | |
download | chessh-4666d7871a9e064a3b3033c7c1daa9c3c4972d98.tar.gz chessh-4666d7871a9e064a3b3033c7c1daa9c3c4972d98.zip |
Web Client (#11)
* Github Oauth
* A simple frontend
* Add middleware proxy on dev
* Forward proxy and rewrite path, add oauth to frontend, increase jwt expiry time to 12 hours
* Some simple style changes
* Add keys as user
* Checkpoint - auth is broken
* Fix auth and use player model, unrelated to this pr: flip board if dark
* Close player session when password or key deleted or put
* Add build script - this branch is quickly becoming cringe
* Docker v2 - add migration and scripts, fix local storage and index that caused build issues
* Ignore keys, proxy api correctly nginx
* Finally nginx is resolved jesus christ
* Remove max screen dimension limits cuz cringe
* Cursor highlight
* Add password form, some minor frontend changes as well
* Remove cringe on home page
* Move to 127.0.0.1 loopback in env
* Add github id in player structs for tests
Diffstat (limited to 'lib/chessh/web/web.ex')
-rw-r--r-- | lib/chessh/web/web.ex | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex new file mode 100644 index 0000000..8c0929a --- /dev/null +++ b/lib/chessh/web/web.ex @@ -0,0 +1,289 @@ +defmodule Chessh.Web.Endpoint do + alias Chessh.{Player, Repo, Key, PlayerSession} + alias Chessh.Web.Token + use Plug.Router + require Logger + import Ecto.Query + + plug(Plug.Logger) + plug(:match) + + plug(Plug.Parsers, + parsers: [:json], + pass: ["application/json"], + json_decoder: Jason + ) + + plug(:dispatch) + + get "/oauth/redirect" do + [github_login_url, client_id, client_secret, github_user_api_url, github_user_agent] = + get_github_configs() + + resp = + case conn.params do + %{"code" => req_token} -> + case :httpc.request( + :post, + {String.to_charlist( + "#{github_login_url}?client_id=#{client_id}&client_secret=#{client_secret}&code=#{req_token}" + ), [], 'application/json', ''}, + [], + [] + ) do + {:ok, {{_, 200, 'OK'}, _, resp}} -> + URI.decode_query(String.Chars.to_string(resp)) + end + end + + {status, body} = + create_player_from_github_response(resp, github_user_api_url, github_user_agent) + + conn + |> assign_jwt_and_redirect_or_encode(status, body) + end + + delete "/player/token/password" do + player = get_player_from_jwt(conn) + PlayerSession.close_all_player_sessions(player) + + {status, body} = + case Repo.update(Ecto.Changeset.change(player, %{hashed_password: nil})) do + {:ok, _new_player} -> + {200, %{success: true}} + + {:error, _} -> + {400, %{success: false}} + end + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + put "/player/token/password" do + player = get_player_from_jwt(conn) + PlayerSession.close_all_player_sessions(player) + + {status, body} = + case conn.body_params do + %{"password" => password, "password_confirmation" => password_confirmation} -> + case Player.password_changeset(player, %{ + password: password, + password_confirmation: password_confirmation + }) + |> Repo.update() do + {:ok, player} -> + {200, %{success: true, id: player.id}} + + {:error, %{valid?: false} = changeset} -> + {400, %{errors: format_errors(changeset)}} + end + end + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + get "/player/logout" do + conn + |> delete_resp_cookie("jwt") + |> send_resp(200, Jason.encode!(%{success: true})) + end + + post "/player/keys" do + player = get_player_from_jwt(conn) + + player_key_count = + Repo.aggregate(from(k in Key, where: k.player_id == ^player.id), :count, :id) + + max_key_count = Application.get_env(:chessh, RateLimits)[:player_public_keys] + + {status, body} = + case conn.body_params do + %{"key" => key, "name" => name} -> + if player_key_count > max_key_count do + {400, %{errors: "Player has reached threshold of #{max_key_count} keys."}} + else + case Key.changeset(%Key{player_id: player.id}, %{key: key, name: name}) + |> Repo.insert() do + {:ok, _new_key} -> + { + 200, + %{ + success: true + } + } + + {:error, %{valid?: false} = changeset} -> + { + 400, + %{ + errors: format_errors(changeset) + } + } + end + end + + _ -> + { + 400, + %{errors: "Must define key and name"} + } + end + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + get "/player/token/me" do + {:ok, jwt} = Token.verify_and_validate(get_jwt(conn)) + + %{"uid" => player_id, "exp" => expiration} = jwt + player = Repo.get(Player, player_id) + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{player: player, expiration: expiration * 1000})) + end + + get "/player/:id/keys" do + %{"id" => player_id} = conn.path_params + + keys = (Repo.get(Player, player_id) |> Repo.preload([:keys])).keys + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(keys)) + end + + delete "/keys/:id" do + player = get_player_from_jwt(conn) + PlayerSession.close_all_player_sessions(player) + + %{"id" => key_id} = conn.path_params + key = Repo.get(Key, key_id) + + {status, body} = + if key && player.id == key.player_id do + case Repo.delete(key) do + {:ok, _} -> + {200, %{success: true}} + + {:error, changeset} -> + {400, %{errors: format_errors(changeset)}} + end + else + if !key do + {404, %{errors: "Key not found"}} + else + {401, %{errors: "You cannot delete that key"}} + end + end + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + match _ do + send_resp(conn, 404, "Route undefined") + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp get_github_configs() do + Enum.map( + [ + :github_oauth_login_url, + :github_client_id, + :github_client_secret, + :github_user_api_url, + :github_user_agent + ], + fn key -> Application.get_env(:chessh, Web)[key] end + ) + end + + defp get_jwt(conn) do + auth_header = + Enum.find_value(conn.req_headers, fn {header, value} -> + if header === "authorization", do: value + end) + + if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt") + end + + defp get_player_from_jwt(conn) do + {:ok, %{"uid" => uid}} = Token.verify_and_validate(get_jwt(conn)) + + Repo.get(Player, uid) + end + + defp assign_jwt_and_redirect_or_encode(conn, status, body) do + case body do + %{jwt: token} -> + client_redirect_location = + Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in] + + conn + |> put_resp_cookie("jwt", token) + |> put_resp_header("location", client_redirect_location) + |> send_resp(301, '') + + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + end + + defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do + case resp do + %{"access_token" => access_token} -> + case :httpc.request( + :get, + {String.to_charlist(github_user_api_url), + [ + {'Authorization', String.to_charlist("Bearer #{access_token}")}, + {'User-Agent', github_user_agent} + ]}, + [], + [] + ) do + {:ok, {{_, 200, 'OK'}, _, user_details}} -> + %{"login" => username, "id" => github_id} = + Jason.decode!(String.Chars.to_string(user_details)) + + %Player{id: id} = + Repo.insert!(%Player{github_id: github_id, username: username}, + on_conflict: [set: [github_id: github_id]], + conflict_target: :github_id + ) + + {200, + %{ + success: true, + jwt: + Token.generate_and_sign!(%{ + "uid" => id + }) + }} + + _ -> + {400, %{errors: "Access token was incorrect. Try again."}} + end + + _ -> + {400, %{errors: "Failed to retrieve token from GitHub. Try again."}} + end + end +end |