From 135ce3a5d6f2930191e37b79ca8465aefaf84867 Mon Sep 17 00:00:00 2001 From: Mitchell Gerber Date: Mon, 1 Jan 2018 18:55:00 -0600 Subject: [PATCH] authentication with persistence working --- .exguard.exs | 7 ++ README.md | 18 +++-- lib/myapp/auth/auth.ex | 1 - lib/myapp/battle_net/auth.ex | 19 +---- lib/myapp/battle_net/user.ex | 28 +++++-- lib/myapp/data/user.ex | 81 +++++++++++++++++++ lib/myapp/jwt.ex | 12 ++- lib/myapp/repo.ex | 1 + .../controllers/battle_net_controller.ex | 15 ++-- lib/myapp_web/controllers/user_controller.ex | 1 + lib/myapp_web/response.ex | 5 ++ mix.exs | 5 +- mix.lock | 3 + .../migrations/20180101200459_create_user.exs | 14 ++++ 14 files changed, 172 insertions(+), 38 deletions(-) create mode 100644 .exguard.exs create mode 100644 lib/myapp/data/user.ex create mode 100644 priv/repo/migrations/20180101200459_create_user.exs diff --git a/.exguard.exs b/.exguard.exs new file mode 100644 index 0000000..0c6caaa --- /dev/null +++ b/.exguard.exs @@ -0,0 +1,7 @@ +use ExGuard.Config + +guard("dialyzer") +|> command("mix dialyzer") +|> watch(~r{\.(erl|ex|exs|eex|xrl|yrl)\z}i) +|> ignore(~r{deps}) +|> notification(:off) diff --git a/README.md b/README.md index a1a0bc3..3a3f51f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MyApp +# Classic WoW Forums ## Generate a new app `mix phx.new myapp --module MyApp --no-brunch --no-html --database postgres` @@ -14,11 +14,11 @@ Ready to run in production? Please [check our deployment guides](http://www.phoe # Ecto -## Create table -`mix ecto.gen.migration create_user` -`mix ecto.migrate` +## Create new database table +- `mix ecto.gen.migration create_user` +- `mix ecto.migrate` -## Prod +## Production Running app `PORT=80 MIX_ENV=prod mix phx.server` @@ -26,7 +26,7 @@ Running app - `MIX_ENV=prod mix ecto.create` - `MIX_ENV=prod mix ecto.migrate` -## Installing Elixir on C9 +# Installing Elixir on C9 ``` # for some reason C9 complains this file is missing when it tries to remove couchdb sudo touch /etc/init.d/couchdb @@ -40,3 +40,9 @@ sudo apt-get install esl-erlang sudo apt-get install elixir mix local.hex ``` + +# Using Dialyzer for type checking +- [Setup with Phoenix](https://github.com/jeremyjh/dialyxir/wiki/Phoenix-Dialyxir-Quickstart) +- Uses [ExGuard](https://github.com/slashmili/ex_guard) to run every time a file is changed. +- Run `mix guard` to start watching files. +- Check out `.exguard.exs` for configuration. diff --git a/lib/myapp/auth/auth.ex b/lib/myapp/auth/auth.ex index 6227502..82d6f30 100644 --- a/lib/myapp/auth/auth.ex +++ b/lib/myapp/auth/auth.ex @@ -21,7 +21,6 @@ defmodule MyApp.Guardian do # the resource id so here we'll rely on that to look it up. id = claims["sub"] # resource = MyApp.get_resource_by_id(id) - IO.inspect(claims) resource = id {:ok, resource} end diff --git a/lib/myapp/battle_net/auth.ex b/lib/myapp/battle_net/auth.ex index fbc28db..114992b 100644 --- a/lib/myapp/battle_net/auth.ex +++ b/lib/myapp/battle_net/auth.ex @@ -4,7 +4,8 @@ defmodule MyApp.BattleNet.Auth do def token_uri, do: "https://us.battle.net/oauth/token" - def get_token(code) do + @spec get_access_token(String.t) :: {:ok, String.t} | {:error, String.t} + def get_access_token(code) do client_id = Application.get_env(:myapp, :bnet_client_id) client_secret = Application.get_env(:myapp, :bnet_client_secret) redirect_uri = Application.get_env(:myapp, :bnet_redirect_uri) @@ -14,28 +15,16 @@ defmodule MyApp.BattleNet.Auth do HTTPoison.request(:post, token_uri, get_req_body(code), [], req_options) |> parse_body |> parse_token - |> validate_user - |> generate_jwt end defp parse_body({:error, err}), do: {:error, err} defp parse_body({:ok, %HTTPoison.Response{body: body}}), do: Poison.decode(body) defp parse_token({:ok, %{"access_token" => token}}), do: {:ok, token} - defp parse_token({:ok, %{"error" => err}}), do: {:error, err} + defp parse_token({:ok, %{"error" => error}}), do: {:error, error} defp parse_token({:error, err}), do: {:error, "Authentication error"} - defp validate_user({:error, err}), do: {:error, err} - defp validate_user({:ok, token}), do: User.get_user(token) - - defp generate_jwt({:error, err}), do: {:error, err} - defp generate_jwt({:ok, user}) do - case JWT.get_jwt(user, user) do - {:ok, token} -> {:ok, Map.merge(user, %{"token" => token})} - {:error, err} -> {:error, err} - end - end - + @spec get_req_body(String.t) :: tuple defp get_req_body(code) do redirect_uri = Application.get_env(:myapp, :bnet_redirect_uri) {:form, [ diff --git a/lib/myapp/battle_net/user.ex b/lib/myapp/battle_net/user.ex index d1dc8fe..012f2c2 100644 --- a/lib/myapp/battle_net/user.ex +++ b/lib/myapp/battle_net/user.ex @@ -1,16 +1,32 @@ defmodule MyApp.BattleNet.User do - defstruct id: nil, battletag: nil + + @type battle_net_user :: %{"battle_net_id": integer, "battletag": String.t, "access_token": String.t} def api_url, do: "https://us.api.battle.net" - def get_user(access_token) do - case HTTPoison.get(resource_url("account/user", access_token)) do - {:ok, %HTTPoison.Response{body: body}} -> {:ok, Poison.decode!(body, as: Battlenet.User)} - {:error, err} -> {:error, err} + # grab user information from battle net api - use token for auth + @spec get_user(String.t | {atom, any}) :: {:ok, battle_net_user} | {:error, any} + def get_user(access_token) when is_binary(access_token) do + HTTPoison.get(resource_url("account/user", access_token)) + |> parse_user_response(access_token) + end + def get_user({:ok, access_token}), do: get_user(access_token) + def get_user({:error, error}), do: {:error, error} + + defp parse_user_response({:error, error}, _), do: {:error, error} + defp parse_user_response({:ok, %HTTPoison.Response{body: body}}, access_token) do + case Poison.decode(body) do + {:ok, user} -> + user = user + |> Map.merge(%{"access_token" => access_token}) # add access token to return map + |> Map.put("battle_net_id", Map.get(user, "id")) # change id key to battle_net_id + |> Map.delete("id") # remove id key + {:ok, user} + {:error, error} -> {:error, error} end end defp resource_url(path, access_token) do - "#{api_url}/#{path}?access_token=#{access_token}" + "#{api_url()}/#{path}?access_token=#{access_token}" end end diff --git a/lib/myapp/data/user.ex b/lib/myapp/data/user.ex new file mode 100644 index 0000000..2b229ef --- /dev/null +++ b/lib/myapp/data/user.ex @@ -0,0 +1,81 @@ +defmodule MyApp.Data.User do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + alias MyApp.Repo + alias MyApp.Data + + @derive {Poison.Encoder, except: [:__meta__]} + schema "user" do + field :battle_net_id, :integer + field :battletag, :string + timestamps() + end + + def changeset(user, params \\ %{}) do + user + |> cast(params, [:battle_net_id, :battletag]) + |> validate_required([:battle_net_id, :battletag]) + |> unique_constraint(:battle_net_id) + end + + @spec get_user(integer) :: nil | map + defp get_user(battle_net_id) do + query = from u in "user", + where: u.battle_net_id == ^battle_net_id, + select: [:id, :battle_net_id, :battletag] + Repo.one(query) + end + + # insert user info in database - if not exists - update battletag if it has changed + @spec upsert_user(%{"battle_net_id": integer, "battletag": String.t} | tuple) :: {:ok, map} | {:error, any} + def upsert_user(params) when is_map(params) do + # check for current user in database + case get_user(Map.get(params, "battle_net_id")) do + nil -> insert_user(params) + user -> + # update user if battletag has changed + if Map.get(user, :battletag) != Map.get(params, "battletag") do + update_battletag(user, params) + else + {:ok, user} + end + end + |> add_access_token(Map.get(params, "access_token")) + end + def upsert_user({:ok, params}), do: upsert_user(params) + def upsert_user({:error, error}), do: {:error, error} + + # need to add token back to map because we don't store it in the database + defp add_access_token({:error, error}, _), do: {:error, error} + defp add_access_token({:ok, user}, access_token) do + {:ok, Map.merge(user, %{access_token: access_token})} + end + + defp insert_user(params) do + cs = changeset(%Data.User{}, params) + cs + |> Repo.insert + |> process_insert_or_update + end + + # it's possible for a user's battle tag to change - if so update it + defp update_battletag(user, params) do + cs = Data.User.changeset(Map.merge(%Data.User{}, user), %{battletag: Map.get(params, "battletag")}) + cs + |> Repo.update + |> process_insert_or_update + end + + defp process_insert_or_update({:error, changeset}), do: {:error, map_changeset(changeset)} + defp process_insert_or_update({:ok, user}) do + {:ok, Map.take(user, [:id, :battle_net_id, :battletag])} # only grab the fields we need + end + + defp map_changeset(changeset) do + Enum.map(changeset.errors, fn {key, val} -> + %{key => elem(val, 0)} + end) + end + +end diff --git a/lib/myapp/jwt.ex b/lib/myapp/jwt.ex index e014923..7a24344 100644 --- a/lib/myapp/jwt.ex +++ b/lib/myapp/jwt.ex @@ -4,11 +4,15 @@ defmodule MyApp.JWT do # ~1 year defp tokenTTL(), do: {52, :weeks} - def get_jwt(user, claims) do - case Guardian.encode_and_sign(user, claims, ttl: tokenTTL()) do - {:ok, token, _claims} -> {:ok, token} - {:error, _token, _claims} -> {:error, "JWT error"} + @spec add_jwt(map | {atom, any}) :: {:ok, map} | {:error, String.t} + def add_jwt(user) when is_map(user) do + case Guardian.encode_and_sign(user, user, ttl: tokenTTL()) do + {:ok, token, _claims} -> {:ok, Map.merge(user, %{token: token})} + {:error, error} -> {:error, error} end end + def add_jwt({:ok, user}), do: add_jwt(user) + def add_jwt({:error, error}), do: {:error, error} + end diff --git a/lib/myapp/repo.ex b/lib/myapp/repo.ex index 6ecbf89..181388e 100644 --- a/lib/myapp/repo.ex +++ b/lib/myapp/repo.ex @@ -1,5 +1,6 @@ defmodule MyApp.Repo do use Ecto.Repo, otp_app: :myapp + @dialyzer {:nowarn_function, rollback: 1} @doc """ Dynamically loads the repository url from the diff --git a/lib/myapp_web/controllers/battle_net_controller.ex b/lib/myapp_web/controllers/battle_net_controller.ex index 2cbfe59..ab8327a 100644 --- a/lib/myapp_web/controllers/battle_net_controller.ex +++ b/lib/myapp_web/controllers/battle_net_controller.ex @@ -1,16 +1,21 @@ defmodule MyAppWeb.BattleNetController do use MyAppWeb, :controller alias MyAppWeb.Response - alias MyApp.BattleNet.Auth + alias MyApp.BattleNet + alias MyApp.Data + alias MyApp.JWT # https://us.battle.net/oauth/authorize?redirect_uri=https://localhost/api/battlenet/authorize&scope=wow.profile&client_id=vxqv32fddxsy6cmk6259amtymbuzmfrq&response_type=code + @spec authorize(map, map) :: any def authorize(conn, %{"code" => code}) when not is_nil(code) do - {output, status} = case Auth.get_token(code) do - {:ok, token} -> {token, 200} - {:error, err} -> {err, 400} - end + {output, status} = code + |> BattleNet.Auth.get_access_token + |> BattleNet.User.get_user + |> Data.User.upsert_user + |> JWT.add_jwt + |> Response.put_resp conn |>put_status(status) diff --git a/lib/myapp_web/controllers/user_controller.ex b/lib/myapp_web/controllers/user_controller.ex index 5246535..63b1597 100644 --- a/lib/myapp_web/controllers/user_controller.ex +++ b/lib/myapp_web/controllers/user_controller.ex @@ -2,6 +2,7 @@ defmodule MyAppWeb.UserController do use MyAppWeb, :controller alias MyAppWeb.Response + @spec index(map, map) :: any def index(conn, params) do IO.inspect(conn) IO.inspect(params) diff --git a/lib/myapp_web/response.ex b/lib/myapp_web/response.ex index 0da7083..1ad30ee 100644 --- a/lib/myapp_web/response.ex +++ b/lib/myapp_web/response.ex @@ -19,4 +19,9 @@ defmodule MyAppWeb.Response do Phoenix.Controller.json(conn, output) end + + # generatic function for converting data to response code + @spec put_resp({:ok, any} | {:error, any}) :: {any, integer} + def put_resp({:ok, data}), do: {data, 200} + def put_resp({:error, error}), do: {error, 400} end diff --git a/mix.exs b/mix.exs index cd2b8d5..e0f3e38 100644 --- a/mix.exs +++ b/mix.exs @@ -10,7 +10,8 @@ defmodule MyApp.Mixfile do compilers: [:phoenix, :gettext] ++ Mix.compilers, start_permanent: Mix.env == :prod, aliases: aliases(), - deps: deps() + deps: deps(), + dialyzer: [plt_add_deps: :transitive] ] end @@ -43,6 +44,8 @@ defmodule MyApp.Mixfile do {:argon2_elixir, "~> 1.2"}, {:guardian, "~> 1.0"}, {:httpoison, "~> 0.13"}, + {:dialyxir, "~> 0.5", only: [:dev], runtime: false}, + {:ex_guard, "~> 1.3", only: :dev}, ] end diff --git a/mix.lock b/mix.lock index ecfcbb8..1037af6 100644 --- a/mix.lock +++ b/mix.lock @@ -7,8 +7,11 @@ "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [], [], "hexpm"}, "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [], [], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [], [], "hexpm"}, "ecto": {:hex, :ecto, "2.2.7", "2074106ff4a5cd9cb2b54b12ca087c4b659ddb3f6b50be4562883c1d763fb031", [], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, "elixir_make": {:hex, :elixir_make, "0.4.0", "992f38fabe705bb45821a728f20914c554b276838433349d4f2341f7a687cddf", [], [], "hexpm"}, + "ex_guard": {:hex, :ex_guard, "1.3.0", "0f5c50b90a7e4c599b45d02448ae53eabffc33adb7bfdfc5f5507715e7662a25", [], [{:fs, "~> 0.9", [hex: :fs, repo: "hexpm", optional: false]}], "hexpm"}, + "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [], [], "hexpm"}, "gettext": {:hex, :gettext, "0.14.0", "1a019a2e51d5ad3d126efe166dcdf6563768e5d06c32a99ad2281a1fa94b4c72", [], [], "hexpm"}, "guardian": {:hex, :guardian, "1.0.0", "21bae2a8c0b4ed5943d9da0c6aeb16e52874c1f675de5d7920ae35471c6263f9", [], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}, {:uuid, ">= 1.1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/priv/repo/migrations/20180101200459_create_user.exs b/priv/repo/migrations/20180101200459_create_user.exs new file mode 100644 index 0000000..c1dba3c --- /dev/null +++ b/priv/repo/migrations/20180101200459_create_user.exs @@ -0,0 +1,14 @@ +defmodule MyApp.Repo.Migrations.CreateUser do + use Ecto.Migration + + def change do + create table(:user) do + add :battle_net_id, :integer + add :battletag, :string + + timestamps() + end + + create unique_index(:user, [:battle_net_id]) + end +end