From e28abc0a0a58d2316076dcbfc75ee32417eaf9ca Mon Sep 17 00:00:00 2001
From: Chris McCord
Date: Wed, 8 Sep 2021 10:58:32 -0400
Subject: [PATCH] Add github login
---
assets/js/app.js | 2 +-
assets/js/phoenix_live_view | 1 +
config/dev.exs | 5 +
lib/live_beats/accounts.ex | 89 ++++++++++++
lib/live_beats/accounts/identity.ex | 42 ++++++
lib/live_beats/accounts/user.ex | 52 +++++++
lib/live_beats/github.ex | 129 ++++++++++++++++++
.../controllers/oauth_callback_controller.ex | 41 ++++++
lib/live_beats_web/controllers/user_auth.ex | 124 +++++++++++++++++
lib/live_beats_web/live/player_live.ex | 11 +-
lib/live_beats_web/live/signin_live.ex | 85 +-----------
lib/live_beats_web/router.ex | 11 +-
.../templates/layout/root.html.heex | 128 +++++++++--------
mix.exs | 3 +-
mix.lock | 1 +
.../20210905021010_create_user_auth.exs | 36 +++++
test/live_beats/accounts_test.exs | 26 ++++
.../controllers/github_callbacks_test.exs | 75 ++++++++++
.../controllers/page_controller_test.exs | 2 +-
.../controllers/user_auth_test.exs | 125 +++++++++++++++++
test/support/fixtures/accounts_fixtures.ex | 60 ++++++++
21 files changed, 899 insertions(+), 149 deletions(-)
create mode 120000 assets/js/phoenix_live_view
create mode 100644 lib/live_beats/accounts.ex
create mode 100644 lib/live_beats/accounts/identity.ex
create mode 100644 lib/live_beats/accounts/user.ex
create mode 100644 lib/live_beats/github.ex
create mode 100644 lib/live_beats_web/controllers/oauth_callback_controller.ex
create mode 100644 lib/live_beats_web/controllers/user_auth.ex
create mode 100644 priv/repo/migrations/20210905021010_create_user_auth.exs
create mode 100644 test/live_beats/accounts_test.exs
create mode 100644 test/live_beats_web/controllers/github_callbacks_test.exs
create mode 100644 test/live_beats_web/controllers/user_auth_test.exs
create mode 100644 test/support/fixtures/accounts_fixtures.ex
diff --git a/assets/js/app.js b/assets/js/app.js
index a35f60b..571300d 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -23,7 +23,7 @@ import Alpine from "alpinejs"
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
-import {LiveSocket} from "phoenix_live_view"
+import {LiveSocket} from "./phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
diff --git a/assets/js/phoenix_live_view b/assets/js/phoenix_live_view
new file mode 120000
index 0000000..41aeefb
--- /dev/null
+++ b/assets/js/phoenix_live_view
@@ -0,0 +1 @@
+/Users/chris/oss/phoenix_live_view/assets/js/phoenix_live_view
\ No newline at end of file
diff --git a/config/dev.exs b/config/dev.exs
index 0ecd515..6dbbf40 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -1,5 +1,10 @@
import Config
+config :live_beats, :github, %{
+ client_id: "83806139172df82d4ccc",
+ client_secret: System.fetch_env!("LIVE_BEATS_GITHUB_CLIENT_SECRET"),
+}
+
# Configure your database
config :live_beats, LiveBeats.Repo,
username: "postgres",
diff --git a/lib/live_beats/accounts.ex b/lib/live_beats/accounts.ex
new file mode 100644
index 0000000..feb9b83
--- /dev/null
+++ b/lib/live_beats/accounts.ex
@@ -0,0 +1,89 @@
+defmodule LiveBeats.Accounts do
+ import Ecto.Query
+ import Ecto.Changeset
+
+ alias LiveBeats.Repo
+ alias LiveBeats.Accounts.{User, Identity}
+
+ @admin_emails ["chris@chrismccord.com"]
+
+ def list_users(opts) do
+ Repo.all(from u in User, limit: ^Keyword.fetch!(opts, :limit))
+ end
+
+ def admin?(%User{} = user), do: user.email in @admin_emails
+
+ ## Database getters
+
+ @doc """
+ Gets a user by email.
+
+ ## Examples
+
+ iex> get_user_by_email("foo@example.com")
+ %User{}
+
+ iex> get_user_by_email("unknown@example.com")
+ nil
+
+ """
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.get_by(User, email: email)
+ end
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ ## User registration
+
+ @doc """
+ Registers a user from their GithHub information.
+ """
+ def register_github_user(primary_email, info, emails, token) do
+ if user = get_user_by_provider(:github, primary_email) do
+ update_github_token(user, token)
+ else
+ info
+ |> User.github_registration_changeset(primary_email, emails, token)
+ |> Repo.insert()
+ end
+ end
+
+ def get_user_by_provider(provider, email) when provider in [:github] do
+ query =
+ from(u in User,
+ join: i in assoc(u, :identities),
+ where:
+ i.provider == ^to_string(provider) and
+ fragment("lower(?)", u.email) == ^String.downcase(email)
+ )
+
+ Repo.one(query)
+ end
+
+ defp update_github_token(%User{} = user, new_token) do
+ identity =
+ Repo.one!(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github"))
+
+ {:ok, _} =
+ identity
+ |> change()
+ |> put_change(:provider_token, new_token)
+ |> Repo.update()
+
+ {:ok, Repo.preload(user, :identities, force: true)}
+ end
+end
diff --git a/lib/live_beats/accounts/identity.ex b/lib/live_beats/accounts/identity.ex
new file mode 100644
index 0000000..31004a3
--- /dev/null
+++ b/lib/live_beats/accounts/identity.ex
@@ -0,0 +1,42 @@
+defmodule LiveBeats.Accounts.Identity do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias LiveBeats.Accounts.{Identity, User}
+
+ # providers
+ @github "github"
+
+ @derive {Inspect, except: [:provider_token, :provider_meta]}
+ schema "identities" do
+ field :provider, :string
+ field :provider_token, :string
+ field :provider_email, :string
+ field :provider_login, :string
+ field :provider_name, :string, virtual: true
+ field :provider_id, :string
+ field :provider_meta, :map
+
+ belongs_to :user, User
+
+ timestamps()
+ end
+
+ @doc """
+ A user changeset for github registration.
+ """
+ def github_registration_changeset(info, primary_email, emails, token) do
+ params = %{
+ "provider_token" => token,
+ "provider_id" => to_string(info["id"]),
+ "provider_login" => info["login"],
+ "provider_name" => info["name"] || info["login"],
+ "provider_email" => primary_email,
+ }
+
+ %Identity{provider: @github, provider_meta: %{"user" => info, "emails" => emails}}
+ |> cast(params, [:provider_token, :provider_email, :provider_login, :provider_name, :provider_id])
+ |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id])
+ |> validate_length(:provider_meta, max: 10_000)
+ end
+end
diff --git a/lib/live_beats/accounts/user.ex b/lib/live_beats/accounts/user.ex
new file mode 100644
index 0000000..af572a3
--- /dev/null
+++ b/lib/live_beats/accounts/user.ex
@@ -0,0 +1,52 @@
+defmodule LiveBeats.Accounts.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias LiveBeats.Accounts.{User, Identity}
+
+ schema "users" do
+ field :email, :string
+ field :name, :string
+ field :username, :string
+ field :confirmed_at, :naive_datetime
+ field :role, :string, default: "subscriber"
+
+ has_many :identities, Identity
+
+ timestamps()
+ end
+
+ @doc """
+ A user changeset for github registration.
+ """
+ def github_registration_changeset(info, primary_email, emails, token) do
+ %{"login" => username} = info
+ identity_changeset = Identity.github_registration_changeset(info, primary_email, emails, token)
+ if identity_changeset.valid? do
+ params = %{
+ "username" => username,
+ "email" => primary_email,
+ "name" => get_change(identity_changeset, :provider_name),
+ }
+ %User{}
+ |> cast(params, [:email, :name, :username])
+ |> validate_required([:email, :name, :username])
+ |> validate_email()
+ |> put_assoc(:identities, [identity_changeset])
+ else
+ %User{}
+ |> change()
+ |> Map.put(:value?, false)
+ |> put_assoc(:identities, [identity_changeset])
+ end
+ end
+
+ defp validate_email(changeset) do
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
+ |> validate_length(:email, max: 160)
+ |> unsafe_validate_unique(:email, LiveBeats.Repo)
+ |> unique_constraint(:email)
+ end
+end
diff --git a/lib/live_beats/github.ex b/lib/live_beats/github.ex
new file mode 100644
index 0000000..168e83e
--- /dev/null
+++ b/lib/live_beats/github.ex
@@ -0,0 +1,129 @@
+defmodule LiveBeats.Github do
+ def authorize_url() do
+ state = random_string()
+ "https://github.com/login/oauth/authorize?client_id=#{client_id()}&state=#{state}&scope=user:email"
+ end
+
+ def exchange_access_token(opts) do
+ code = Keyword.fetch!(opts, :code)
+ state = Keyword.fetch!(opts, :state)
+
+ state
+ |> fetch_exchange_response(code)
+ |> fetch_user_info()
+ |> fetch_emails()
+ end
+
+ defp fetch_exchange_response(state, code) do
+ resp =
+ http(
+ "github.com",
+ "POST",
+ "/login/oauth/access_token",
+ [state: state, code: code, client_secret: secret()],
+ [{"accept", "application/json"}]
+ )
+
+ with {:ok, resp} <- resp,
+ %{"access_token" => token} <- Jason.decode!(resp) do
+ {:ok, token}
+ else
+ {:error, _reason} = err -> err
+ %{} = resp -> {:error, {:bad_response, resp}}
+ end
+ end
+
+ defp fetch_user_info({:error, _reason} = error), do: error
+ defp fetch_user_info({:ok, token}) do
+ resp =
+ http(
+ "api.github.com",
+ "GET",
+ "/user",
+ [],
+ [{"accept", "application/vnd.github.v3+json"}, {"Authorization", "token #{token}"}]
+ )
+ case resp do
+ {:ok, info} -> {:ok, %{info: Jason.decode!(info), token: token}}
+ {:error, _reason} = err -> err
+ end
+ end
+
+ defp fetch_emails({:error, _} = err), do: err
+ defp fetch_emails({:ok, user}) do
+ resp =
+ http(
+ "api.github.com",
+ "GET",
+ "/user/emails",
+ [],
+ [{"accept", "application/vnd.github.v3+json"}, {"Authorization", "token #{user.token}"}]
+ )
+ case resp do
+ {:ok, info} ->
+ emails = Jason.decode!(info)
+ {:ok, Map.merge(user, %{primary_email: primary_email(emails), emails: emails})}
+
+ {:error, _reason} = err ->
+ err
+ end
+ end
+
+ def random_string do
+ binary = <<
+ System.system_time(:nanosecond)::64,
+ :erlang.phash2({node(), self()})::16,
+ :erlang.unique_integer()::16
+ >>
+
+ binary
+ |> Base.url_encode64()
+ |> String.replace(["/", "+"], "-")
+ end
+
+ defp client_id, do: Application.fetch_env!(:live_beats, :github)[:client_id]
+ defp secret, do: Application.fetch_env!(:live_beats, :github)[:client_secret]
+
+ defp http(host, method, path, query, headers, body \\ "") do
+ {:ok, conn} = Mint.HTTP.connect(:https, host, 443)
+
+ path = path <> "?" <> URI.encode_query([{:client_id, client_id()} | query])
+
+ {:ok, conn, ref} =
+ Mint.HTTP.request(
+ conn,
+ method,
+ path,
+ headers,
+ body
+ )
+
+ receive_resp(conn, ref, nil, nil, false)
+ end
+
+ defp receive_resp(conn, ref, status, data, done?) do
+ receive do
+ message ->
+ {:ok, conn, responses} = Mint.HTTP.stream(conn, message)
+
+ {new_status, new_data, done?} =
+ Enum.reduce(responses, {status, data, done?}, fn
+ {:status, ^ref, new_status}, {_old_status, data, done?} -> {new_status, data, done?}
+ {:headers, ^ref, _headers}, acc -> acc
+ {:data, ^ref, binary}, {status, nil, done?} -> {status, binary, done?}
+ {:data, ^ref, binary}, {status, data, done?} -> {status, data <> binary, done?}
+ {:done, ^ref}, {status, data, _done?} -> {status, data, true}
+ end)
+
+ cond do
+ done? and new_status == 200 -> {:ok, new_data}
+ done? -> {:error, {new_status, new_data}}
+ !done? -> receive_resp(conn, ref, new_status, new_data, done?)
+ end
+ end
+ end
+
+ defp primary_email(emails) do
+ Enum.find(emails, fn email -> email["primary"] end)["email"] || Enum.at(emails, 0)
+ end
+end
diff --git a/lib/live_beats_web/controllers/oauth_callback_controller.ex b/lib/live_beats_web/controllers/oauth_callback_controller.ex
new file mode 100644
index 0000000..1eb59ff
--- /dev/null
+++ b/lib/live_beats_web/controllers/oauth_callback_controller.ex
@@ -0,0 +1,41 @@
+defmodule LiveBeatsWeb.OAuthCallbackController do
+ use LiveBeatsWeb, :controller
+ require Logger
+
+ alias LiveBeats.Accounts
+
+ def new(conn, %{"provider" => "github", "code" => code, "state" => state}) do
+ client = github_client(conn)
+
+ with {:ok, info} <- client.exchange_access_token(code: code, state: state),
+ %{info: info, primary_email: primary, emails: emails, token: token} = info,
+ {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do
+
+ conn
+ |> put_flash(:info, "Welcome #{user.email}")
+ |> LiveBeatsWeb.UserAuth.log_in_user(user)
+ else
+ {:error, %Ecto.Changeset{} = changeset} ->
+ Logger.debug("failed GitHub insert #{inspect(changeset.errors)}")
+
+ conn
+ |> put_flash(:error, "We were unable to fetch the necessary information from your GithHub account")
+ |> redirect(to: "/")
+
+ {:error, reason} ->
+ Logger.debug("failed GitHub exchange #{inspect(reason)}")
+
+ conn
+ |> put_flash(:error, "We were unable to contact GitHub. Please try again later")
+ |> redirect(to: "/")
+ end
+ end
+
+ def new(conn, %{"provider" => "github", "error" => "access_denied"}) do
+ redirect(conn, to: "/")
+ end
+
+ defp github_client(conn) do
+ conn.assigns[:github_client] || LiveBeats.Github
+ end
+end
diff --git a/lib/live_beats_web/controllers/user_auth.ex b/lib/live_beats_web/controllers/user_auth.ex
new file mode 100644
index 0000000..19e7bb7
--- /dev/null
+++ b/lib/live_beats_web/controllers/user_auth.ex
@@ -0,0 +1,124 @@
+defmodule LiveBeatsWeb.UserAuth do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Phoenix.LiveView
+ alias LiveBeats.Accounts
+ alias LiveBeatsWeb.Router.Helpers, as: Routes
+
+ def mount_defaults(_params, session, socket) do
+ case session do
+ %{"user_id" => user_id} ->
+ {:cont, LiveView.assign_new(socket, :current_user, fn -> Accounts.get_user!(user_id) end)}
+
+ %{} ->
+ {:cont, LiveView.assign(socket, :current_user, nil)}
+ end
+ end
+
+ @doc """
+ Logs the user in.
+
+ It renews the session ID and clears the whole session
+ to avoid fixation attacks. See the renew_session
+ function to customize this behaviour.
+
+ It also sets a `:live_socket_id` key in the session,
+ so LiveView sessions are identified and automatically
+ disconnected on log out. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def log_in_user(conn, user) do
+ user_return_to = get_session(conn, :user_return_to)
+
+ conn
+ |> renew_session()
+ |> put_session(:user_id, user.id)
+ |> put_session(:live_socket_id, "users_sessions:#{user.id}")
+ |> redirect(to: user_return_to || signed_in_path(conn))
+ end
+
+ defp renew_session(conn) do
+ conn
+ |> configure_session(renew: true)
+ |> clear_session()
+ end
+
+ @doc """
+ Logs the user out.
+
+ It clears all session data for safety. See renew_session.
+ """
+ def log_out_user(conn) do
+ if live_socket_id = get_session(conn, :live_socket_id) do
+ LiveBeatsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+ end
+
+ conn
+ |> renew_session()
+ |> redirect(to: "/")
+ end
+
+ @doc """
+ Authenticates the user by looking into the session.
+ """
+ def fetch_current_user(conn, _opts) do
+ user_id = get_session(conn, :user_id)
+ user = user_id && Accounts.get_user!(user_id)
+ assign(conn, :current_user, user)
+ end
+
+ @doc """
+ Used for routes that require the user to not be authenticated.
+ """
+ def redirect_if_user_is_authenticated(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ |> redirect(to: signed_in_path(conn))
+ |> halt()
+ else
+ conn
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to be authenticated.
+
+ If you want to enforce the user email is confirmed before
+ they use the application at all, here would be a good place.
+ """
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must log in to access this page.")
+ |> maybe_store_return_to()
+ |> redirect(to: Routes.home_path(conn, :index))
+ |> halt()
+ end
+ end
+
+ def require_authenticated_admin(conn, _opts) do
+ user = conn.assigns[:current_user]
+ if user && LiveBeats.Accounts.admin?(user) do
+ assign(conn, :current_admin, user)
+ else
+ conn
+ |> put_flash(:error, "You must be logged into access that page")
+ |> maybe_store_return_to()
+ |> redirect(to: "/")
+ |> halt()
+ end
+ end
+
+ defp maybe_store_return_to(%{method: "GET"} = conn) do
+ %{request_path: request_path, query_string: query_string} = conn
+ return_to = if query_string == "", do: request_path, else: request_path <> "?" <> query_string
+ put_session(conn, :user_return_to, return_to)
+ end
+
+ defp maybe_store_return_to(conn), do: conn
+
+ defp signed_in_path(_conn), do: "/"
+end
diff --git a/lib/live_beats_web/live/player_live.ex b/lib/live_beats_web/live/player_live.ex
index b151616..d7eac1d 100644
--- a/lib/live_beats_web/live/player_live.ex
+++ b/lib/live_beats_web/live/player_live.ex
@@ -17,9 +17,12 @@ defmodule LiveBeatsWeb.PlayerLive do
-
-
-
+
<%= @time %>
@@ -76,7 +79,7 @@ defmodule LiveBeatsWeb.PlayerLive do
def mount(_parmas, _session, socket) do
if connected?(socket), do: Process.send_after(self(), :tick, 1000)
- {:ok, assign(socket, time: inspect(System.system_time()), count: 0)}
+ {:ok, assign(socket, time: inspect(System.system_time()), count: 0), layout: false}
end
def handle_info(:tick, socket) do
diff --git a/lib/live_beats_web/live/signin_live.ex b/lib/live_beats_web/live/signin_live.ex
index f7ac254..5b1a2a7 100644
--- a/lib/live_beats_web/live/signin_live.ex
+++ b/lib/live_beats_web/live/signin_live.ex
@@ -19,87 +19,10 @@ defmodule LiveBeatsWeb.SigninLive do
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
+
diff --git a/lib/live_beats_web/router.ex b/lib/live_beats_web/router.ex
index 01bfb1a..3351f37 100644
--- a/lib/live_beats_web/router.ex
+++ b/lib/live_beats_web/router.ex
@@ -1,6 +1,8 @@
defmodule LiveBeatsWeb.Router do
use LiveBeatsWeb, :router
+ import LiveBeatsWeb.UserAuth, only: [redirect_if_user_is_authenticated: 2]
+
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -17,12 +19,19 @@ defmodule LiveBeatsWeb.Router do
scope "/", LiveBeatsWeb do
pipe_through :browser
- live_session :default do
+ live_session :default, on_mount: {LiveBeatsWeb.UserAuth, :mount_defaults} do
+ live "/test", IndexLive
live "/", HomeLive, :index
live "/signin", SigninLive, :index
end
end
+ scope "/", LiveBeatsWeb do
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
+
+ get "/oauth/callbacks/:provider", OAuthCallbackController, :new
+ end
+
# Other scopes may use custom stacks.
# scope "/api", LiveBeatsWeb do
# pipe_through :api
diff --git a/lib/live_beats_web/templates/layout/root.html.heex b/lib/live_beats_web/templates/layout/root.html.heex
index 23bc680..3a74e5c 100644
--- a/lib/live_beats_web/templates/layout/root.html.heex
+++ b/lib/live_beats_web/templates/layout/root.html.heex
@@ -80,17 +80,21 @@
My tasks
- <%= live_redirect to: Routes.signin_path(@conn, :index),
- class: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md" do %>
+ <%= if @current_user do %>
-
- Recent
+ <% else %>
+ <%= live_redirect to: Routes.signin_path(@conn, :index),
+ class: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md" do %>
+
+
+ Sign in
+ <% end %>
<% end %>
@@ -145,52 +149,54 @@
+ <%= if @current_user do %>
-