3 | <% end %>
4 |
5 | <%= if get_flash(@conn, :error) do %>
6 |
<%= get_flash(@conn, :error) %>
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/lib/user_service/user_identities/user_identity.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.UserIdentities.UserIdentity do
2 | use Ecto.Schema
3 | use PowAssent.Ecto.UserIdentities.Schema, user: UserService.Users.User
4 |
5 | schema "user_identities" do
6 | pow_assent_user_identity_fields()
7 |
8 | timestamps()
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/user_service_web/controllers/placeholder_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.PlaceholderController do
2 | use UserServiceWeb, :controller
3 |
4 | plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
5 |
6 | def show(conn, _params) do
7 | render(conn, "show.html")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumer do
2 | @moduledoc """
3 | UserConsumer keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200318055008_add_name_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.AddNameToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :first_name, :text, null: false, default: ""
7 | add :last_name, :text, null: false, default: ""
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/test/user_service_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.LayoutViewTest do
2 | use UserServiceWeb.ConnCase, async: true
3 |
4 | # When testing helpers, you may want to import Phoenix.HTML and
5 | # use functions such as safe_to_string() to convert the helper
6 | # result into an HTML string.
7 | # import Phoenix.HTML
8 | end
9 |
--------------------------------------------------------------------------------
/user_consumer/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :user_consumer, UserConsumerWeb.Endpoint,
6 | http: [port: 4002],
7 | server: false
8 |
9 | # Print only warnings and errors during test
10 | config :logger, level: :warn
11 |
--------------------------------------------------------------------------------
/lib/ecto/trimmed_string.ex:
--------------------------------------------------------------------------------
1 | defmodule Ecto.TrimmedString do
2 | def type, do: :string
3 |
4 | def cast(binary) when is_binary(binary), do: {:ok, String.trim(binary)}
5 | def cast(other), do: Ecto.Type.cast(:string, other)
6 |
7 | def load(data), do: Ecto.Type.load(:string, data)
8 |
9 | def dump(data), do: Ecto.Type.dump(:string, data)
10 | end
11 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_invitation/mailer/invitation.text.eex:
--------------------------------------------------------------------------------
1 | Hi,
2 |
3 | You've been invited to join <%= @invited_by_user_id %> on APP_NAME. Please use the following link to accept your invitation:
4 |
5 | <%= @url %>
6 |
7 | If you have any questions, you can reply to this email and we'll get back to you!
8 |
9 | Thanks!
10 | The Team at APP_NAME
11 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_reset_password/mailer/reset_password.text.eex:
--------------------------------------------------------------------------------
1 | Hi,
2 |
3 | You can reset your password using the following link. If you didn't request a password reset, then please disregard this email.
4 |
5 | <%= @url %>
6 |
7 | If you have any questions, you can reply to this email and we'll get back to you!
8 |
9 | Thanks!
10 | The Team at APP_NAME
11 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.Router do
2 | use UserConsumerWeb, :router
3 |
4 | pipeline :api do
5 | plug :accepts, ["json"]
6 | plug UserConsumerWeb.Plug.SsoUserConsumer
7 | end
8 |
9 | scope "/", UserConsumerWeb do
10 | pipe_through :api
11 |
12 | get "/", PlaceholderController, :show
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200215192702_create_users.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.CreateUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:users) do
6 | add :email, :string, null: false
7 | add :password_hash, :string
8 |
9 | timestamps()
10 | end
11 |
12 | create unique_index(:users, [:email])
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_reset_password/mailer/reset_password.html.eex:
--------------------------------------------------------------------------------
1 |
Hi,
2 |
3 |
You can reset your password using <%= link("this link", to: @url) %>. If you didn't request a password reset, then please disregard this email.
4 |
5 |
If you have any questions, you can reply to this email and we'll get back to you!
6 |
7 |
Thanks! The Team at APP_NAME
8 |
--------------------------------------------------------------------------------
/lib/user_service/access.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Access do
2 | alias UserService.Access.{Context, Token}
3 |
4 | @token_access_minutes 60
5 |
6 | def user_access_token(%UserService.Users.User{guid: guid}) do
7 | %Context{guid: guid}
8 | |> Token.sign_auth_token()
9 | end
10 |
11 | def token_duration_in_seconds() do
12 | @token_access_minutes * 60
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_invitation/mailer/invitation.html.eex:
--------------------------------------------------------------------------------
1 |
Hi,
2 |
3 |
You've been invited to join <%= @invited_by_user_id %> on APP_NAME. Please use the following link to accept your invitation:
4 |
5 |
<%= link(@url, to: @url) %>
6 |
7 |
If you have any questions, you can reply to this email and we'll get back to you!
8 |
9 |
Thanks! The Team at APP_NAME
10 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # UserService.Repo.insert!(%UserService.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200318054354_drop_sso_token_foreign_key.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.DropSsoTokenForeignKey do
2 | use Ecto.Migration
3 |
4 | def up do
5 | drop constraint("sso_tokens", "sso_tokens_user_id_fkey")
6 | end
7 |
8 | def down do
9 | alter table("sso_tokens") do
10 | modify :user_id, references(:users, on_delete: :nothing), null: false
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200315042833_add_guid_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.AddGuidToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute "CREATE EXTENSION pgcrypto", "DROP EXTENSION pgcrypto"
6 |
7 | alter table(:users) do
8 | add :guid, :uuid, null: false, default: fragment("gen_random_uuid()")
9 | end
10 |
11 | create unique_index(:users, [:guid])
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/user_service_web/views/pow_assent/registration_view.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.PowAssent.RegistrationView do
2 | use UserServiceWeb, :view
3 |
4 | def provider_message(%{params: %{"provider" => "github"}}), do: "with Github."
5 | def provider_message(_), do: "."
6 |
7 | def email_taken?(%{errors: errors}) do
8 | Enum.find(errors, fn
9 | {:email, {_, [{:constraint, :unique}, _]}} -> true
10 | _ -> false
11 | end)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200215194435_add_pow_email_confirmation_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.AddPowEmailConfirmationToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :email_confirmation_token, :string
7 | add :email_confirmed_at, :utc_datetime
8 | add :unconfirmed_email, :string
9 | end
10 |
11 | create unique_index(:users, [:email_confirmation_token])
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200417041204_add_pow_invitation_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.AddPowInvitationToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :invitation_token, :string
7 | add :invitation_accepted_at, :utc_datetime
8 | add :invited_by_id, references("users", on_delete: :nothing)
9 | end
10 |
11 | create unique_index(:users, [:invitation_token])
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_email_confirmation/mailer/email_confirmation.text.eex:
--------------------------------------------------------------------------------
1 | Hi,
2 |
3 | Welcome to APP_NAME, thanks for signing up! You're on your way to DO_SOMETHING_GREAT.
4 |
5 | To get started, Confirm your email address using the following link and follow the setup in app!
6 |
7 | <%= @url %>
8 |
9 | We hope you enjoy THE_PRODUCT. You can reply to this email if you have any questions, we'll get back to you as soon as possible.
10 |
11 | Thanks,
12 | The Team at APP_NAME
13 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200319033538_create_user_identities.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.CreateUserIdentities do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:user_identities) do
6 | add :provider, :string, null: false
7 | add :uid, :string, null: false
8 | add :user_id, :integer
9 |
10 | timestamps()
11 | end
12 |
13 | create unique_index(:user_identities, [:uid, :provider])
14 | create index(:user_identities, :user_id)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/user_service_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.ErrorViewTest do
2 | use UserServiceWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(UserServiceWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(UserServiceWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/user_service_web/controllers/api/tokens_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Api.TokensController do
2 | use UserServiceWeb, :controller
3 |
4 | def create(conn, _params) do
5 | json(conn, payload(Pow.Plug.current_user(conn)))
6 | end
7 |
8 | defp payload(user) do
9 | %{
10 | duration_in_seconds: UserService.Access.token_duration_in_seconds(),
11 | now_utc: :erlang.system_time(:seconds),
12 | subject: user.guid,
13 | token: UserService.Access.user_access_token(user)
14 | }
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200216050100_create_sso_tokens.exs:
--------------------------------------------------------------------------------
1 | defmodule UserService.Repo.Migrations.CreateSsoTokens do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:sso_tokens) do
6 | add :token, :text, null: false
7 | add :expires_at, :utc_datetime_usec, null: false
8 | add :user_id, references(:users, on_delete: :nothing), null: false
9 |
10 | timestamps(type: :utc_datetime_usec)
11 | end
12 |
13 | create index(:sso_tokens, [:user_id])
14 | create unique_index(:sso_tokens, [:token])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # Configure your database
4 | config :user_service, UserService.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "user_service_test",
8 | hostname: "localhost",
9 | pool: Ecto.Adapters.SQL.Sandbox
10 |
11 | # We don't run a server during test. If one is required,
12 | # you can enable the server option below.
13 | config :user_service, UserServiceWeb.Endpoint,
14 | http: [port: 4002],
15 | server: false
16 |
17 | # Print only warnings and errors during test
18 | config :logger, level: :warn
19 |
--------------------------------------------------------------------------------
/lib/user_service/application.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Application do
2 | @moduledoc false
3 |
4 | use Application
5 |
6 | def start(_type, _args) do
7 | children = [
8 | UserService.Redix,
9 | UserService.Repo,
10 | UserServiceWeb.Endpoint
11 | ]
12 |
13 | opts = [strategy: :one_for_one, name: UserService.Supervisor]
14 | Supervisor.start_link(children, opts)
15 | end
16 |
17 | def config_change(changed, _new, removed) do
18 | UserServiceWeb.Endpoint.config_change(changed, removed)
19 | :ok
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_email_confirmation/mailer/email_confirmation.html.eex:
--------------------------------------------------------------------------------
1 | <%# Template left unstyled, due to custom needs for any particular app %>
2 |
3 |
Hi,
4 |
5 |
Welcome to APP_NAME, thanks for signing up! You're on your way to DO_SOMETHING_GREAT.
6 |
7 |
To get started, <%= link("confirm your email address", to: @url) %> and follow the setup in app!
8 |
9 |
We hope you enjoy THE_PRODUCT. You can reply to this email if you have any questions, we'll get back to you as soon as possible.
10 |
11 |
Thanks, The Team at APP_NAME
12 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | @doc """
7 | Translates an error message.
8 | """
9 | def translate_error({msg, opts}) do
10 | # Because the error messages we show in our forms and APIs
11 | # are defined inside Ecto, we need to translate them dynamically.
12 | Enum.reduce(opts, msg, fn {key, value}, acc ->
13 | String.replace(acc, "%{#{key}}", to_string(value))
14 | end)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/user_consumer/test/user_consumer_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.ErrorViewTest do
2 | use UserConsumerWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.json" do
8 | assert render(UserConsumerWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}}
9 | end
10 |
11 | test "renders 500.json" do
12 | assert render(UserConsumerWeb.ErrorView, "500.json", []) ==
13 | %{errors: %{detail: "Internal Server Error"}}
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/user_service_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.ErrorView do
2 | use UserServiceWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.ErrorView do
2 | use UserConsumerWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.json", _assigns) do
7 | # %{errors: %{detail: "Internal Server Error"}}
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.json" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_invitation/invitation/show.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Invite Your Teammates
4 |
We'll send an email to your teammate for them to join your team.
5 |
6 |
7 |
8 |
9 | You can send your teammate the following URL: <%= @url %>
10 |
11 |
12 |
13 | <%= link "Cancel", to: Routes.pow_session_path(@conn, :new), class: "btn btn-light w-50" %>
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/user_service_web/controllers/external_redirect_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.ExternalRedirectController do
2 | use UserServiceWeb, :controller
3 |
4 | alias UserServiceWeb.Plug.RedirectTo
5 |
6 | def show(conn, _params) do
7 | case external_url(conn) do
8 | nil ->
9 | redirect(conn, to: "/")
10 |
11 | url ->
12 | conn
13 | |> clear_url()
14 | |> redirect(external: url)
15 | end
16 | end
17 |
18 | defp external_url(conn) do
19 | get_session(conn, RedirectTo.session_key())
20 | end
21 |
22 | defp clear_url(conn) do
23 | delete_session(conn, RedirectTo.session_key())
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/user_service/access/token.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Access.Token do
2 | use Joken.Config
3 |
4 | alias UserService.Access.Context
5 |
6 | @impl true
7 | def token_config do
8 | default_claims(iss: "user-service", skip: [:aud])
9 | end
10 |
11 | def sign_auth_token(%Context{guid: guid}) do
12 | signer = Joken.Signer.create("HS256", secret())
13 | generate_and_sign!(%{"guid" => guid, "exp" => expiration()}, signer)
14 | end
15 |
16 | defp secret() do
17 | Application.fetch_env!(:user_service, :api_signer_secret)
18 | end
19 |
20 | defp expiration() do
21 | :erlang.system_time(:seconds) + UserService.Access.token_duration_in_seconds()
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/user_service_web/pow/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Pow.Mailer do
2 | use Pow.Phoenix.Mailer
3 | use Bamboo.Mailer, otp_app: :user_service
4 |
5 | import Bamboo.Email
6 |
7 | @impl true
8 | def cast(%{user: user, subject: subject, text: text, html: html}) do
9 | new_email(
10 | to: user.email,
11 | from: from_address(),
12 | subject: subject,
13 | html_body: html,
14 | text_body: text
15 | )
16 | end
17 |
18 | @impl true
19 | def process(email) do
20 | deliver_now(email)
21 | end
22 |
23 | defp from_address() do
24 | Application.fetch_env!(:user_service, __MODULE__)
25 | |> Keyword.fetch!(:from_address)
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/user_service/redix.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Redix do
2 | @pool_size 15
3 |
4 | def child_spec(_args) do
5 | redis_url = Application.get_env(:user_service, :redis_uri)
6 |
7 | children =
8 | for i <- 0..(@pool_size - 1) do
9 | Supervisor.child_spec({Redix, {redis_url, name: :"redix_#{i}"}}, id: {Redix, i})
10 | end
11 |
12 | %{
13 | id: RedixSupervisor,
14 | type: :supervisor,
15 | start: {Supervisor, :start_link, [children, [strategy: :one_for_one]]}
16 | }
17 | end
18 |
19 | def instance_name() do
20 | :"redix_#{random_index()}"
21 | end
22 |
23 | defp random_index() do
24 | rem(System.unique_integer([:positive]), @pool_size)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/user_service_web/plug/redirect_to.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Plug.RedirectTo do
2 | import Plug.Conn
3 |
4 | def session_key(), do: :saved_redirect_to
5 |
6 | def init(_) do
7 | []
8 | end
9 |
10 | def call(conn = %{query_params: %{"redirect_to" => redirect_to}}, _opts) do
11 | put_session(conn, session_key(), validated_redirect_to(redirect_to))
12 | end
13 |
14 | def call(conn, _), do: conn
15 |
16 | defp validated_redirect_to(url) do
17 | uri = URI.parse(url)
18 |
19 | if uri.scheme && String.ends_with?(uri.host, cookie_domain()) do
20 | url
21 | else
22 | nil
23 | end
24 | end
25 |
26 | defp cookie_domain(), do: Application.get_env(:user_service, :sso_cookie_domain)
27 | end
28 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= assigns[:page_title] || "UserService · Phoenix Framework" %>
8 | "/>
9 | <%= csrf_meta_tag() %>
10 |
11 |
12 |
13 |
14 | <%= render @view_module, @view_template, assigns %>
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/lib/user_service/sso/sso_token.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Sso.SsoToken do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "sso_tokens" do
6 | field :expires_at, :utc_datetime_usec
7 | field :token, :string
8 | field :user_id, :id
9 |
10 | timestamps(type: :utc_datetime_usec)
11 | end
12 |
13 | @doc false
14 | def changeset(attrs) do
15 | attrs =
16 | if attrs[:token] == :random do
17 | length = 64
18 | token = :crypto.strong_rand_bytes(length) |> Base.encode64() |> binary_part(0, length)
19 | Map.put(attrs, :token, token)
20 | else
21 | attrs
22 | end
23 |
24 | %__MODULE__{}
25 | |> cast(attrs, [:token, :expires_at, :user_id])
26 | |> validate_required([:token, :expires_at, :user_id])
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/user_service_web/controllers/sso/verify_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Sso.VerifyController do
2 | @moduledoc """
3 | TODO: In production environment, this should be secured with an server-server secret. For starters, possibly just
4 | a list of secrets kept in config.
5 | """
6 |
7 | use UserServiceWeb, :controller
8 |
9 | def create(conn, %{"sso_token" => token}) do
10 | case UserService.Sso.get_user_for_sso_token(token) do
11 | {:ok, user} ->
12 | conn
13 | |> json(serialize(user))
14 |
15 | {:error, reason} ->
16 | conn
17 | |> put_status(422)
18 | |> json(%{error: "invalid_token", reason: reason})
19 | end
20 | end
21 |
22 | defp serialize(user) do
23 | %{
24 | email: user.email,
25 | guid: user.guid
26 | }
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/user_service_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import UserServiceWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :user_service
24 | end
25 |
--------------------------------------------------------------------------------
/user_consumer/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | user_consumer-*.tar
24 |
25 | # Since we are building assets from assets/,
26 | # we ignore priv/static. You may want to comment
27 | # this depending on your deployment strategy.
28 | /priv/static/
29 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "webpack --mode production",
6 | "watch": "webpack --mode development --watch"
7 | },
8 | "dependencies": {
9 | "phoenix_html": "../deps/phoenix_html"
10 | },
11 | "devDependencies": {
12 | "@babel/core": "^7.0.0",
13 | "@babel/preset-env": "^7.0.0",
14 | "babel-loader": "^8.0.0",
15 | "bootstrap": "^4.4.1",
16 | "copy-webpack-plugin": "^5.1.1",
17 | "css-loader": "^3.4.2",
18 | "extract-text-webpack-plugin": "^4.0.0-beta.0",
19 | "mini-css-extract-plugin": "^0.9.0",
20 | "node-sass": "^4.13.1",
21 | "optimize-css-assets-webpack-plugin": "^5.0.1",
22 | "sass-loader": "^8.0.2",
23 | "style-loader": "^1.1.3",
24 | "terser-webpack-plugin": "^2.3.2",
25 | "webpack": "4.41.5",
26 | "webpack-cli": "^3.3.2"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_assent/registration/add_user_id.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Whoops!
4 |
We weren't able to sign you in <%= provider_message(@conn) %>
5 |
6 |
7 |
8 | <%= if email_taken?(@changeset) do %>
9 |
We could not verify that your account is configured to use this login provider.
10 | <% else %>
11 |
We can not log you in using this login provider.
12 | <% end %>
13 |
14 |
15 | Please <%= link("login", to: Routes.login_path(@conn, :new)) %> with your email and password, or with a
16 | different provider. You can <%= link "reset your password", to: Routes.pow_reset_password_reset_password_path(@conn, :new) %>
17 | if you forgot it.
18 |
You're logged in as <%= Pow.Plug.current_user(@conn).email %>
14 |
15 | <%= link "Edit Your Info", to: Routes.pow_registration_path(@conn, :edit) %>
16 | <%= link "Invite Your Teammates", to: Routes.pow_invitation_invitation_path(@conn, :new) %>
17 | <%= link "Log Out", to: Routes.logout_path(@conn, :delete) %>
18 |
19 |
20 |
--------------------------------------------------------------------------------
/lib/user_service_web/pow/routes.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Pow.Routes do
2 | use Pow.Phoenix.Routes
3 |
4 | alias UserServiceWeb.Router.Helpers, as: Routes
5 | alias UserServiceWeb.Plug.RedirectTo
6 |
7 | @impl true
8 | def user_not_authenticated_path(conn) do
9 | Routes.pow_session_path(conn, :new)
10 | end
11 |
12 | @impl true
13 | def after_sign_in_path(conn) do
14 | case external_url(conn) do
15 | nil ->
16 | "/"
17 |
18 | _ ->
19 | Routes.external_redirect_path(conn, :show)
20 | end
21 | end
22 |
23 | @impl true
24 | def after_sign_out_path(conn, routes_module \\ __MODULE__) do
25 | case external_url(conn) do
26 | nil ->
27 | routes_module.session_path(conn, :new)
28 |
29 | _ ->
30 | Routes.external_redirect_path(conn, :show)
31 | end
32 | end
33 |
34 | defp external_url(conn) do
35 | Plug.Conn.get_session(conn, RedirectTo.session_key())
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/user_service/sso/cookie.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Sso.Cookie do
2 | def cookie_name() do
3 | "sso_session"
4 | end
5 |
6 | def cookie_domain() do
7 | Application.get_env(:user_service, :sso_cookie_domain)
8 | end
9 |
10 | def sign_token(%{token: token, expires_at: expires_at}) do
11 | secret = Application.get_env(:user_service, :sso_secret)
12 |
13 | Phoenix.Token.sign(secret, "sso_salt", %{token: token, expires_at: expires_at})
14 | end
15 |
16 | def verify_session(token) do
17 | secret = Application.get_env(:user_service, :sso_secret)
18 | now = System.system_time(:second)
19 |
20 | case Phoenix.Token.verify(secret, "sso_salt", token, max_age: :infinity) do
21 | {:ok, payload = %{expires_at: expire_at_s}} when expire_at_s > now ->
22 | {:valid, payload}
23 |
24 | {:ok, expired_payload} ->
25 | {:expired, expired_payload}
26 |
27 | {:error, e} ->
28 | {:invalid, e}
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/user_service_web/plug/fetch_user_plug.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.Plug.FetchUserPlug do
2 | @moduledoc """
3 | Plug to refetch the current user from Pow, to ensure that it's always up to date.
4 |
5 | From: https://hexdocs.pm/pow/1.0.19/sync_user.html#content
6 |
7 | Motivation: Not every controller may need the most up-to-date user (it could be cached and used), but
8 | it's easier to assume an un-cached user and then cache if there's performance issues in the
9 | future.
10 | """
11 |
12 | @behaviour Plug
13 |
14 | @impl true
15 | def init(opts), do: opts
16 |
17 | @impl true
18 | def call(conn, _opts) do
19 | config = Pow.Plug.fetch_config(conn)
20 |
21 | case Pow.Plug.current_user(conn, config) do
22 | nil ->
23 | conn
24 |
25 | user ->
26 | reloaded_user = UserService.Users.get_user(user.id)
27 | Pow.Plug.assign_current_user(conn, reloaded_user, config)
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/user_service/sso.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Sso do
2 | @moduledoc """
3 | SSO cookie management for the current user on login. This SSO cookie can be used
4 | by other domains under the same parent in order to provide server-server verified SSO session.
5 | """
6 |
7 | alias __MODULE__.{Cookie, Plug, Store}
8 |
9 | def plug(), do: Plug
10 |
11 | def get_user_for_sso_token(sso_token) do
12 | with {:verify, {:valid, %{token: token}}} <- {:verify, Cookie.verify_session(sso_token)},
13 | {:find_token, %{user_id: id}} <- {:find_token, Store.find_valid_token(token)},
14 | {:find_user, user = %{}} <- {:find_user, UserService.Users.get_user(id)} do
15 | {:ok, user}
16 | else
17 | {:verify, {:invalid, _}} ->
18 | {:error, :invalid_session}
19 |
20 | {:verify, _e} ->
21 | {:error, :verifying_session}
22 |
23 | {:find_token, _} ->
24 | {:error, :finding_token}
25 |
26 | {:find_user, _} ->
27 | {:error, :finding_user}
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2020 Stephen Bussey
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | user_service-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 | # Since we are building assets from assets/,
32 | # we ignore priv/static. You may want to comment
33 | # this depending on your deployment strategy.
34 | /priv/static/
35 |
36 | *.secret.exs
37 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer/application.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumer.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | # List all child processes to be supervised
10 | children = [
11 | # Start the endpoint when the application starts
12 | UserConsumerWeb.Endpoint
13 | # Starts a worker by calling: UserConsumer.Worker.start_link(arg)
14 | # {UserConsumer.Worker, arg},
15 | ]
16 |
17 | # See https://hexdocs.pm/elixir/Supervisor.html
18 | # for other strategies and supported options
19 | opts = [strategy: :one_for_one, name: UserConsumer.Supervisor]
20 | Supervisor.start_link(children, opts)
21 | end
22 |
23 | # Tell Phoenix to update the endpoint configuration
24 | # whenever the application is updated.
25 | def config_change(changed, _new, removed) do
26 | UserConsumerWeb.Endpoint.config_change(changed, removed)
27 | :ok
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/user_service/sso/store.ex:
--------------------------------------------------------------------------------
1 | defmodule UserService.Sso.Store do
2 | @moduledoc """
3 | SSO cookie management for the current user on login. This SSO cookie can be used
4 | by other domains under the same parent in order to provide server-server verified SSO session.
5 | """
6 |
7 | import Ecto.Query
8 |
9 | alias UserService.Repo
10 | alias UserService.Sso.SsoToken
11 |
12 | @one_month 60 * 60 * 24 * 30
13 |
14 | def create_sso_token_for_user!(_user = %{id: user_id}) when not is_nil(user_id) do
15 | expiry = (System.system_time(:second) + @one_month) |> DateTime.from_unix!()
16 |
17 | SsoToken.changeset(%{token: :random, expires_at: expiry, user_id: user_id})
18 | |> Repo.insert!()
19 | end
20 |
21 | def find_valid_token(token) do
22 | now = DateTime.utc_now()
23 |
24 | from(sso in SsoToken, where: sso.token == ^token and sso.expires_at > ^now)
25 | |> Repo.one()
26 | end
27 |
28 | def delete_sso_token(token: token) do
29 | from(sso in SsoToken, where: sso.token == ^token)
30 | |> Repo.delete_all()
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/user_consumer/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use UserConsumerWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with channels
23 | use Phoenix.ChannelTest
24 |
25 | # The default endpoint for testing
26 | @endpoint UserConsumerWeb.Endpoint
27 | end
28 | end
29 |
30 | setup _tags do
31 | :ok
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/user_consumer/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | use Mix.Config
9 |
10 | # Configures the endpoint
11 | config :user_consumer, UserConsumerWeb.Endpoint,
12 | url: [host: "localhost"],
13 | secret_key_base: "raMcS2w6t+EqlpSUODuKbXu2AaQJet64a8OhqXBomZLEgfd+fnbnkJsZLPynOuo2",
14 | render_errors: [view: UserConsumerWeb.ErrorView, accepts: ~w(json)],
15 | pubsub: [name: UserConsumer.PubSub, adapter: Phoenix.PubSub.PG2],
16 | live_view: [signing_salt: "gbWRJRhF"]
17 |
18 | # Configures Elixir's Logger
19 | config :logger, :console,
20 | format: "$time $metadata[$level] $message\n",
21 | metadata: [:request_id]
22 |
23 | # Use Jason for JSON parsing in Phoenix
24 | config :phoenix, :json_library, Jason
25 |
26 | # Import environment specific config. This must remain at the bottom
27 | # of this file so it overrides the configuration defined above.
28 | import_config "#{Mix.env()}.exs"
29 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_reset_password/reset_password/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Reset your password
4 |
We'll confirm your email and then you can set a new password.
5 |
6 |
7 |
8 | <%= form_for @changeset, @action, [as: :user], fn f -> %>
9 | <%= if @changeset.action do %>
10 |
11 |
Oops, something went wrong! Please check the errors below.
28 |
--------------------------------------------------------------------------------
/user_consumer/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule UserConsumer.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :user_consumer,
7 | version: "0.1.0",
8 | elixir: "~> 1.5",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix] ++ Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {UserConsumer.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:phoenix, "~> 1.4.13"},
36 | {:phoenix_pubsub, "~> 1.1"},
37 | {:jason, "~> 1.0"},
38 | {:plug_cowboy, "~> 2.0"},
39 | {:mojito, "~> 0.6.1"}
40 | ]
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/user_consumer/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use UserConsumerWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | use Phoenix.ConnTest
24 | alias UserConsumerWeb.Router.Helpers, as: Routes
25 |
26 | # The default endpoint for testing
27 | @endpoint UserConsumerWeb.Endpoint
28 | end
29 | end
30 |
31 | setup _tags do
32 | {:ok, conn: Phoenix.ConnTest.build_conn()}
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_invitation/invitation/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Invite Your Teammates
4 |
We'll send an email to your teammate for them to join your team.
30 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", UserConsumerWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | def connect(_params, socket, _connect_info) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # UserConsumerWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use UserServiceWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with channels
23 | use Phoenix.ChannelTest
24 |
25 | # The default endpoint for testing
26 | @endpoint UserServiceWeb.Endpoint
27 | end
28 | end
29 |
30 | setup tags do
31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(UserService.Repo)
32 |
33 | unless tags[:async] do
34 | Ecto.Adapters.SQL.Sandbox.mode(UserService.Repo, {:shared, self()})
35 | end
36 |
37 | :ok
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/salesloft/provider.ex:
--------------------------------------------------------------------------------
1 | defmodule SalesLoft.Provider do
2 | @moduledoc """
3 | This is here to demonstrate how simple it is to make a custom OAuth provider.
4 |
5 | SalesLoft requires SSL for all redirect URIs for security reasons (except for localhost). Because I'm
6 | using a custom domain for this, I currently have to run it in SSL mode and things just get really weird
7 | with the self-signed cert.
8 | """
9 |
10 | use Assent.Strategy.OAuth2.Base
11 |
12 | @impl true
13 | def default_config(_config) do
14 | [
15 | site: "https://accounts.salesloft.com/",
16 | authorize_url: "https://accounts.salesloft.com/oauth/authorize",
17 | token_url: "https://accounts.salesloft.com/oauth/token",
18 | user_url: "https://api.salesloft.com/v2/me",
19 | authorization_params: [],
20 | auth_method: :client_secret_post
21 | ]
22 | end
23 |
24 | @impl true
25 | def normalize(_config, %{"data" => user}) do
26 | {:ok,
27 | %{
28 | "sub" => user["guid"],
29 | "given_name" => user["first_name"],
30 | "family_name" => user["last_name"],
31 | "email" => user["email"],
32 | # change to true to bypass need for verification
33 | "email_verified" => false
34 | }}
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow_reset_password/reset_password/edit.html.eex:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule UserServiceWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use UserServiceWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | use Phoenix.ConnTest
24 | alias UserServiceWeb.Router.Helpers, as: Routes
25 |
26 | # The default endpoint for testing
27 | @endpoint UserServiceWeb.Endpoint
28 | end
29 | end
30 |
31 | setup tags do
32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(UserService.Repo)
33 |
34 | unless tags[:async] do
35 | Ecto.Adapters.SQL.Sandbox.mode(UserService.Repo, {:shared, self()})
36 | end
37 |
38 | {:ok, conn: Phoenix.ConnTest.build_conn()}
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/priv/cert/selfsigned.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDjjCCAnagAwIBAgIID2neKjEQJwYwDQYJKoZIhvcNAQELBQAwQzEaMBgGA1UE
3 | CgwRUGhvZW5peCBGcmFtZXdvcmsxJTAjBgNVBAMMHFNlbGYtc2lnbmVkIHRlc3Qg
4 | Y2VydGlmaWNhdGUwHhcNMjAwMzE5MDAwMDAwWhcNMjEwMzE5MDAwMDAwWjBDMRow
5 | GAYDVQQKDBFQaG9lbml4IEZyYW1ld29yazElMCMGA1UEAwwcU2VsZi1zaWduZWQg
6 | dGVzdCBjZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
7 | AN3lNLmeJeAbvxNXXZBMk7EzGoLtVWLb4Jy0ZrkG2vYfpwQQSo0c3lCl62ro3usq
8 | Syns501sip5m7cEBegiwVID+VzCg0hvkHjOdXgZ4uN1OcLKRs6qW01w1h7u+Hq8c
9 | ecTIOzFd5EJuDdJUBIJbllk/kzlH0LWTQtsr///jEvubOnlfSLMuqRHbALPp/KrU
10 | vk8CXnwAHhKQlzZUPwGMYpDRxUCK3U6t6QZ3MSQnuQdspH9Go1MZ1d3V3zlIgcqK
11 | 4qvBd+wkCt2WINNqp9KPVyGEcYOvB5f2Wk0AvtvptTS4eNGu3R1LPIYktMLcN4Yw
12 | AfMNbxdHpbbP9Q3EaYjL0AUCAwEAAaOBhTCBgjAMBgNVHRMBAf8EAjAAMA4GA1Ud
13 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O
14 | BBYEFASOZM8Qulsgb+E2I95WdNsaHLH1MCQGA1UdEQQdMBuCGWlkcC5sb2NhbGhv
15 | c3QuZGV2ZWxvcG1lbnQwDQYJKoZIhvcNAQELBQADggEBANx0MT/nnzDbhAcDBujn
16 | VMlumC3vWG0dqyLypo/fPi+gXWTY4eFkeznD/bOKrTqanswPhxQomevnX5ZHREkC
17 | HAHbjoOfHhLv8F+sIeGRDXKVQO2shJWNphOxAQqQUL5Vf3F8ghIVKTFyMntDQ3yg
18 | a0+7fmckldAUHPXYYM188auaeAXC7Wc3/Uzq57IsjBsczGPoSdR7klJaoRUmzeeD
19 | 2kKa1sfuU2ySiFoVSYdlVLkpH4qbpiznq8oaV1hqZXNqYxTDjT5I94YeFYaGvdXw
20 | oTRpkV1m8hJSKxiPNoeBfckrI2CSdFfabBD63CT0Pk8kaYIoTV2K5sJm+70dzwLi
21 | l+o=
22 | -----END CERTIFICATE-----
23 |
24 |
--------------------------------------------------------------------------------
/config/prod.secret.exs:
--------------------------------------------------------------------------------
1 | # In this file, we load production configuration and secrets
2 | # from environment variables. You can also hardcode secrets,
3 | # although such is generally not recommended and you have to
4 | # remember to add this file to your .gitignore.
5 | use Mix.Config
6 |
7 | database_url =
8 | System.get_env("DATABASE_URL") ||
9 | raise """
10 | environment variable DATABASE_URL is missing.
11 | For example: ecto://USER:PASS@HOST/DATABASE
12 | """
13 |
14 | config :user_service, UserService.Repo,
15 | # ssl: true,
16 | url: database_url,
17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
18 |
19 | secret_key_base =
20 | System.get_env("SECRET_KEY_BASE") ||
21 | raise """
22 | environment variable SECRET_KEY_BASE is missing.
23 | You can generate one by calling: mix phx.gen.secret
24 | """
25 |
26 | config :user_service, UserServiceWeb.Endpoint,
27 | http: [
28 | port: String.to_integer(System.get_env("PORT") || "4000"),
29 | transport_options: [socket_opts: [:inet6]]
30 | ],
31 | secret_key_base: secret_key_base
32 |
33 | # ## Using releases (Elixir v1.9+)
34 | #
35 | # If you are doing OTP releases, you need to instruct Phoenix
36 | # to start each relevant endpoint:
37 | #
38 | # config :user_service, UserServiceWeb.Endpoint, server: true
39 | #
40 | # Then you can assemble a release by calling `mix release`.
41 | # See `mix help release` for more information.
42 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :user_consumer
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_user_consumer_key",
10 | signing_salt: "IudYggNf"
11 | ]
12 |
13 | socket "/socket", UserConsumerWeb.UserSocket,
14 | websocket: true,
15 | longpoll: false
16 |
17 | # Serve at "/" the static files from "priv/static" directory.
18 | #
19 | # You should set gzip to true if you are running phx.digest
20 | # when deploying your static files in production.
21 | plug Plug.Static,
22 | at: "/",
23 | from: :user_consumer,
24 | gzip: false,
25 | only: ~w(css fonts images js favicon.ico robots.txt)
26 |
27 | # Code reloading can be explicitly enabled under the
28 | # :code_reloader configuration of your endpoint.
29 | if code_reloading? do
30 | plug Phoenix.CodeReloader
31 | end
32 |
33 | plug Plug.RequestId
34 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
35 |
36 | plug Plug.Parsers,
37 | parsers: [:urlencoded, :multipart, :json],
38 | pass: ["*/*"],
39 | json_decoder: Phoenix.json_library()
40 |
41 | plug Plug.MethodOverride
42 | plug Plug.Head
43 | plug Plug.Session, @session_options
44 | plug UserConsumerWeb.Router
45 | end
46 |
--------------------------------------------------------------------------------
/lib/user_service_web/templates/pow/session/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Welcome to ThingCorp
4 |
We help you do things—You are going to get so much done.
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pow Exploration Project - User Service
2 |
3 | I setup this project to explore how Pow works, and to configure an instance of how I might want to run Pow. This allows me to
4 | freely judge the project, and determine if I'd ever use it.
5 |
6 | I'm quite happy with Pow so far. I've been able to keep control over my user flow, and have even gone so far as to implement
7 | SSO flows on top of Pow. The flexibility has been good, while still giving me everything I need to be successful.
8 |
9 | This project is still a WIP, until the checklist at the bottom is fully completed.
10 |
11 | # Running the Project
12 |
13 | You can startup this project with the following commands:
14 |
15 | ```bash
16 | mix deps.get && npm install --prefix assets && mix ecto.setup
17 | mix phx.server
18 | ```
19 |
20 | This starts on port 4000, but expects to run via http://idp.localhost.development:4000. You can set this up in your `/etc/hosts` file
21 | as:
22 |
23 | ```
24 | 127.0.0.1 idp.localhost.development test.localhost.development
25 | ```
26 |
27 | You should also startup the user consumer if you want to see the SSO flow in action.
28 |
29 | ## Sent Emails
30 |
31 | You can view sent emails (in-memory only) at http://idp.localhost.development:4000/sent_emails. Use this to confirm any created users.
32 |
33 | ## OAuth Login
34 |
35 | You can create a GitHub app to test out OAuth connection. The SalesLoft one is just there to see how Assent works with custom OAuth providers.
36 | Update `config/dev.secret.exs` to include your sensitive environment variables. It's not version-controlled.
37 |
38 | ## TOTP
39 |
40 | I setup a basic TOTP implementation as a pow extension to see what writing a custom extension was like. I don't think I'm going to finish
41 | it at the moment, but it can be found on the branch `pow-totp`.
42 |
43 | You can find the extension (not finished) at https://github.com/sb8244/pow_totp.
44 |
45 | # User Consumer
46 |
47 | There is an included sub-project that implements the consumer side of SSO. It has code for both server-server
48 | and client-server SSO. Start it via:
49 |
50 | ```bash
51 | cd user_consumer
52 | mix deps.get
53 | mix phx.server
54 | ```
55 |
56 | It starts on port 4001, but expects to run via http://test.localhost.development:4001 for local testing purposes. This
57 | allows it to be on a different domain than the IDP, which proves that it is working correctly. You can use the `/etc/hosts`
58 | setup in the previous section.
59 |
60 | # TODO
61 |
62 | - [x] Setup Redis cache store with namespace
63 | - [x] Make cookie live longer than session (possibly persistent extension)
64 | - [x] Sign in with redirection
65 | - [x] SSO API server
66 | - [x] Review all messages (Pow.Phoenix.Messages, [Pow Extension].Phoenix.Messages)
67 | - [x] Setup mailer (local)
68 | - [x] SSO API server auth (JWT token auth)
69 | - [x] Add GUID to user for reference
70 | - [x] CORS
71 | - [x] Do not store the full user in the session
72 | - [x] UI
73 | - [x] Bulma to BS4?
74 | - [x] Capture user name on registration
75 | - [x] Social login
76 | - [ ] Logging on all failure (like add-user-id triggering)
77 | - [x] 2FA
78 | - [o] invites
79 | - [x] Basic setup
80 | - [ ] Attach arbitrary attributes to the invite (is Pow okay for this?)
81 | - [ ] View all pending invitations sent by the current user (user enumeration vector?)
82 | - [ ] Admin interface to manage users
83 | - [ ] Manually confirm emails
84 | - [ ] View user information
85 | - [ ] Send reset password link
86 | - [ ] Team concept?
87 | - I'm a bit unsure if I want to introduce this here or not. It should be where
88 | invites are, or the 2 systems would need to cross talk about invites.
89 |
90 | ## Tests
91 | - [ ] Redis Cache Tests
92 | - [ ] IDP tests
93 |
--------------------------------------------------------------------------------
/user_consumer/lib/user_consumer_web/controllers/placeholder_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule UserConsumerWeb.PlaceholderController do
2 | use UserConsumerWeb, :controller
3 |
4 | def show(conn, _params) do
5 | user = conn.private[:sso_user]
6 | html(conn, EEx.eval_string(template(), user: user, logout_url: logout_url()))
7 | end
8 |
9 | defp template() do
10 | """
11 |