From 4eba121d1595d607a3ba42673aee0df1feba2c7c Mon Sep 17 00:00:00 2001 From: CJ_Clippy Date: Tue, 14 Jan 2025 18:51:12 -0800 Subject: [PATCH] progress --- config/deploy.yml | 9 + docker-compose.yml | 31 +- dockerfiles/bright.dockerfile | 11 +- services/bright/assets/images/favicon.png | Bin 0 -> 743 bytes services/bright/assets/static/127.jpg | Bin 0 -> 151006 bytes services/bright/assets/static/favicon.ico | Bin 0 -> 323 bytes services/bright/config/config.exs | 16 +- services/bright/lib/bright/accounts.ex | 353 ++++++++++++ services/bright/lib/bright/accounts/user.ex | 161 ++++++ .../lib/bright/accounts/user_notifier.ex | 79 +++ .../bright/lib/bright/accounts/user_token.ex | 179 ++++++ services/bright/lib/bright/application.ex | 4 +- .../lib/bright/jobs/create_hls_playlist.ex | 134 ++++- services/bright/lib/bright_web.ex | 3 +- .../components/layouts/app.html.heex | 2 - .../components/layouts/root.html.heex | 128 ++++- .../components/navigation_components.ex | 120 ----- .../controllers/page_html/about.html.heex | 3 +- .../controllers/page_html/api.html.heex | 1 - .../controllers/page_html/home.html.heex | 13 +- .../controllers/stream_html/index.html.heex | 8 +- .../controllers/stream_html/show.html.heex | 2 +- .../controllers/user_session_controller.ex | 42 ++ .../controllers/vod_html/index.html.heex | 1 + .../controllers/vod_html/show.html.heex | 49 +- .../user_confirmation_instructions_live.ex | 51 ++ .../bright_web/live/user_confirmation_live.ex | 58 ++ .../live/user_forgot_password_live.ex | 50 ++ .../lib/bright_web/live/user_login_live.ex | 43 ++ .../bright_web/live/user_registration_live.ex | 87 +++ .../live/user_reset_password_live.ex | 89 +++ .../lib/bright_web/live/user_settings_live.ex | 167 ++++++ services/bright/lib/bright_web/router.ex | 120 ++++- services/bright/lib/bright_web/user_auth.ex | 229 ++++++++ services/bright/mix.exs | 1 + services/bright/mix.lock | 1 + ...0113171439_update_users_to_auth_schema.exs | 35 ++ services/bright/priv/static/favicon.ico | Bin 152 -> 0 bytes services/bright/priv/static/images/logo.svg | 6 - ...obots-9e2c81b0855bbff2baa8371bc4a78186.txt | 5 + ...ts-9e2c81b0855bbff2baa8371bc4a78186.txt.gz | Bin 0 -> 164 bytes services/bright/priv/static/robots.txt.gz | Bin 0 -> 164 bytes services/bright/test/bright/accounts_test.exs | 508 ++++++++++++++++++ .../user_session_controller_test.exs | 113 ++++ ...er_confirmation_instructions_live_test.exs | 67 +++ .../live/user_confirmation_live_test.exs | 89 +++ .../live/user_forgot_password_live_test.exs | 63 +++ .../bright_web/live/user_login_live_test.exs | 87 +++ .../live/user_registration_live_test.exs | 87 +++ .../live/user_reset_password_live_test.exs | 118 ++++ .../live/user_settings_live_test.exs | 210 ++++++++ .../bright/test/bright_web/user_auth_test.exs | 272 ++++++++++ services/bright/test/support/conn_case.ex | 26 + .../support/fixtures/accounts_fixtures.ex | 31 ++ 54 files changed, 3707 insertions(+), 255 deletions(-) create mode 100644 services/bright/assets/images/favicon.png create mode 100644 services/bright/assets/static/127.jpg create mode 100644 services/bright/assets/static/favicon.ico create mode 100644 services/bright/lib/bright/accounts.ex create mode 100644 services/bright/lib/bright/accounts/user.ex create mode 100644 services/bright/lib/bright/accounts/user_notifier.ex create mode 100644 services/bright/lib/bright/accounts/user_token.ex delete mode 100644 services/bright/lib/bright_web/components/navigation_components.ex create mode 100644 services/bright/lib/bright_web/controllers/user_session_controller.ex create mode 100644 services/bright/lib/bright_web/live/user_confirmation_instructions_live.ex create mode 100644 services/bright/lib/bright_web/live/user_confirmation_live.ex create mode 100644 services/bright/lib/bright_web/live/user_forgot_password_live.ex create mode 100644 services/bright/lib/bright_web/live/user_login_live.ex create mode 100644 services/bright/lib/bright_web/live/user_registration_live.ex create mode 100644 services/bright/lib/bright_web/live/user_reset_password_live.ex create mode 100644 services/bright/lib/bright_web/live/user_settings_live.ex create mode 100644 services/bright/lib/bright_web/user_auth.ex create mode 100644 services/bright/priv/repo/migrations/20250113171439_update_users_to_auth_schema.exs delete mode 100644 services/bright/priv/static/favicon.ico delete mode 100644 services/bright/priv/static/images/logo.svg create mode 100644 services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt create mode 100644 services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz create mode 100644 services/bright/priv/static/robots.txt.gz create mode 100644 services/bright/test/bright/accounts_test.exs create mode 100644 services/bright/test/bright_web/controllers/user_session_controller_test.exs create mode 100644 services/bright/test/bright_web/live/user_confirmation_instructions_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_confirmation_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_forgot_password_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_login_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_registration_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_reset_password_live_test.exs create mode 100644 services/bright/test/bright_web/live/user_settings_live_test.exs create mode 100644 services/bright/test/bright_web/user_auth_test.exs create mode 100644 services/bright/test/support/fixtures/accounts_fixtures.ex diff --git a/config/deploy.yml b/config/deploy.yml index 4982eec..93763a9 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -53,9 +53,12 @@ env: PORT: 4000 DATABASE_HOST: futureporn-db MIX_ENV: prod + SUPERSTREAMER_URL: http://superstreamer-api + PUBLIC_S3_ENDPOINT: https://futureporn-b2.b-cdn.net secret: - DATABASE_URL - SECRET_KEY_BASE + - SUPERSTREAMER_AUTH_TOKEN # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: # "bin/kamal logs -r job" will tail logs from the first server in the job section. @@ -87,6 +90,12 @@ ssh: # accessories: + superstreamer: + env: + clear: + PUBLIC_API_ENDPOINT: https://api.superstreamer.futureporn.net + PUBLIC_STITCHER_ENDPOINT: http://localhost:52002 + db: image: postgres:15 host: 45.76.57.101 diff --git a/docker-compose.yml b/docker-compose.yml index cf12471..47066af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ services: + # This service is just here for env var re-use between all the superstreamer-* services. + # IDK if there is a way to do this without an image so we just run alpine which quits right away. superstreamer: image: alpine environment: @@ -7,7 +9,7 @@ services: - PUBLIC_STITCHER_ENDPOINT=http://localhost:52002 - REDIS_HOST=redis - REDIS_PORT=6379 - - DATABASE_URI=postgres://postgres:password@db:5432/sprs + - DATABASE_URI=postgres://postgres:password@db:5432/superstreamer env_file: .kamal/secrets.development superstreamer-app: @@ -53,29 +55,38 @@ services: - redis_data:/data bright: - image: futureporn/bright:latest container_name: bright build: context: . dockerfile: dockerfiles/bright.dockerfile - working_dir: /app + target: dev + args: + - MIX_ENV=dev environment: MIX_ENV: dev PORT: "4000" DATABASE_HOSTNAME: db + SUPERSTREAMER_URL: http://superstreamer-api:52001 + PUBLIC_S3_ENDPOINT: https://fp-dev.b-cdn.net env_file: - .kamal/secrets.development ports: - '4000:4000' depends_on: - db - volumes: - - ./services/bright/lib:/app/lib - develop: - watch: - - action: sync+restart - path: ./services/bright/mix.exs - target: /app + # volumes: + # - ./services/bright/lib:/app/lib + # develop: + # watch: + # - action: sync + # path: ./services/bright/ + # target: /app/ + # - action: sync+restart + # path: ./services/bright/application.ex + # target: /app/lib/bright/application.ex + # - action: sync+restart + # path: ./services/bright/mix.exs + # target: /app/mix.exs db: image: postgres:15 diff --git a/dockerfiles/bright.dockerfile b/dockerfiles/bright.dockerfile index 81f1210..dcf0564 100644 --- a/dockerfiles/bright.dockerfile +++ b/dockerfiles/bright.dockerfile @@ -18,10 +18,11 @@ ARG DEBIAN_VERSION=bullseye-20241202-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" -FROM ${BUILDER_IMAGE} as builder + +FROM ${BUILDER_IMAGE} AS builder # install build dependencies -RUN apt-get update -y && apt-get install -y build-essential git \ +RUN apt-get update -y && apt-get install -y build-essential git inotify-tools \ && apt-get clean && rm -f /var/lib/apt/lists/*_* # prepare build dir @@ -32,7 +33,9 @@ RUN mix local.hex --force && \ mix local.rebar --force # set build ENV -ENV MIX_ENV="prod" +ARG MIX_ENV +ENV MIX_ENV $MIX_ENV +RUN echo MIX_ENV=$MIX_ENV # install mix dependencies COPY ./services/bright/mix.exs ./services/bright/mix.lock ./ @@ -43,6 +46,7 @@ RUN mkdir config # to ensure any relevant config change will trigger the dependencies # to be re-compiled. COPY ./services/bright/config/config.exs ./services/bright/config/${MIX_ENV}.exs config/ +RUN ls -la config/ RUN mix deps.compile COPY ./services/bright/priv priv @@ -66,7 +70,6 @@ RUN mix release ## dev target FROM builder AS dev -WORKDIR "/app" CMD [ "mix", "ecto.setup", "&&", "mix", "phx.server" ] diff --git a/services/bright/assets/images/favicon.png b/services/bright/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ac8c1aead05d6d622900b43c64ba91e20420f678 GIT binary patch literal 743 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%0AcwzGU#Vaw$ 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 L4x0ae$Nc{Q#)~j5 literal 0 HcmV?d00001 diff --git a/services/bright/assets/static/favicon.ico b/services/bright/assets/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..04e664e9f3c928727b2cc223f14dea8fa0e4aa28 GIT binary patch literal 323 zcmZQzU<5)32LT|-!l28@z#zuJz|a}s=g!L|#RX*YdV0770ci&i=3oPot1=7k0jV#Z zE{-7;bKc(G%X`Q`f-SL0L-XY(j>QLf&K&015Mfo;Af+rAsc19lZ=870lagOg{zUNn z{`lig$(bZjIyWv{V#XDV&#AQ^z9F`yWxB9Roc|r-v7&@(ja!BW)0)oz;%XC zXI+5sL)J4st^ITV%-!qR^R;))Z(zB=Gz%TSDsNnRpJ)0$??1n!lfDa2V2E;<48@oK z>~r|WmhQozyC5?!e!;YN>I#fo8q|oyX(#I2t<`~wpRt~pFYfMlQPdmge+ExiKbLh* G2~7ZH&wIE4 literal 0 HcmV?d00001 diff --git a/services/bright/config/config.exs b/services/bright/config/config.exs index 5d181f2..e52d3ac 100644 --- a/services/bright/config/config.exs +++ b/services/bright/config/config.exs @@ -23,14 +23,14 @@ config :bright, BrightWeb.Endpoint, live_view: [signing_salt: "JGNufzrG"] -# config :bright, Oban, -# engine: Oban.Engines.Basic, -# queues: [default: 10], -# repo: Bright.Repo, -# plugins: [ -# {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}, -# {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)} -# ] +config :bright, Oban, + engine: Oban.Engines.Basic, + queues: [default: 10], + repo: Bright.Repo, + plugins: [ + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}, + {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)} + ] # Configures the mailer diff --git a/services/bright/lib/bright/accounts.ex b/services/bright/lib/bright/accounts.ex new file mode 100644 index 0000000..0b92a86 --- /dev/null +++ b/services/bright/lib/bright/accounts.ex @@ -0,0 +1,353 @@ +defmodule Bright.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Bright.Repo + + alias Bright.Accounts.{User, UserToken, UserNotifier} + + ## 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 user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + 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. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end +end diff --git a/services/bright/lib/bright/accounts/user.ex b/services/bright/lib/bright/accounts/user.ex new file mode 100644 index 0000000..8e4a7b8 --- /dev/null +++ b/services/bright/lib/bright/accounts/user.ex @@ -0,0 +1,161 @@ +defmodule Bright.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> validate_email(opts) + |> validate_password(opts) + end + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, Bright.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Bright.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/services/bright/lib/bright/accounts/user_notifier.ex b/services/bright/lib/bright/accounts/user_notifier.ex new file mode 100644 index 0000000..c047d86 --- /dev/null +++ b/services/bright/lib/bright/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Bright.Accounts.UserNotifier do + import Swoosh.Email + + alias Bright.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Bright", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/services/bright/lib/bright/accounts/user_token.ex b/services/bright/lib/bright/accounts/user_token.ex new file mode 100644 index 0000000..0bf73d6 --- /dev/null +++ b/services/bright/lib/bright/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule Bright.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Bright.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Bright.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in by_token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/services/bright/lib/bright/application.ex b/services/bright/lib/bright/application.ex index 3e7429f..6678c5e 100644 --- a/services/bright/lib/bright/application.ex +++ b/services/bright/lib/bright/application.ex @@ -7,7 +7,7 @@ defmodule Bright.Application do @impl true def start(_type, _args) do - # Oban.Telemetry.attach_default_logger(level: :debug) + Oban.Telemetry.attach_default_logger(level: :debug) children = [ BrightWeb.Telemetry, Bright.Repo, @@ -15,7 +15,7 @@ defmodule Bright.Application do {Phoenix.PubSub, name: Bright.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: Bright.Finch}, - # {Oban, Application.fetch_env!(:bright, Oban)}, + {Oban, Application.fetch_env!(:bright, Oban)}, # Start a worker by calling: Bright.Worker.start_link(arg) # {Bright.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/services/bright/lib/bright/jobs/create_hls_playlist.ex b/services/bright/lib/bright/jobs/create_hls_playlist.ex index 8b859e2..0fd5e8c 100644 --- a/services/bright/lib/bright/jobs/create_hls_playlist.ex +++ b/services/bright/lib/bright/jobs/create_hls_playlist.ex @@ -2,7 +2,7 @@ defmodule Bright.Jobs.CreateHlsPlaylist do - use Oban.Worker, queue: :default, max_attempts: 3 + use Oban.Worker, queue: :default, max_attempts: 1 alias Bright.Repo alias Bright.Streams.Vod @@ -23,9 +23,9 @@ defmodule Bright.Jobs.CreateHlsPlaylist do with {:ok, transcode_job_id} <- start_transcode(payload), {:ok, asset_id} <- poll_job_completion(transcode_job_id), - {:ok, package_job_id} <- start_package(transcode_job_id), + {:ok, package_job_id} <- start_package(asset_id), {:ok, asset_id} <- poll_job_completion(package_job_id) do - update_vod_with_playlist_url(vod, package_job_id) + update_vod_with_playlist_url(vod, asset_id) Logger.info("HLS playlist created and updated for VOD ID #{vod_id}") else @@ -42,6 +42,7 @@ defmodule Bright.Jobs.CreateHlsPlaylist do %{"type" => "video", "path" => input_url} ], "streams" => [ + %{"type" => "video", "codec" => "h264", "height" => 1080}, %{"type" => "video", "codec" => "h264", "height" => 720}, %{"type" => "video", "codec" => "h264", "height" => 144}, %{"type" => "audio", "codec" => "aac"} @@ -50,30 +51,49 @@ defmodule Bright.Jobs.CreateHlsPlaylist do } end + defp start_transcode(payload) do Logger.info("Starting transcode with payload: #{inspect(payload)}") headers = auth_headers() - case HTTPoison.post("#{@api_url}/transcode", Jason.encode!(payload), headers) do + Logger.info("auth headers as follows") + Logger.info(inspect(headers)) + + Logger.info("now we will POST /transcode to api_url=#{@api_url}") + data = case HTTPoison.post("#{@api_url}/transcode", Jason.encode!(payload), headers) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - case Jason.decode(body) do - {:ok, %{"jobId" => job_id}} -> {:ok, job_id} - {:error, _} = error -> error - end + {:ok, Jason.decode!(body)} {:ok, %HTTPoison.Response{status_code: status, body: body}} -> {:error, %{status: status, body: body}} {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} + + failed -> + Logger.error("Failed to POST /transcode: #{inspect(failed)}") + {:error, :failed} end + + Logger.info("we got some data as follows. #{inspect(data)}") + + formatted = case data do + {:ok, %{"jobId" => transcode_job_id}} -> + {:ok, transcode_job_id} + end + + Logger.info("start_transcode has finished it's duties and is returning the following formatted data.") + Logger.info(inspect(formatted)) + + formatted + end defp start_package(asset_id) do payload = %{ "assetId" => asset_id, - "concurrency" => 5, + "concurrency" => 1, "public" => false } @@ -81,22 +101,37 @@ defmodule Bright.Jobs.CreateHlsPlaylist do headers = auth_headers() - Logger.info("@TODO @TODO @TODO") - {:error, "missing implementation."} + Logger.info("auth headers as follows") + Logger.info(inspect(headers)) - # case HTTPoison.post("#{@api_url}/package", Jason.encode!(payload), headers) do - # {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - # case Jason.decode(body) do - # {:ok, %{"jobId" => job_id}} -> {:ok, job_id} - # {:error, _} = error -> error - # end + Logger.info("now we will POST /package to api_url=#{@api_url}") + data = case HTTPoison.post("#{@api_url}/package", Jason.encode!(payload), headers) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, Jason.decode!(body)} - # {:ok, %HTTPoison.Response{status_code: status, body: body}} -> - # {:error, %{status: status, body: body}} + {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + {:error, %{status: status, body: body}} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} + + failed -> + Logger.error("Failed to POST /package: #{inspect(failed)}") + {:error, :failed} + end + + Logger.info("we got some data as follows. #{inspect(data)}") + + formatted = case data do + {:ok, %{"jobId" => package_job_id}} -> + {:ok, package_job_id} + end + + Logger.info("start_package has finished it's duties and is returning the following formatted data.") + Logger.info(inspect(formatted)) + + formatted - # {:error, %HTTPoison.Error{reason: reason}} -> - # {:error, reason} - # end end defp poll_job_completion(job_id) do @@ -107,14 +142,33 @@ defmodule Bright.Jobs.CreateHlsPlaylist do Enum.reduce_while(1..max_retries, :ok, fn _, acc -> case get_job_status(job_id) do - {:ok, "completed"} -> + {:ok, "completed", data} -> Logger.info("Job ID #{job_id} completed successfully") - {:halt, :ok} + Logger.info("here we need to return {:ok, asset_id}") + Logger.info(inspect(data)) + formatted = case data do + {:ok, %{"outputData" => outputData}} -> + case Jason.decode(outputData) do + {:ok, decoded} -> decoded + {:error, reason} -> + Logger.error("Failed to decode outputData: #{inspect(reason)}") + %{} + end + end - {:ok, _state} -> + + + Logger.info(">>>> formatted=#{inspect(formatted)}") + {:halt, {:ok, formatted["assetId"]}} + + {:ok, state, data} -> + Logger.info("Job ID #{job_id} #{state}. Re-polling in #{poll_interval}.") :timer.sleep(poll_interval) {:cont, acc} + {:ok, "failed", _data} -> + {:halt, {:error, "superstreamer reports that the job failed."}} + {:error, reason} -> Logger.error("Error polling job ID #{job_id}: #{inspect(reason)}") {:halt, {:error, reason}} @@ -125,18 +179,42 @@ defmodule Bright.Jobs.CreateHlsPlaylist do defp get_job_status(job_id) do headers = auth_headers() + data = case HTTPoison.get("#{@api_url}/jobs/#{job_id}", headers) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, Jason.decode!(body)} + + {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + {:error, %{status: status, body: body}} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} + + failed -> + Logger.error("Failed to GET /jobs/: #{inspect(failed)}") + {:error, :failed} + end + + status = case data do + {:ok, %{"state" => state}} -> + {:ok, state, data} + end + + Logger.info("job #{job_id} status=#{inspect(status)}") + + status + end - defp update_vod_with_playlist_url(vod, job_id) do - playlist_url = generate_playlist_url(job_id) + defp update_vod_with_playlist_url(vod, asset_id) do + playlist_url = generate_playlist_url(asset_id) vod |> Ecto.Changeset.change(playlist_url: playlist_url) |> Repo.update!() end - defp generate_playlist_url(job_id), do: "#{@public_s3_endpoint}/package/#{job_id}/hls/master.m3u8" + defp generate_playlist_url(asset_id), do: "#{@public_s3_endpoint}/package/#{asset_id}/hls/master.m3u8" defp auth_headers do [ diff --git a/services/bright/lib/bright_web.ex b/services/bright/lib/bright_web.ex index f3e83d4..8ed9cfe 100644 --- a/services/bright/lib/bright_web.ex +++ b/services/bright/lib/bright_web.ex @@ -17,7 +17,7 @@ defmodule BrightWeb do those modules here. """ - def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets fonts images favicon.ico favicon.png robots.txt) def router do quote do @@ -86,7 +86,6 @@ defmodule BrightWeb do # Core UI components and translation import BrightWeb.CoreComponents import BrightWeb.Gettext - import BrightWeb.NavigationComponents # Shortcut for generating JS commands alias Phoenix.LiveView.JS diff --git a/services/bright/lib/bright_web/components/layouts/app.html.heex b/services/bright/lib/bright_web/components/layouts/app.html.heex index fdfd902..5c0997d 100644 --- a/services/bright/lib/bright_web/components/layouts/app.html.heex +++ b/services/bright/lib/bright_web/components/layouts/app.html.heex @@ -1,5 +1,3 @@ -<.navbar> -
<.flash_group flash={@flash} /> <%= @inner_content %> diff --git a/services/bright/lib/bright_web/components/layouts/root.html.heex b/services/bright/lib/bright_web/components/layouts/root.html.heex index 9c4a422..7e6d3f7 100644 --- a/services/bright/lib/bright_web/components/layouts/root.html.heex +++ b/services/bright/lib/bright_web/components/layouts/root.html.heex @@ -4,24 +4,136 @@ + + + + + + + + <.live_title suffix=" · Futureporn"> <%= assigns[:page_title] || "Bright" %> - <%# - - %> + - <%# - - %> - - + <%= @inner_content %> diff --git a/services/bright/lib/bright_web/components/navigation_components.ex b/services/bright/lib/bright_web/components/navigation_components.ex deleted file mode 100644 index 8fc2a1a..0000000 --- a/services/bright/lib/bright_web/components/navigation_components.ex +++ /dev/null @@ -1,120 +0,0 @@ -defmodule BrightWeb.NavigationComponents do - - @moduledoc """ - Components for user navigation - """ - - use Phoenix.Component - use Phoenix.VerifiedRoutes, - endpoint: BrightWeb.Endpoint, - router: BrightWeb.Router, - statics: BrightWeb.static_paths() - - alias Phoenix.LiveView.JS - - - @doc """ - Renders a Bulma navbar component. - - ## Examples - - <.navbar brand="MyApp"> - <:start> - Home - About - - <:end> - Login - Sign Up - - - """ - attr :rest, :global, doc: "any additional attributes for the navbar element" - - slot :start_slot, doc: "slot for navbar items aligned to the start" - slot :end_slot, doc: "slot for navbar items aligned to the end" - - def navbar(assigns) do - ~H""" - - """ - end - -end diff --git a/services/bright/lib/bright_web/controllers/page_html/about.html.heex b/services/bright/lib/bright_web/controllers/page_html/about.html.heex index 5fdd3a3..7987ab5 100644 --- a/services/bright/lib/bright_web/controllers/page_html/about.html.heex +++ b/services/bright/lib/bright_web/controllers/page_html/about.html.heex @@ -1,13 +1,12 @@ <.flash_group flash={@flash} /> -<.navbar />

