From d71138900c8878843e231164d827cd7cc5eb0cda Mon Sep 17 00:00:00 2001 From: Logan Hunt Date: Tue, 17 Jan 2023 18:24:16 -0700 Subject: [PATCH] Github Oauth --- config/config.exs | 7 + config/runtime.exs | 23 ++ lib/chessh.ex | 5 - lib/chessh/application.ex | 11 +- lib/chessh/schema/key.ex | 9 +- lib/chessh/schema/player.ex | 4 +- lib/chessh/web/token.ex | 3 + lib/chessh/web/web.ex | 262 ++++++++++++++++++ mix.exs | 6 +- mix.lock | 11 + .../20221219082326_create_player.exs | 4 +- .../migrations/20221219215005_add_keys.exs | 2 +- 12 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 config/runtime.exs delete mode 100644 lib/chessh.ex create mode 100644 lib/chessh/web/token.ex create mode 100644 lib/chessh/web/web.ex diff --git a/config/config.exs b/config/config.exs index ad54ebf..d974be4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,4 +18,11 @@ config :chessh, RateLimits, player_session_message_burst_ms: 500, player_session_message_burst_rate: 8 +config :chessh, Web, + port: 8080, + github_oauth_login_url: "https://github.com/login/oauth/access_token", + github_user_api_url: "https://api.github.com/user" + +config :joken, default_signer: "secret" + import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..ec1e7dc --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,23 @@ +import Config + +config :chessh, Web, + github_client_id: System.get_env("GITHUB_CLIENT_ID"), + github_client_secret: System.get_env("GITHUB_CLIENT_SECRET"), + github_user_agent: System.get_env("GITHUB_USER_AGENT") + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] + + config :chessh, Chessh.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 +end 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/schema/key.ex b/lib/chessh/schema/key.ex index df790e2..7ed9c55 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/^(?!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..d9b2a9e 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) @@ -26,7 +28,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/web/token.ex b/lib/chessh/web/token.ex new file mode 100644 index 0000000..8ddafe2 --- /dev/null +++ b/lib/chessh/web/token.ex @@ -0,0 +1,3 @@ +defmodule Chessh.Web.Token do + use Joken.Config +end diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex new file mode 100644 index 0000000..c5fe8bc --- /dev/null +++ b/lib/chessh/web/web.ex @@ -0,0 +1,262 @@ +defmodule Chessh.Web.Endpoint do + alias Chessh.{Player, Repo, Key} + alias Chessh.Web.Token + use Plug.Router + require Logger + + 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} = + 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 + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + put "/player/password" do + jwt = + Enum.find_value(conn.req_headers, fn {header, value} -> + if header === "authorization", do: value + end) + + {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + + player = Repo.get(Player, uid) + + {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 + + post "/player/login" do + {status, body} = + case conn.body_params do + %{"username" => username, "password" => password} -> + player = Repo.get_by(Player, username: username) + + case Player.valid_password?(player, password) do + true -> + { + 200, + %{ + token: + Token.generate_and_sign!(%{ + "uid" => player.id + }) + } + } + + _ -> + { + 400, + %{ + errors: "Invalid credentials" + } + } + end + + _ -> + { + 400, + %{errors: "Username and password must be defined"} + } + end + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + + post "/player/keys" do + jwt = + Enum.find_value(conn.req_headers, fn {header, value} -> + if header === "authorization", do: value + end) + + {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + + {status, body} = + case conn.body_params do + %{"key" => key, "name" => name} -> + case Key.changeset(%Key{}, %{player_id: uid, key: key, name: name}) |> Repo.insert() do + {:ok, _new_key} -> + { + 200, + %{ + success: true + } + } + + {:error, %{valid?: false} = changeset} -> + { + 400, + %{ + errors: format_errors(changeset) + } + } + 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/: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: keys})) + end + + delete "/keys/:id" do + %{"id" => key_id} = conn.path_params + + jwt = + Enum.find_value(conn.req_headers, fn {header, value} -> + if header === "authorization", do: value + end) + + {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + + key = Repo.get(Key, key_id) + + {status, body} = + if key && uid == 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 +end diff --git a/mix.exs b/mix.exs index e4b0631..586120e 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,7 @@ defmodule Chessh.MixProject do def application do [ mod: {Chessh.Application, []}, - extra_applications: [:logger, :crypto, :syn, :ssh] + extra_applications: [:logger, :crypto, :syn, :ssh, :plug_cowboy, :inets, :ssl] ] end @@ -34,7 +34,9 @@ defmodule Chessh.MixProject do {:bcrypt_elixir, "~> 3.0"}, {:hammer, "~> 6.1"}, {:syn, "~> 3.3"}, - {:jason, "~> 1.3"} + {:jason, "~> 1.3"}, + {:plug_cowboy, "~> 2.2"}, + {:joken, "~> 2.5"} ] end diff --git a/mix.lock b/mix.lock index cd09aef..d5b54b8 100644 --- a/mix.lock +++ b/mix.lock @@ -4,6 +4,9 @@ "chess": {:hex, :chess, "0.4.1", "34c04abed2db81e0c56476c8e74fd85ef4e1bae23a4cd528e0ce8a052ada976f", [:mix], [], "hexpm", "692e0def99dc25af4af2413839a4605a2a0a713c2646f9afcf3a47c76a6de43d"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"}, @@ -13,9 +16,17 @@ "esshd": {:hex, :esshd, "0.2.1", "cded6a329c32bc3b3c15828bcd34203227bbef310db3c39a6f3c55cf5b29cd34", [:mix], [], "hexpm", "b058b56af53aba1c23522d72a3c39ab7f302e509af1c0ba1a748f00d93053c4d"}, "hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, + "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "ueberauth": {:hex, :ueberauth, "0.10.3", "4a3bd7ab7b5d93d301d264f0f6858392654ee92171f4437d067d1ae227c051d9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "1394f36a6c64e97f2038cf95228e7e52b4cb75417962e30418fbe9902b30e6d3"}, "uef": {:hex, :uef, "2.6.0", "0aa813d125c429e48c1d5abbab89837d8b7bd0499e6ee2f7dc9cc4287a475cfd", [:rebar3], [], "hexpm", "1585cba305dc7c0a3f75aeab15937b324184673d2922148b432edf9beba73d62"}, } diff --git a/priv/repo/migrations/20221219082326_create_player.exs b/priv/repo/migrations/20221219082326_create_player.exs index 4c0c553..8044344 100644 --- a/priv/repo/migrations/20221219082326_create_player.exs +++ b/priv/repo/migrations/20221219082326_create_player.exs @@ -5,11 +5,13 @@ defmodule Chessh.Repo.Migrations.CreatePlayer do execute("CREATE EXTENSION IF NOT EXISTS citext", "") create table(:players) do + add(:github_id, :integer, null: false) add(:username, :citext, null: false) - add(:hashed_password, :string, null: false) + add(:hashed_password, :string, null: true) timestamps() end create(unique_index(:players, [:username])) + create(unique_index(:players, [:github_id])) end end diff --git a/priv/repo/migrations/20221219215005_add_keys.exs b/priv/repo/migrations/20221219215005_add_keys.exs index 06ea2c5..cfa61a5 100644 --- a/priv/repo/migrations/20221219215005_add_keys.exs +++ b/priv/repo/migrations/20221219215005_add_keys.exs @@ -6,7 +6,7 @@ defmodule Chessh.Repo.Migrations.AddKeys do add(:key, :text, null: false) add(:name, :string, null: false) - add(:player_id, references(:players)) + add(:player_id, references(:players), null: false) timestamps() end