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 | |
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')
-rw-r--r-- | lib/chessh.ex | 5 | ||||
-rw-r--r-- | lib/chessh/application.ex | 11 | ||||
-rw-r--r-- | lib/chessh/release.ex | 18 | ||||
-rw-r--r-- | lib/chessh/schema/key.ex | 11 | ||||
-rw-r--r-- | lib/chessh/schema/player.ex | 13 | ||||
-rw-r--r-- | lib/chessh/schema/player_session.ex | 8 | ||||
-rw-r--r-- | lib/chessh/ssh/client/client.ex | 13 | ||||
-rw-r--r-- | lib/chessh/ssh/client/game/game.ex | 37 | ||||
-rw-r--r-- | lib/chessh/ssh/client/menu.ex | 10 | ||||
-rw-r--r-- | lib/chessh/ssh/daemon.ex | 8 | ||||
-rw-r--r-- | lib/chessh/web/token.ex | 5 | ||||
-rw-r--r-- | lib/chessh/web/web.ex | 289 |
12 files changed, 391 insertions, 37 deletions
diff --git a/lib/chessh.ex b/lib/chessh.ex deleted file mode 100644 index 82d0cfc..0000000 --- a/lib/chessh.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Chessh do - def hello() do - :world - end -end diff --git a/lib/chessh/application.ex b/lib/chessh/application.ex index 4b03169..5538e39 100644 --- a/lib/chessh/application.ex +++ b/lib/chessh/application.ex @@ -15,7 +15,16 @@ defmodule Chessh.Application do end def start(_, _) do - children = [Chessh.Repo, Chessh.SSH.Daemon] + children = [ + Chessh.Repo, + Chessh.SSH.Daemon, + Plug.Cowboy.child_spec( + scheme: :http, + plug: Chessh.Web.Endpoint, + options: [port: Application.get_env(:chessh, Web)[:port]] + ) + ] + opts = [strategy: :one_for_one, name: Chessh.Supervisor] with {:ok, pid} <- Supervisor.start_link(children, opts) do diff --git a/lib/chessh/release.ex b/lib/chessh/release.ex new file mode 100644 index 0000000..82f1807 --- /dev/null +++ b/lib/chessh/release.ex @@ -0,0 +1,18 @@ +defmodule Chessh.Release do + @app :chessh + + def migrate do + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.load(@app) + Application.fetch_env!(@app, :ecto_repos) + end +end diff --git a/lib/chessh/schema/key.ex b/lib/chessh/schema/key.ex index df790e2..856aa8e 100644 --- a/lib/chessh/schema/key.ex +++ b/lib/chessh/schema/key.ex @@ -11,13 +11,20 @@ defmodule Chessh.Key do timestamps() end + defimpl Jason.Encoder, for: Chessh.Key do + def encode(value, opts) do + Jason.Encode.map(Map.take(value, [:id, :key, :name]), opts) + end + end + def changeset(key, attrs) do key - |> cast(update_encode_key(attrs, :key), [:key]) + |> cast(update_encode_key(attrs, :key), [:key, :player_id]) |> cast(attrs, [:name]) |> validate_required([:key, :name]) - |> validate_format(:key, ~r/[\-\w\d]+ [^ ]+$/, message: "invalid public ssh key") + |> validate_format(:key, ~r/^[\-\w\d]+ [^ ]+$/, message: "invalid public ssh key") |> validate_format(:key, ~r/^(?!ssh-dss).+/, message: "DSA keys are not supported") + |> unique_constraint([:player_id, :key], message: "Player already has that key") end def encode_key(key) do diff --git a/lib/chessh/schema/player.ex b/lib/chessh/schema/player.ex index 074ea4e..f12ad9e 100644 --- a/lib/chessh/schema/player.ex +++ b/lib/chessh/schema/player.ex @@ -5,6 +5,8 @@ defmodule Chessh.Player do @derive {Inspect, except: [:password]} schema "players" do + field(:github_id, :integer) + field(:username, :string) field(:password, :string, virtual: true) @@ -19,6 +21,15 @@ defmodule Chessh.Player do timestamps() end + defimpl Jason.Encoder, for: Chessh.Player do + def encode(value, opts) do + Jason.Encode.map( + Map.take(value, [:id, :github_id, :username, :created_at, :updated_at]), + opts + ) + end + end + def authentications_changeset(player, attrs) do player |> cast(attrs, [:authentications]) @@ -26,7 +37,7 @@ defmodule Chessh.Player do def registration_changeset(player, attrs, opts \\ []) do player - |> cast(attrs, [:username, :password]) + |> cast(attrs, [:username, :password, :github_id]) |> validate_username() |> validate_password(opts) end diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex index b16519f..f12387a 100644 --- a/lib/chessh/schema/player_session.ex +++ b/lib/chessh/schema/player_session.ex @@ -108,4 +108,12 @@ defmodule Chessh.PlayerSession do 3_000 -> false end end + + def close_all_player_sessions(player) do + player_sessions = Repo.all(from(p in PlayerSession, where: p.player_id == ^player.id)) + + Enum.map(player_sessions, fn session -> + :syn.publish(:player_sessions, {:session, session.id}, :session_closed) + end) + end end diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex index 72bbb66..3aaed07 100644 --- a/lib/chessh/ssh/client/client.ex +++ b/lib/chessh/ssh/client/client.ex @@ -11,8 +11,6 @@ defmodule Chessh.SSH.Client do @min_terminal_width 64 @min_terminal_height 38 - @max_terminal_width 220 - @max_terminal_height 100 defmodule State do defstruct tui_pid: nil, @@ -163,13 +161,10 @@ defmodule Chessh.SSH.Client do end defp get_terminal_dim_msg(width, height) do - case {height > @max_terminal_height, height < @min_terminal_height, - width > @max_terminal_width, width < @min_terminal_width} do - {true, _, _, _} -> {true, @clear_codes ++ ["The terminal height is too large."]} - {_, true, _, _} -> {true, @clear_codes ++ ["The terminal height is too small."]} - {_, _, true, _} -> {true, @clear_codes ++ ["The terminal width is too large"]} - {_, _, _, true} -> {true, @clear_codes ++ ["The terminal width is too small."]} - {false, false, false, false} -> {false, nil} + case {height < @min_terminal_height, width < @min_terminal_width} do + {true, _} -> {true, @clear_codes ++ ["The terminal height is too small."]} + {_, true} -> {true, @clear_codes ++ ["The terminal width is too small."]} + {false, false} -> {false, nil} end end diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex index 2ee6dca..65b9d10 100644 --- a/lib/chessh/ssh/client/game/game.ex +++ b/lib/chessh/ssh/client/game/game.ex @@ -35,16 +35,23 @@ defmodule Chessh.SSH.Client.Game do def init([ %State{ color: color, - game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id} + game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id}, + player_session: %{player_id: player_id} } = state | tail ]) when is_nil(color) do + {is_dark, is_light} = {player_id == dark_player_id, player_id == light_player_id} + new_state = - case {is_nil(dark_player_id), is_nil(light_player_id)} do - {true, false} -> %State{state | color: :dark} - {false, true} -> %State{state | color: :light} - {_, _} -> %State{state | color: Enum.random([:light, :dark])} + if is_dark || is_light do + %State{state | color: if(is_light, do: :light, else: :dark)} + else + case {is_nil(dark_player_id), is_nil(light_player_id)} do + {true, false} -> %State{state | color: :dark} + {false, true} -> %State{state | color: :light} + {_, _} -> %State{state | color: :light} + end end init([new_state | tail]) @@ -89,18 +96,22 @@ defmodule Chessh.SSH.Client.Game do end binbo_pid = initialize_game(game_id, fen) - send(client_pid, {:send_to_ssh, Utils.clear_codes()}) new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) - new_state = %State{ - state - | binbo_pid: binbo_pid, - color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark), - game: new_game - } + player_color = + if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark) - {:ok, new_state} + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + {:ok, + %State{ + state + | binbo_pid: binbo_pid, + color: player_color, + game: new_game, + flipped: player_color == :dark + }} end def init([ diff --git a/lib/chessh/ssh/client/menu.ex b/lib/chessh/ssh/client/menu.ex index 6c96cc2..b69340c 100644 --- a/lib/chessh/ssh/client/menu.ex +++ b/lib/chessh/ssh/client/menu.ex @@ -63,7 +63,6 @@ defmodule Chessh.SSH.Client.Menu do %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}} end) ++ [ - {"Settings", {}}, {"Help", {}} ] end @@ -130,7 +129,14 @@ defmodule Chessh.SSH.Client.Menu do fn {i, {option, _}} -> [ ANSI.cursor(y + length(logo_lines) + i + 1, x), - if(i == selected, do: ANSI.format([:bright, :light_cyan, "+ #{option}"]), else: option) + if(i == selected, + do: + ANSI.format_fragment( + [:light_cyan, :bright, "> #{option} <", :reset], + true + ), + else: option + ) ] end ) ++ [ANSI.home()] diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex index e122f9a..6be6732 100644 --- a/lib/chessh/ssh/daemon.ex +++ b/lib/chessh/ssh/daemon.ex @@ -47,12 +47,12 @@ defmodule Chessh.SSH.Daemon do :disconnect end - x -> + authed_or_disconnect -> PlayerSession.update_sessions_and_player_satisfies(username, fn _player -> - x + authed_or_disconnect end) - x + authed_or_disconnect end end @@ -92,7 +92,7 @@ defmodule Chessh.SSH.Daemon do def handle_info(_, state), do: {:noreply, state} defp on_disconnect(_reason) do - Logger.debug("#{inspect(self())} disconnected") + Logger.info("#{inspect(self())} disconnected") Repo.delete_all( from(p in PlayerSession, diff --git a/lib/chessh/web/token.ex b/lib/chessh/web/token.ex new file mode 100644 index 0000000..c0ac740 --- /dev/null +++ b/lib/chessh/web/token.ex @@ -0,0 +1,5 @@ +defmodule Chessh.Web.Token do + use Joken.Config + + def token_config, do: default_claims(default_exp: 12 * 60 * 60) +end 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 |