About

-

Welcome to Futureporn, a platform built by fans, for fans, dedicated to preserving the moments that matter in the world of live streaming. It all started with a simple need: capturing ProjektMelody's streams on Chaturbate. Chaturbate doesn’t save VODs, and sometimes we missed the magic. Other times, creators like ProjektMelody faced unnecessary de-platforming for simply being unique. We wanted to create a space where this content could endure, unshaken by the tides of censorship or fleeting platforms.

+

Welcome to Futureporn, a platform built by fans, for fans, dedicated to preserving the moments that matter in the world of R-18 VTuber live streaming. It all started with a simple need: capturing ProjektMelody's streams on Chaturbate. Chaturbate doesn’t save VODs, and sometimes we missed the magic. Other times, creators like ProjektMelody faced unnecessary de-platforming for simply being unique. We wanted to create a space where this content could endure, unshaken by the tides of censorship or fleeting platforms.

diff --git a/services/bright/lib/bright_web/controllers/page_html/api.html.heex b/services/bright/lib/bright_web/controllers/page_html/api.html.heex index cf9a853..1d77ee3 100644 --- a/services/bright/lib/bright_web/controllers/page_html/api.html.heex +++ b/services/bright/lib/bright_web/controllers/page_html/api.html.heex @@ -1,6 +1,5 @@ <.flash_group flash={@flash} /> -<.navbar />

