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
- -