API

diff --git a/services/bright/lib/bright_web/controllers/page_html/home.html.heex b/services/bright/lib/bright_web/controllers/page_html/home.html.heex index e9af2fe..1685cbe 100644 --- a/services/bright/lib/bright_web/controllers/page_html/home.html.heex +++ b/services/bright/lib/bright_web/controllers/page_html/home.html.heex @@ -1,4 +1,4 @@ -<.navbar /> +
<.flash_group flash={@flash} /> @@ -15,14 +15,15 @@
- +
+

Behold: the pre-alpha preview, pre-flight, pre-production, preposterously experimental pre-release teaser demo of Futureporn 2.0.

-

This is a development pre-alpha preview pre-flight test sample demo of Futureporn version 2.0

-

Work is in progress, check back each week for updates.

+

(Yeah, we’re still testing.) check back each week for updates.

- Streams Archive - + Streams Archive
+
+ diff --git a/services/bright/lib/bright_web/controllers/stream_html/index.html.heex b/services/bright/lib/bright_web/controllers/stream_html/index.html.heex index 1637061..64bea2a 100644 --- a/services/bright/lib/bright_web/controllers/stream_html/index.html.heex +++ b/services/bright/lib/bright_web/controllers/stream_html/index.html.heex @@ -24,9 +24,11 @@
<%= for vtuber <- stream.vtubers do %>
-
- {vtuber.display_name} -
+ <.link href={~p"/vtubers/#{vtuber.id}"}> +
+ {vtuber.display_name} +
+
<% end %>
diff --git a/services/bright/lib/bright_web/controllers/stream_html/show.html.heex b/services/bright/lib/bright_web/controllers/stream_html/show.html.heex index b2e27d3..633c9de 100644 --- a/services/bright/lib/bright_web/controllers/stream_html/show.html.heex +++ b/services/bright/lib/bright_web/controllers/stream_html/show.html.heex @@ -21,7 +21,7 @@ <:item title="Vods">
    -
  • <.link href={~p"/vods/#{vod.id}"}>{vod.s3_key}
  • +
  • <.link href={~p"/vods/#{vod.id}"}>{vod.id}
<:item title="Vtubers"> diff --git a/services/bright/lib/bright_web/controllers/user_session_controller.ex b/services/bright/lib/bright_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..bb69007 --- /dev/null +++ b/services/bright/lib/bright_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule BrightWeb.UserSessionController do + use BrightWeb, :controller + + alias Bright.Accounts + alias BrightWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/services/bright/lib/bright_web/controllers/vod_html/index.html.heex b/services/bright/lib/bright_web/controllers/vod_html/index.html.heex index 2c35b47..c05a9d8 100644 --- a/services/bright/lib/bright_web/controllers/vod_html/index.html.heex +++ b/services/bright/lib/bright_web/controllers/vod_html/index.html.heex @@ -8,6 +8,7 @@ <.table id="vods" rows={@vods} row_click={&JS.navigate(~p"/vods/#{&1}")}> + <:col :let={vod} label="ID">{vod.id} <:col :let={vod} label="S3 cdn url">{vod.s3_cdn_url} <:col :let={vod} label="S3 upload">{vod.s3_upload_id} <:col :let={vod} label="S3 key">{vod.s3_key} diff --git a/services/bright/lib/bright_web/controllers/vod_html/show.html.heex b/services/bright/lib/bright_web/controllers/vod_html/show.html.heex index 59945de..8b7cd6a 100644 --- a/services/bright/lib/bright_web/controllers/vod_html/show.html.heex +++ b/services/bright/lib/bright_web/controllers/vod_html/show.html.heex @@ -1,18 +1,3 @@ -<.header> - Vod {@vod.id} - <:subtitle>This is a vod record from the database. - <:actions> - <.link href={~p"/vods/#{@vod}/edit"}> - <.button>Edit vod - - - - - -<%#
- -
%> - @@ -22,27 +7,41 @@ - +<%= if @vod.playlist_url do %> + +<% else %> +
+

Video is processing...

+
+<% end %> + + +<.header class="mt-3"> + Vod {@vod.id} + <:actions> + <.link href={~p"/vods/#{@vod}/edit"}> + <.button>Edit vod + + + @@ -53,8 +52,6 @@ <:item title="S3 upload">{@vod.s3_upload_id} <:item title="S3 key">{@vod.s3_key} <:item title="S3 bucket">{@vod.s3_bucket} - <:item title="Mux asset">{@vod.mux_asset_id} - <:item title="Mux playback">{@vod.mux_playback_id} <:item title="Ipfs CID">{@vod.ipfs_cid} <:item title="Torrent">{@vod.torrent} diff --git a/services/bright/lib/bright_web/live/user_confirmation_instructions_live.ex b/services/bright/lib/bright_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..9e0c9b7 --- /dev/null +++ b/services/bright/lib/bright_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,51 @@ +defmodule BrightWeb.UserConfirmationInstructionsLive do + use BrightWeb, :live_view + + alias Bright.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/services/bright/lib/bright_web/live/user_confirmation_live.ex b/services/bright/lib/bright_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..e62885e --- /dev/null +++ b/services/bright/lib/bright_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule BrightWeb.UserConfirmationLive do + use BrightWeb, :live_view + + alias Bright.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + form = to_form(%{"token" => token}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/services/bright/lib/bright_web/live/user_forgot_password_live.ex b/services/bright/lib/bright_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..0a202b3 --- /dev/null +++ b/services/bright/lib/bright_web/live/user_forgot_password_live.ex @@ -0,0 +1,50 @@ +defmodule BrightWeb.UserForgotPasswordLive do + use BrightWeb, :live_view + + alias Bright.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/services/bright/lib/bright_web/live/user_login_live.ex b/services/bright/lib/bright_web/live/user_login_live.ex new file mode 100644 index 0000000..ff10836 --- /dev/null +++ b/services/bright/lib/bright_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule BrightWeb.UserLoginLive do + use BrightWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Log in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Logging in..." class="w-full"> + Log in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = Phoenix.Flash.get(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/services/bright/lib/bright_web/live/user_registration_live.ex b/services/bright/lib/bright_web/live/user_registration_live.ex new file mode 100644 index 0000000..f4addbb --- /dev/null +++ b/services/bright/lib/bright_web/live/user_registration_live.ex @@ -0,0 +1,87 @@ +defmodule BrightWeb.UserRegistrationLive do + use BrightWeb, :live_view + + alias Bright.Accounts + alias Bright.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/services/bright/lib/bright_web/live/user_reset_password_live.ex b/services/bright/lib/bright_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..0882b88 --- /dev/null +++ b/services/bright/lib/bright_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule BrightWeb.UserResetPasswordLive do + use BrightWeb, :live_view + + alias Bright.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/services/bright/lib/bright_web/live/user_settings_live.ex b/services/bright/lib/bright_web/live/user_settings_live.ex new file mode 100644 index 0000000..44e71f2 --- /dev/null +++ b/services/bright/lib/bright_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule BrightWeb.UserSettingsLive do + use BrightWeb, :live_view + + alias Bright.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/services/bright/lib/bright_web/router.ex b/services/bright/lib/bright_web/router.ex index ec63843..967dfd6 100644 --- a/services/bright/lib/bright_web/router.ex +++ b/services/bright/lib/bright_web/router.ex @@ -1,6 +1,7 @@ defmodule BrightWeb.Router do use BrightWeb, :router + import BrightWeb.UserAuth pipeline :browser do @@ -10,21 +11,12 @@ defmodule BrightWeb.Router do plug :put_root_layout, html: {BrightWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end - defp fetch_current_user(conn, _) do - if user_uuid = get_session(conn, :current_uuid) do - assign(conn, :current_uuid, user_uuid) - else - new_uuid = Ecto.UUID.generate() - conn - |> assign(:current_uuid, new_uuid) - |> put_session(:current_uuid, new_uuid) - end - end @@ -32,14 +24,47 @@ defmodule BrightWeb.Router do plug :accepts, ["json"] end - scope "/" do - pipe_through [:browser] + # scope "/" do + # pipe_through [:browser, :require_authenticated_user, :require_admin_user] + # ## !!! DANGER, platforms must only be writable by admins, (unless we implement SVG sanitizing) + # get "/platforms/new", PlatformController, :new + # post "/platforms", PlatformController, :create + # get "/platforms/:id/edit", PlatformController, :edit + # patch "/platforms/:id", PlatformController, :update + # put "/platforms/:id", PlatformController, :update + + # end + + + scope "/" do + pipe_through [:browser, :require_authenticated_user] + + get "/streams/new", StreamController, :new + post "/streams", StreamController, :create + + # get "/vods/new", VodController, :new + # post "/vods", VodController, :create + + # resources "/vt", VtuberController do + # get "/vods/new", VodController, :new + # post "/vods", VodController, :create + # get "/vtubers/:id/edit", VtuberController, :edit + # end + + resources "/vtubers", VtuberController do + get "/vods/new", VodController, :new + post "/vods", VodController, :create + get "/vtubers/:id/edit", VtuberController, :edit + end + + get "/tags/new", TagController, :new + post "/tags", TagController, :create - # get "/streams/new", StreamController, :new - # post "/streams", StreamController, :create end + + scope "/", BrightWeb do pipe_through :browser @@ -52,29 +77,34 @@ defmodule BrightWeb.Router do resources "/orders", OrderController, only: [:create, :show] - resources "/archive", StreamController get "/streams", StreamController, :index get "/streams/:id", StreamController, :show - get "/streams/new", StreamController, :new - post "/streams", StreamController, :create + + # get "/vods", VodController, :index + # get "/vods/:id", VodController, :show resources "/vods", VodController - resources "/vtubers", VtuberController + + get "/tags", TagController, :index + get "/tags:id", TagController, :show + + get "/platforms", PlatformController, :index + get "/platforms/:id", PlatformController, :show + + + + get "/vtubers", VtuberController, :index + get "/vtubers/:id", VtuberController, :show + resources "/vt", VtuberController do - resources "/vods", VodController + get "/vods", VodController, :index + get "/vods/:id", VodController, :show end - # resources "/users", UserController - resources "/tags", TagController - - ## @todo DANGER, platforms must only be writable by admins, (unless we implement SVG sanitizing) - resources "/platforms", PlatformController - - end @@ -111,4 +141,42 @@ defmodule BrightWeb.Router do + + ## Authentication routes + + scope "/", BrightWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{BrightWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + + post "/users/log_in", UserSessionController, :create + end + + scope "/", BrightWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{BrightWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end + end + + scope "/", BrightWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + + live_session :current_user, + on_mount: [{BrightWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end + end end diff --git a/services/bright/lib/bright_web/user_auth.ex b/services/bright/lib/bright_web/user_auth.ex new file mode 100644 index 0000000..6eec60f --- /dev/null +++ b/services/bright/lib/bright_web/user_auth.ex @@ -0,0 +1,229 @@ +defmodule BrightWeb.UserAuth do + use BrightWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Bright.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_bright_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @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, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + 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 + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + BrightWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule BrightWeb.PageLive do + use BrightWeb, :live_view + + on_mount {BrightWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{BrightWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + 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: ~p"/users/log_in") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/services/bright/mix.exs b/services/bright/mix.exs index 0efccb2..7681137 100644 --- a/services/bright/mix.exs +++ b/services/bright/mix.exs @@ -1,4 +1,5 @@ defmodule Bright.MixProject do + use Mix.Project def project do diff --git a/services/bright/mix.lock b/services/bright/mix.lock index cdfd8b7..228ab82 100644 --- a/services/bright/mix.lock +++ b/services/bright/mix.lock @@ -44,6 +44,7 @@ "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, + "redirect": {:hex, :redirect, "0.4.0", "98b46053504ee517bc3ad2fd04c064b64b48d339e1e18266355b30c4f8bb52b0", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.3 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dfa29a8ecbad066ed0b73b34611cf24c78101719737f37bdf750f39197d67b97"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "superstreamer_player": {:git, "https://github.com/superstreamerapp/superstreamer.git", "9e868acede851f396b3db98fb9799ab4bf712b02", [sparse: "packages/player"]}, "swoosh": {:hex, :swoosh, "1.17.5", "14910d267a2633d4335917b37846e376e2067815601592629366c39845dad145", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "629113d477bc82c4c3bffd15a25e8becc1c7ccc0f0e67743b017caddebb06f04"}, diff --git a/services/bright/priv/repo/migrations/20250113171439_update_users_to_auth_schema.exs b/services/bright/priv/repo/migrations/20250113171439_update_users_to_auth_schema.exs new file mode 100644 index 0000000..9be16da --- /dev/null +++ b/services/bright/priv/repo/migrations/20250113171439_update_users_to_auth_schema.exs @@ -0,0 +1,35 @@ +defmodule Bright.Repo.Migrations.UpdateUsersToAuthSchema do + use Ecto.Migration + + def change do + # Ensure the citext extension exists + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + # Modify the users table + alter table(:users) do + remove :name + remove :bio + remove :number_of_pets + modify :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + end + + # Ensure unique constraint on email + create unique_index(:users, [:email]) + + # Create the users_tokens table + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + # Add indexes for users_tokens + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/services/bright/priv/static/favicon.ico b/services/bright/priv/static/favicon.ico deleted file mode 100644 index 7f372bfc21cdd8cb47585339d5fa4d9dd424402f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y diff --git a/services/bright/priv/static/images/logo.svg b/services/bright/priv/static/images/logo.svg deleted file mode 100644 index 9f26bab..0000000 --- a/services/bright/priv/static/images/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt b/services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz b/services/bright/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..043be337a41f4dd7232202988a782fd515bf5af3 GIT binary patch literal 164 zcmV;V09*ebiwFP!0000014Ya+4#F@D1<<{x_)<3{nmsc&01l8+w~3U*RqQGpA5#V- zFJJ!ujkpsbs_x>Q>%C8nXI9a-PTV&4Pf<(8$_)#@jzU#~Ca$oH+@Xv^2pS2$$z&U> zDbp|xBOZ)7RD_%%ds?Uo*2d-R8Q>%C8nXI9a-PTV&4Pf<(8$_)#@jzU#~Ca$oH+@Xv^2pS2$$z&U> zDbp|xBOZ)7RD_%%ds?Uo*2d-R8 + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/services/bright/test/bright_web/controllers/user_session_controller_test.exs b/services/bright/test/bright_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..bc96432 --- /dev/null +++ b/services/bright/test/bright_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule BrightWeb.UserSessionControllerTest do + use BrightWeb.ConnCase, async: true + + import Bright.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_bright_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/services/bright/test/bright_web/live/user_confirmation_instructions_live_test.exs b/services/bright/test/bright_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..1ed0b96 --- /dev/null +++ b/services/bright/test/bright_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule BrightWeb.UserConfirmationInstructionsLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + alias Bright.Accounts + alias Bright.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/services/bright/test/bright_web/live/user_confirmation_live_test.exs b/services/bright/test/bright_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..692d7e5 --- /dev/null +++ b/services/bright/test/bright_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule BrightWeb.UserConfirmationLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + alias Bright.Accounts + alias Bright.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/services/bright/test/bright_web/live/user_forgot_password_live_test.exs b/services/bright/test/bright_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..d4d3e8a --- /dev/null +++ b/services/bright/test/bright_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule BrightWeb.UserForgotPasswordLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + alias Bright.Accounts + alias Bright.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/services/bright/test/bright_web/live/user_login_live_test.exs b/services/bright/test/bright_web/live/user_login_live_test.exs new file mode 100644 index 0000000..f4bb46f --- /dev/null +++ b/services/bright/test/bright_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule BrightWeb.UserLoginLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/services/bright/test/bright_web/live/user_registration_live_test.exs b/services/bright/test/bright_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..e1cc5d5 --- /dev/null +++ b/services/bright/test/bright_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule BrightWeb.UserRegistrationLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/services/bright/test/bright_web/live/user_reset_password_live_test.exs b/services/bright/test/bright_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..46330ec --- /dev/null +++ b/services/bright/test/bright_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule BrightWeb.UserResetPasswordLiveTest do + use BrightWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + alias Bright.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/services/bright/test/bright_web/live/user_settings_live_test.exs b/services/bright/test/bright_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..cdc04c6 --- /dev/null +++ b/services/bright/test/bright_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule BrightWeb.UserSettingsLiveTest do + use BrightWeb.ConnCase, async: true + + alias Bright.Accounts + import Phoenix.LiveViewTest + import Bright.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/services/bright/test/bright_web/user_auth_test.exs b/services/bright/test/bright_web/user_auth_test.exs new file mode 100644 index 0000000..07653df --- /dev/null +++ b/services/bright/test/bright_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule BrightWeb.UserAuthTest do + use BrightWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias Bright.Accounts + alias BrightWeb.UserAuth + import Bright.AccountsFixtures + + @remember_me_cookie "_bright_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, BrightWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + BrightWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: BrightWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: BrightWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/services/bright/test/support/conn_case.ex b/services/bright/test/support/conn_case.ex index 3c85964..f0a4591 100644 --- a/services/bright/test/support/conn_case.ex +++ b/services/bright/test/support/conn_case.ex @@ -61,4 +61,30 @@ defmodule BrightWeb.ConnCase do |> Phoenix.ConnTest.init_test_session(%{}) |> Plug.Conn.put_session(:platform_token, token) end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = Bright.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Bright.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/services/bright/test/support/fixtures/accounts_fixtures.ex b/services/bright/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..487cb72 --- /dev/null +++ b/services/bright/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Bright.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Bright.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Bright.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end