Replace Pow with generated Phoenix auth (#10)

This commit is contained in:
liamwhite 2020-07-28 16:56:26 -04:00 committed by GitHub
parent f006635971
commit 98f4166ea8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
106 changed files with 3188 additions and 1380 deletions

View file

@ -20,6 +20,9 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
elasticsearch:
image: elasticsearch:7.6.2
ports: ['9200:9200']
redis:
image: redis:5.0.9
ports: ['6379:6379']
@ -30,15 +33,22 @@ jobs:
apt-get update
apt-get -yqq install libpq-dev postgresql-client
- name: Install Dependencies
env:
MIX_ENV: 'test'
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
- name: Run Tests
env:
MIX_ENV: 'test'
PGUSER: 'postgres'
PGPASSWORD: 'postgres'
PGDATABASE: 'philomena_test'
PGPORT: 5432
PGHOST: db
run: mix test
run: |
mix ecto.create
mix ecto.load
mix reindex_all
mix test

3
.gitignore vendored
View file

@ -42,5 +42,8 @@ npm-debug.log
# Intellij IDEA
.idea
# ElixirLS
.elixir_ls
# Index dumps
*.jsonl

View file

@ -31,17 +31,6 @@ config :philomena,
proxy_host: nil,
app_dir: File.cwd!()
config :philomena, :pow,
user: Philomena.Users.User,
repo: Philomena.Repo,
web_module: PhilomenaWeb,
users_context: Philomena.Users,
extensions: [PowResetPassword, PowLockout, PowCaptcha, PowPersistentSession],
controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks,
mailer_backend: PhilomenaWeb.PowMailer,
captcha_verifier: Philomena.Captcha,
cache_store_backend: Pow.Store.Backend.MnesiaCache
config :exq,
queues: [{"videos", 2}, {"images", 4}, {"indexing", 16}],
scheduler_enable: true,

View file

@ -71,7 +71,7 @@ config :logger, :console, format: "[$level] $message\n"
config :logger, compile_time_purge_matching: [[application: :remote_ip], [application: :mint]]
# Set up mailer
config :philomena, PhilomenaWeb.Mailer, adapter: Bamboo.LocalAdapter
config :philomena, Philomena.Mailer, adapter: Bamboo.LocalAdapter
config :philomena, :mailer_address, "noreply@philomena.lc"

View file

@ -43,7 +43,7 @@ config :philomena, Philomena.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "32")
config :philomena, PhilomenaWeb.Mailer,
config :philomena, Philomena.Mailer,
adapter: Bamboo.SMTPAdapter,
server: System.get_env("SMTP_RELAY"),
hostname: System.get_env("SMTP_DOMAIN"),

View file

@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
# Configure your database
config :philomena, Philomena.Repo,
username: "postgres",
@ -8,11 +11,17 @@ config :philomena, Philomena.Repo,
pool: Ecto.Adapters.SQL.Sandbox
config :philomena,
redis_host: "redis"
elasticsearch_url: "http://elasticsearch:9200",
redis_host: "redis",
pwned_passwords: false,
captcha: false
config :exq,
host: "redis"
config :philomena, Philomena.Mailer, adapter: Bamboo.LocalAdapter
config :philomena, :mailer_address, "test@philomena.lc"
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :philomena, PhilomenaWeb.Endpoint,

View file

@ -8,12 +8,6 @@ defmodule Philomena.Application do
def start(_type, _args) do
# List all child processes to be supervised
children = [
# Connect to cluster nodes
{Cluster.Supervisor, [[philomena: [strategy: Cluster.Strategy.ErlangHosts]]]},
# Session storage
Philomena.MnesiaClusterSupervisor,
# Start the Ecto repository
Philomena.Repo,

View file

@ -1,3 +1,3 @@
defmodule PhilomenaWeb.Mailer do
defmodule Philomena.Mailer do
use Bamboo.Mailer, otp_app: :philomena
end

View file

@ -1,17 +0,0 @@
defmodule Philomena.MnesiaClusterSupervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{Pow.Store.Backend.MnesiaCache, extra_db_nodes: Node.list()},
Pow.Store.Backend.MnesiaCache.Unsplit
]
Supervisor.init(children, strategy: :one_for_one)
end
end

View file

@ -169,7 +169,10 @@ defmodule Philomena.Tags.Tag do
# with ascii quotes, trim space from end
name
|> String.downcase()
|> String.replace(~r/[[:space:]\x{00a0}\x{1680}\x{180e}\x{2000}-\x{200f}\x{202f}\x{205f}\x{3000}\x{feff}]+/u, " ")
|> String.replace(
~r/[[:space:]\x{00a0}\x{1680}\x{180e}\x{2000}-\x{200f}\x{202f}\x{205f}\x{3000}\x{feff}]+/u,
" "
)
|> String.replace(~r/[\x{00b4}\x{2018}\x{2019}\x{201a}\x{201b}\x{2032}]/u, "'")
|> String.replace(~r/[\x{201c}\x{201d}\x{201e}\x{201f}\x{2033}]/u, "\"")
|> String.trim()

View file

@ -7,8 +7,7 @@ defmodule Philomena.Users do
alias Ecto.Multi
alias Philomena.Repo
alias Philomena.Users.Uploader
alias Philomena.Users.User
alias Philomena.Users.{User, UserToken, UserNotifier, Uploader}
alias Philomena.{Forums, Forums.Forum}
alias Philomena.Topics
alias Philomena.Roles.Role
@ -19,21 +18,87 @@ defmodule Philomena.Users do
alias Philomena.Galleries
alias Philomena.Reports
use Pow.Ecto.Context,
repo: Repo,
user: User
## Database getters
@doc """
Returns the list of users.
Gets a user by API token.
## Examples
iex> list_users()
[%User{}, ...]
iex> get_user_by_authentication_token("5Ow89k7nW24E0K34d3zX")
%User{}
iex> get_user_by_authentication_token("invalid")
nil
"""
def list_users do
Repo.all(User)
def get_user_by_authentication_token(token) when is_binary(token) do
Repo.get_by(User, authentication_token: token)
end
@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, unlock_url_fun)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
cond do
is_nil(user) or not is_nil(user.locked_at) ->
nil
User.valid_password?(user, password) ->
user
|> User.successful_attempt_changeset()
|> Repo.update!()
true ->
user
|> User.failed_attempt_changeset()
|> Repo.update!()
|> maybe_send_unlock_instructions(unlock_url_fun)
nil
end
end
defp maybe_send_unlock_instructions(%{failed_attempts: attempts}, _unlock_url_fun)
when attempts < 10 do
nil
end
defp maybe_send_unlock_instructions(%User{} = user, unlock_url_fun) do
user
|> User.lock_changeset()
|> Repo.update!()
|> deliver_user_unlock_instructions(unlock_url_fun)
nil
end
@doc """
@ -52,24 +117,368 @@ defmodule Philomena.Users do
"""
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Creates a user.
Registers a user.
## Examples
iex> create_user(%{field: value})
iex> register_user(%{field: value})
{:ok, %User{}}
iex> create_user(%{field: bad_value})
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs \\ %{}) do
def register_user(attrs) do
%User{}
|> User.changeset(attrs)
|> 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)
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)
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 in 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.user_and_contexts_query(user, [context]))
end
@doc """
Delivers the update email instructions to the given user.
## Examples
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
{:ok, %{to: ..., body: ...}}
"""
def deliver_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 """
Unlocks the user by the given token.
If the token matches, the user is marked as unlocked
and the token is deleted.
"""
def unlock_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "unlock"),
%User{} = user <- Repo.one(query),
{:ok, %{user: user}} <- Repo.transaction(unlock_user_multi(user)) do
{:ok, user}
else
_ -> :error
end
end
defp unlock_user_multi(user) do
changeset = User.unlock_changeset(user)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["unlock"]))
end
@doc """
Delivers the unlock instructions to the given user.
## Examples
iex> deliver_user_unlock_instructions(user, &Routes.unlock_url(conn, :show, &1))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_unlock_instructions(%User{} = user, unlock_url_fun)
when is_function(unlock_url_fun, 1) do
if is_nil(user.locked_at) do
{:error, :not_locked}
else
{encoded_token, user_token} = UserToken.build_email_token(user, "unlock")
Repo.insert!(user_token)
UserNotifier.deliver_unlock_instructions(user, unlock_url_fun.(encoded_token))
end
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)
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.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 """
Generates a TOTP token.
"""
def generate_user_totp_token(user) do
{token, user_token} = UserToken.build_totp_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)
load_with_roles(query)
end
def user_totp_token_valid?(nil, _token) do
false
end
def user_totp_token_valid?(user, token) do
{:ok, query} = UserToken.verify_totp_token_query(user, token)
Repo.exists?(query)
end
@doc """
Deletes the signed token with the given context.
"""
def delete_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
:ok
end
@doc """
Deletes the signed token with the given context.
"""
def delete_totp_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "totp"))
:ok
end
## Confirmation
@doc """
Delivers the confirmation email instructions to the given user.
## Examples
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
{:ok, %{to: ..., body: ...}}
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :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.user_and_contexts_query(user, ["confirm"]))
end
## Reset password
@doc """
Delivers the reset password email to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &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.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
def change_user(%User{} = user) do
User.changeset(user, %{})
end
@doc """
Updates a user.
@ -227,38 +636,10 @@ defmodule Philomena.Users do
|> Repo.update()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user(user)
%Ecto.Changeset{source: %User{}}
"""
def change_user(%User{} = user) do
User.changeset(user, %{})
end
@impl Pow.Ecto.Context
def delete(user) do
{:error, User.changeset(user, %{})}
end
@impl Pow.Ecto.Context
def create(params) do
%User{}
|> User.creation_changeset(params)
|> Repo.insert()
end
@impl Pow.Ecto.Context
def get_by(clauses) do
User
|> join(:left, [u], _ in assoc(u, :roles))
|> join(:left, [u, _], _ in assoc(u, :current_filter))
|> preload([_, r, cf], current_filter: cf, roles: r)
|> Repo.get_by(clauses)
defp load_with_roles(query) do
query
|> Repo.one()
|> Repo.preload([:roles, :current_filter])
|> setup_roles()
end

View file

@ -3,14 +3,6 @@ defmodule Philomena.Users.User do
alias Philomena.Slug
use Ecto.Schema
use Pow.Ecto.Schema,
password_hash_methods: {&Password.hash_pwd_salt/1, &Password.verify_pass/2},
password_min_length: 6
use Pow.Extension.Ecto.Schema,
extensions: [PowResetPassword, PowLockout]
import Ecto.Changeset
alias Philomena.Schema.TagList
@ -30,7 +22,7 @@ defmodule Philomena.Users.User do
alias Philomena.Donations.Donation
@derive {Phoenix.Param, key: :slug}
@derive {Inspect, except: [:password]}
schema "users" do
has_many :links, UserLink
has_many :verified_links, UserLink, where: [aasm_state: "verified"]
@ -53,27 +45,20 @@ defmodule Philomena.Users.User do
# Authentication
field :email, :string
field :password, :string, virtual: true
field :encrypted_password, :string
field :password_hash, :string, source: :encrypted_password
field :reset_password_token, :string
field :reset_password_sent_at, :naive_datetime
field :remember_created_at, :naive_datetime
field :sign_in_count, :integer, default: 0
field :current_sign_in_at, :naive_datetime
field :last_sign_in_at, :naive_datetime
field :current_sign_in_ip, EctoNetwork.INET
field :last_sign_in_ip, EctoNetwork.INET
field :hashed_password, :string, source: :encrypted_password
field :confirmed_at, :naive_datetime
field :otp_required_for_login, :boolean
field :authentication_token, :string
# field :failed_attempts, :integer
field :failed_attempts, :integer
# field :unlock_token, :string
# field :locked_at, :naive_datetime
field :locked_at, :naive_datetime
field :encrypted_otp_secret, :string
field :encrypted_otp_secret_iv, :string
field :encrypted_otp_secret_salt, :string
field :consumed_timestep, :integer
field :otp_backup_codes, {:array, :string}
pow_user_fields()
# General attributes
field :name, :string
@ -147,16 +132,140 @@ defmodule Philomena.Users.User do
timestamps(inserted_at: :created_at)
end
@doc false
def changeset(user, attrs) do
@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.
"""
def registration_changeset(user, attrs) do
user
|> pow_changeset(attrs)
|> pow_extension_changeset(attrs)
|> cast(attrs, [])
|> validate_required([])
|> cast(attrs, [:name, :email, :password])
|> validate_name()
|> validate_email()
|> validate_password()
|> put_api_key()
|> put_slug()
|> unique_constraints()
end
defp validate_name(changeset) do
changeset
|> validate_required([:name])
|> validate_length(:name, max: 50)
end
defp validate_email(changeset) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unsafe_validate_unique(:email, Philomena.Repo)
end
defp validate_password(changeset) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 80)
|> prepare_changes(&hash_password/1)
end
defp hash_password(changeset) do
password = get_change(changeset, :password)
changeset
|> put_change(:hashed_password, Password.hash_pwd_salt(password))
|> delete_change(:password)
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) do
user
|> cast(attrs, [:email])
|> validate_email()
|> case do
%{changes: %{email: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, "did not change")
end
end
@doc """
A user changeset for changing the password.
"""
def password_changeset(user, attrs) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password()
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.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?(%User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Password.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
if valid_password?(changeset.data, password) do
changeset
else
add_error(changeset, :current_password, "is not valid")
end
end
def successful_attempt_changeset(user) do
change(user, failed_attempts: 0)
end
def failed_attempt_changeset(user) do
if not is_integer(user.failed_attempts) or user.failed_attempts < 0 do
change(user, failed_attempts: 1)
else
change(user, failed_attempts: user.failed_attempts + 1)
end
end
def lock_changeset(user) do
locked_at = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
change(user, locked_at: locked_at)
end
def unlock_changeset(user) do
change(user, locked_at: nil, failed_attempts: 0)
end
def changeset(user, attrs) do
cast(user, attrs, [])
end
def update_changeset(user, attrs, roles) do
user
|> cast(attrs, [:name, :email, :role, :secondary_role, :hide_default_role])
@ -167,17 +276,6 @@ defmodule Philomena.Users.User do
|> unique_constraints()
end
def creation_changeset(user, attrs) do
user
|> pow_changeset(attrs)
|> pow_extension_changeset(attrs)
|> cast(attrs, [:name])
|> validate_required([:name])
|> put_api_key()
|> put_slug()
|> unique_constraints()
end
def filter_changeset(user, filter) do
changeset = change(user)
user = changeset.data
@ -352,23 +450,25 @@ defmodule Philomena.Users.User do
end
def totp_changeset(changeset, params, backup_codes) do
%{"user" => %{"current_password" => password}} = params
changeset = change(changeset, %{})
user = changeset.data
case user.otp_required_for_login do
true ->
cond do
!!user.otp_required_for_login and valid_password?(user, password) ->
# User wants to disable TOTP
changeset
|> pow_password_changeset(params)
|> consume_totp_token_changeset(params)
|> disable_totp_changeset()
_falsy ->
!user.otp_required_for_login and valid_password?(user, password) ->
# User wants to enable TOTP
changeset
|> pow_password_changeset(params)
|> consume_totp_token_changeset(params)
|> enable_totp_changeset(backup_codes)
true ->
add_error(changeset, :current_password, "is invalid")
end
end
@ -409,20 +509,16 @@ defmodule Philomena.Users.User do
end
defp enable_totp_changeset(user, backup_codes) do
hashed_codes =
backup_codes
|> Enum.map(&Password.hash_pwd_salt/1)
hashed_codes = Enum.map(backup_codes, &Password.hash_pwd_salt/1)
user
|> change(%{
change(user, %{
otp_required_for_login: true,
otp_backup_codes: hashed_codes
})
end
defp disable_totp_changeset(user) do
user
|> change(%{
change(user, %{
otp_required_for_login: false,
otp_backup_codes: [],
encrypted_otp_secret: nil,
@ -437,7 +533,6 @@ defmodule Philomena.Users.User do
|> unique_constraint(:slug, name: :index_users_on_slug)
|> unique_constraint(:email, name: :index_users_on_email)
|> unique_constraint(:authentication_token, name: :index_users_on_authentication_token)
|> unique_constraint(:name, name: :temp_unique_index_users_on_name)
end
defp extract_token(%{"user" => %{"twofactor_token" => t}}),
@ -455,8 +550,7 @@ defmodule Philomena.Users.User do
defp put_slug(changeset) do
name = get_field(changeset, :name)
changeset
|> put_change(:slug, Slug.slug(name))
put_change(changeset, :slug, Slug.slug(name))
end
defp totp_valid?(user, token) do

View file

@ -0,0 +1,101 @@
defmodule Philomena.Users.UserNotifier do
alias Bamboo.Email
alias Philomena.Mailer
defp deliver(to, subject, body) do
email =
Email.new_email(
to: to,
from: mailer_address(),
subject: subject,
text_body: body
)
|> Mailer.deliver_later()
{:ok, email}
end
defp mailer_address do
Application.get_env(:philomena, :mailer_address)
end
@doc """
Deliver instructions to confirm account.
"""
def deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions for your account", """
==============================
Hi #{user.name},
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 password for an account.
"""
def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Password reset instructions for your account", """
==============================
Hi #{user.name},
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 an account email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Email update instructions for your account", """
==============================
Hi #{user.name},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to unlock an account.
"""
def deliver_unlock_instructions(user, url) do
deliver(user.email, "Unlock instructions for your account", """
==============================
Hi #{user.name},
Your account has been automatically locked due to too many attempts to sign in.
You can unlock your account by visting the URL below:
#{url}
==============================
""")
end
end

View file

@ -0,0 +1,163 @@
defmodule Philomena.Users.UserToken do
use Ecto.Schema
import Ecto.Query
@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
@unlock_email_validity_in_days 7
@session_validity_in_days 365
schema "user_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Philomena.Users.User
timestamps(inserted_at: :created_at, 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.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %Philomena.Users.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.
"""
def verify_session_token_query(token) do
query =
from token in token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.created_at > ago(@session_validity_in_days, "day"),
select: user
{:ok, query}
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.
"""
def build_totp_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %Philomena.Users.UserToken{token: token, context: "totp", user_id: user.id}}
end
@doc """
Checks if the TOTP token is valid and returns its underlying lookup query.
"""
def verify_totp_token_query(%{id: id}, token) do
query =
from token in token_and_context_query(token, "totp"),
where: token.user_id == ^id,
where: token.created_at > ago(@session_validity_in_days, "day")
{:ok, query}
end
@doc """
Builds a token with a hashed counter part.
The non-hashed token is sent to the user email while the
hashed part is stored in the database, to avoid reconstruction.
The token is valid for a week as long as users don't change
their email.
"""
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),
%Philomena.Users.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.
"""
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 token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.created_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
defp days_for_context("unlock"), do: @unlock_email_validity_in_days
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user token record.
"""
def verify_change_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)
query =
from token in token_and_context_query(hashed_token, context),
where: token.created_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Returns the given token with the given context.
"""
def token_and_context_query(token, context) do
from Philomena.Users.UserToken, where: [token: ^token, context: ^context]
end
@doc """
Gets all tokens for the given user for the given contexts.
"""
def user_and_contexts_query(user, :all) do
from t in Philomena.Users.UserToken, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in Philomena.Users.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View file

@ -111,8 +111,8 @@ defmodule PhilomenaWeb.CommissionController do
user ->
user = Repo.preload(user, :commission)
config = Pow.Plug.fetch_config(conn)
Pow.Plug.assign_current_user(conn, user, config)
assign(conn, :current_user, user)
end
end
end

View file

@ -0,0 +1,45 @@
defmodule PhilomenaWeb.ConfirmationController do
use PhilomenaWeb, :controller
alias Philomena.Users
plug PhilomenaWeb.CaptchaPlug when action in [:create]
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Users.get_user_by_email(email) do
Users.deliver_user_confirmation_instructions(
user,
&Routes.confirmation_url(conn, :show, &1)
)
end
# Regardless of the outcome, show an impartial success/error message.
conn
|> put_flash(
:info,
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
|> redirect(to: "/")
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def show(conn, %{"id" => token}) do
case Users.confirm_user(token) do
{:ok, _} ->
conn
|> put_flash(:info, "Account confirmed successfully.")
|> redirect(to: "/")
:error ->
conn
|> put_flash(:error, "Confirmation link is invalid or it has expired.")
|> redirect(to: "/")
end
end
end

View file

@ -0,0 +1,61 @@
defmodule PhilomenaWeb.PasswordController do
use PhilomenaWeb, :controller
alias Philomena.Users
plug PhilomenaWeb.CaptchaPlug when action in [:create]
plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:update]
plug :get_user_by_reset_password_token when action in [:edit, :update]
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Users.get_user_by_email(email) do
Users.deliver_user_reset_password_instructions(
user,
&Routes.password_url(conn, :edit, &1)
)
end
# Regardless of the outcome, show an impartial success/error message.
conn
|> put_flash(
:info,
"If your email is in our system, you will receive instructions to reset your password shortly."
)
|> redirect(to: "/")
end
def edit(conn, _params) do
render(conn, "edit.html", changeset: Users.change_user_password(conn.assigns.user))
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do
case Users.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} ->
conn
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: Routes.session_path(conn, :new))
{:error, changeset} ->
render(conn, "edit.html", changeset: changeset)
end
end
defp get_user_by_reset_password_token(conn, _opts) do
%{"id" => token} = conn.params
if user = Users.get_user_by_reset_password_token(token) do
conn |> assign(:user, user) |> assign(:token, token)
else
conn
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: "/")
|> halt()
end
end
end

View file

@ -0,0 +1,44 @@
defmodule PhilomenaWeb.Registration.EmailController do
use PhilomenaWeb, :controller
alias Philomena.Users
def create(conn, %{"current_password" => password, "user" => user_params}) do
user = conn.assigns.current_user
case Users.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Users.deliver_update_email_instructions(
applied_user,
user.email,
&Routes.registration_email_url(conn, :show, &1)
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: Routes.registration_path(conn, :edit))
{:error, _changeset} ->
conn
|> put_flash(:error, "Failed to update email.")
|> redirect(to: Routes.registration_path(conn, :edit))
end
end
def show(conn, %{"id" => token}) do
case Users.update_user_email(conn.assigns.current_user, token) do
:ok ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: Routes.registration_path(conn, :edit))
:error ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: Routes.registration_path(conn, :edit))
end
end
end

View file

@ -0,0 +1,25 @@
defmodule PhilomenaWeb.Registration.PasswordController do
use PhilomenaWeb, :controller
alias Philomena.Users
alias PhilomenaWeb.UserAuth
plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:update]
def update(conn, %{"current_password" => password, "user" => user_params}) do
user = conn.assigns.current_user
case Users.update_user_password(user, password, user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, Routes.registration_path(conn, :edit))
|> UserAuth.log_in_user(user)
{:error, _changeset} ->
conn
|> put_flash(:error, "Failed to update password.")
|> redirect(to: Routes.registration_path(conn, :edit))
end
end
end

View file

@ -1,7 +1,9 @@
defmodule PhilomenaWeb.Registration.TotpController do
use PhilomenaWeb, :controller
alias PhilomenaWeb.UserAuth
alias Philomena.Users.User
alias Philomena.Users
alias Philomena.Repo
def edit(conn, _params) do
@ -13,11 +15,11 @@ defmodule PhilomenaWeb.Registration.TotpController do
|> User.create_totp_secret_changeset()
|> Repo.update()
# Redirect to have Pow pick up the changes
# Redirect to have the conn pick up the changes
redirect(conn, to: Routes.registration_totp_path(conn, :edit))
_ ->
changeset = Pow.Plug.change_user(conn)
changeset = Users.change_user(user)
secret = User.totp_secret(user)
qrcode = User.totp_qrcode(user)
@ -32,7 +34,7 @@ defmodule PhilomenaWeb.Registration.TotpController do
def update(conn, params) do
backup_codes = User.random_backup_codes()
user = Pow.Plug.current_user(conn)
user = conn.assigns.current_user
user
|> User.totp_changeset(params, backup_codes)
@ -45,7 +47,7 @@ defmodule PhilomenaWeb.Registration.TotpController do
{:ok, user} ->
conn
|> PhilomenaWeb.TotpPlug.update_valid_totp_at_for_session(user)
|> UserAuth.totp_auth_user(user, %{})
|> put_flash(:totp_backup_codes, backup_codes)
|> redirect(to: Routes.registration_totp_path(conn, :edit))
end

View file

@ -0,0 +1,48 @@
defmodule PhilomenaWeb.RegistrationController do
use PhilomenaWeb, :controller
alias Philomena.Users
alias Philomena.Users.User
plug PhilomenaWeb.CaptchaPlug when action in [:create]
plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:create]
plug :assign_email_and_password_changesets when action in [:edit]
def new(conn, _params) do
changeset = Users.change_user_registration(%User{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Users.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Users.deliver_user_confirmation_instructions(
user,
&Routes.confirmation_url(conn, :show, &1)
)
conn
|> put_flash(
:info,
"Account created successfully. Check your email for confirmation instructions."
)
|> redirect(to: "/")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def edit(conn, _params) do
render(conn, "edit.html")
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_user
conn
|> assign(:email_changeset, Users.change_user_email(user))
|> assign(:password_changeset, Users.change_user_password(user))
end
end

View file

@ -2,30 +2,32 @@ defmodule PhilomenaWeb.Session.TotpController do
use PhilomenaWeb, :controller
alias PhilomenaWeb.LayoutView
alias PhilomenaWeb.UserAuth
alias Philomena.Users.User
alias Philomena.Users
alias Philomena.Repo
def new(conn, _params) do
changeset = Pow.Plug.change_user(conn)
changeset = Users.change_user(conn.assigns.current_user)
render(conn, "new.html", layout: {LayoutView, "two_factor.html"}, changeset: changeset)
end
def create(conn, params) do
conn
|> Pow.Plug.current_user()
%{"user" => user_params} = params
conn.assigns.current_user
|> User.consume_totp_token_changeset(params)
|> Repo.update()
|> case do
{:error, _changeset} ->
conn
|> Pow.Plug.delete()
|> put_flash(:error, "Sorry, invalid TOTP token entered. Please sign in again.")
|> redirect(to: Routes.pow_session_path(conn, :new))
|> put_flash(:error, "Invalid TOTP token entered. Please sign in again.")
|> UserAuth.log_out_user()
{:ok, user} ->
conn
|> PhilomenaWeb.TotpPlug.update_valid_totp_at_for_session(user)
|> UserAuth.totp_auth_user(user, user_params)
|> redirect(to: "/")
end
end

View file

@ -0,0 +1,42 @@
defmodule PhilomenaWeb.SessionController do
use PhilomenaWeb, :controller
alias Philomena.Users
alias PhilomenaWeb.UserAuth
def new(conn, _params) do
render(conn, "new.html", error_message: nil)
end
def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
user =
Users.get_user_by_email_and_password(
email,
password,
&Routes.unlock_url(conn, :show, &1)
)
cond do
not is_nil(user) and is_nil(user.confirmed_at) ->
conn
|> put_flash(:error, "You must confirm your account before logging in.")
|> redirect(to: "/")
not is_nil(user) ->
conn
|> put_flash(:info, "Successfully logged in.")
|> UserAuth.log_in_user(user, user_params)
true ->
render(conn, "new.html", error_message: "Invalid email or password")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -0,0 +1,45 @@
defmodule PhilomenaWeb.UnlockController do
use PhilomenaWeb, :controller
alias Philomena.Users
plug PhilomenaWeb.CaptchaPlug when action in [:create]
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Users.get_user_by_email(email) do
Users.deliver_user_unlock_instructions(
user,
&Routes.unlock_url(conn, :show, &1)
)
end
# Regardless of the outcome, show an impartial success/error message.
conn
|> put_flash(
:info,
"If your email is in our system and your account has been locked, " <>
"you will receive an email with instructions shortly."
)
|> redirect(to: "/")
end
# Do not log in the user after unlocking to avoid a
# leaked token giving the user access to the account.
def show(conn, %{"id" => token}) do
case Users.unlock_user(token) do
{:ok, _} ->
conn
|> put_flash(:info, "Account unlocked successfully. You may now log in.")
|> redirect(to: "/")
:error ->
conn
|> put_flash(:error, "Unlock link is invalid or it has expired.")
|> redirect(to: "/")
end
end
end

View file

@ -45,22 +45,6 @@ defmodule PhilomenaWeb.Endpoint do
signing_salt: "signed cookie",
encryption_salt: "authenticated encrypted cookie"
# This is used to capture tokens being invalidated to store for temporary
# reuse
plug PhilomenaWeb.PowInvalidatedSessionPlug, :pow_session
plug PhilomenaWeb.PowInvalidatedSessionPlug, :pow_persistent_session
plug Pow.Plug.Session, otp_app: :philomena
plug PowPersistentSession.Plug.Cookie,
otp_app: :philomena,
persistent_session_cookie_opts: [extra: "SameSite=Lax"]
# This is used as fallback to load user if the Pow session could not be
# loaded
plug PhilomenaWeb.PowInvalidatedSessionPlug, :load
plug PhilomenaWeb.ReloadUserPlug
plug PhilomenaWeb.RenderTimePlug
plug PhilomenaWeb.ReferrerPlug
plug PhilomenaWeb.Router

View file

@ -1,6 +1,6 @@
defmodule PhilomenaWeb.ApiTokenPlug do
alias Philomena.Users
alias Pow.Plug
alias Plug.Conn
def init([]), do: []
@ -12,15 +12,13 @@ defmodule PhilomenaWeb.ApiTokenPlug do
defp maybe_find_user(conn, nil), do: {conn, nil}
defp maybe_find_user(conn, key) do
user = Users.get_by(authentication_token: key)
defp maybe_find_user(conn, token) do
user = Users.get_user_by_authentication_token(token)
{conn, user}
end
defp assign_user({conn, user}) do
config = Plug.fetch_config(conn)
Plug.assign_current_user(conn, user, config)
Conn.assign(conn, :current_user, user)
end
end

View file

@ -6,10 +6,10 @@ defmodule PhilomenaWeb.CaptchaPlug do
def init([]), do: false
def call(conn, _opts) do
user = conn |> Pow.Plug.current_user()
conn
|> maybe_check_captcha(user)
case captcha_enabled?() do
true -> maybe_check_captcha(conn, conn.assigns.current_user)
false -> conn
end
end
defp maybe_check_captcha(conn, nil) do
@ -46,4 +46,8 @@ defmodule PhilomenaWeb.CaptchaPlug do
_ -> false
end
end
def captcha_enabled? do
Application.get_env(:philomena, :captcha) != false
end
end

View file

@ -5,7 +5,10 @@ defmodule PhilomenaWeb.CompromisedPasswordCheckPlug do
def init(opts), do: opts
def call(conn, _opts) do
error_if_password_compromised(conn, conn.params)
case pwned_passwords_enabled?() do
true -> error_if_password_compromised(conn, conn.params)
false -> conn
end
end
defp error_if_password_compromised(conn, %{"user" => %{"password" => password}}) do
@ -41,4 +44,8 @@ defmodule PhilomenaWeb.CompromisedPasswordCheckPlug do
defp make_api_url(prefix) do
"https://api.pwnedpasswords.com/range/#{prefix}"
end
defp pwned_passwords_enabled? do
Application.get_env(:philomena, :pwned_passwords) != false
end
end

View file

@ -8,7 +8,6 @@ defmodule PhilomenaWeb.CurrentBanPlug do
"""
alias Philomena.Bans
alias Plug.Conn
alias Pow.Plug
@doc false
@spec init(any()) :: any()
@ -17,12 +16,10 @@ defmodule PhilomenaWeb.CurrentBanPlug do
@doc false
@spec call(Conn.t(), any()) :: Conn.t()
def call(conn, _opts) do
conn =
conn
|> Conn.fetch_cookies()
conn = Conn.fetch_cookies(conn)
fingerprint = conn.cookies["_ses"]
user = Plug.current_user(conn)
user = conn.assigns.current_user
ip = conn.remote_ip
ban = Bans.exists_for?(user, ip, fingerprint)

View file

@ -3,14 +3,14 @@ defmodule PhilomenaWeb.CurrentFilterPlug do
alias Philomena.{Filters, Filters.Filter, Users.User}
alias Philomena.Repo
alias Pow.Plug
# No options
def init([]), do: false
# Assign current filter
def call(conn, _opts) do
conn = conn |> fetch_session()
user = conn |> Plug.current_user()
conn = fetch_session(conn)
user = conn.assigns.current_user
{filter, forced_filter} =
if user do

View file

@ -6,10 +6,10 @@ defmodule PhilomenaWeb.EnsureUserEnabledPlug do
plug PhilomenaWeb.EnsureUserEnabledPlug
"""
alias PhilomenaWeb.Router.Helpers, as: Routes
alias Phoenix.Controller
alias Plug.Conn
alias Pow.Plug
alias PhilomenaWeb.UserAuth
@doc false
@spec init(any()) :: any()
@ -18,19 +18,19 @@ defmodule PhilomenaWeb.EnsureUserEnabledPlug do
@doc false
@spec call(Conn.t(), any()) :: Conn.t()
def call(conn, _opts) do
conn
|> Plug.current_user()
|> disabled?()
conn.assigns.current_user
|> disabled_or_unconfirmed?()
|> maybe_halt(conn)
end
defp disabled?(%{deleted_at: deleted_at}) when not is_nil(deleted_at), do: true
defp disabled?(_user), do: false
defp disabled_or_unconfirmed?(%{deleted_at: deleted_at}) when not is_nil(deleted_at), do: true
defp disabled_or_unconfirmed?(%{confirmed_at: nil}), do: true
defp disabled_or_unconfirmed?(_user), do: false
defp maybe_halt(true, conn) do
conn
|> Plug.delete()
|> Controller.redirect(to: Routes.pow_session_path(conn, :new))
|> Controller.put_flash(:error, "Your account is not currently active.")
|> UserAuth.log_out_user()
|> Conn.halt()
end

View file

@ -28,10 +28,7 @@ defmodule PhilomenaWeb.FilterSelectPlug do
@doc false
@spec call(Conn.t(), any()) :: Conn.t()
def call(conn, _opts) do
user = Pow.Plug.current_user(conn)
conn
|> maybe_assign_filters(user)
maybe_assign_filters(conn, conn.assigns.current_user)
end
defp maybe_assign_filters(conn, nil), do: conn

View file

@ -3,14 +3,13 @@ defmodule PhilomenaWeb.ImageFilterPlug do
import Philomena.Search.String
alias Philomena.Images.Query
alias Pow.Plug
# No options
def init([]), do: false
# Assign current filter
def call(conn, _opts) do
user = conn |> Plug.current_user()
user = conn.assigns.current_user
filter = defaults(conn.assigns[:current_filter])
forced = defaults(conn.assigns[:forced_filter])

View file

@ -22,7 +22,7 @@ defmodule PhilomenaWeb.NotificationCountPlug do
@doc false
@spec call(Plug.Conn.t(), any()) :: Plug.Conn.t()
def call(conn, _opts) do
user = Pow.Plug.current_user(conn)
user = conn.assigns.current_user
conn
|> maybe_assign_notifications(user)
@ -34,8 +34,7 @@ defmodule PhilomenaWeb.NotificationCountPlug do
defp maybe_assign_notifications(conn, user) do
notifications = Notifications.count_unread_notifications(user)
conn
|> Conn.assign(:notification_count, notifications)
Conn.assign(conn, :notification_count, notifications)
end
defp maybe_assign_conversations(conn, nil), do: conn
@ -43,7 +42,6 @@ defmodule PhilomenaWeb.NotificationCountPlug do
defp maybe_assign_conversations(conn, user) do
conversations = Conversations.count_unread_conversations(user)
conn
|> Conn.assign(:conversation_count, conversations)
Conn.assign(conn, :conversation_count, conversations)
end
end

View file

@ -1,14 +1,13 @@
defmodule PhilomenaWeb.PaginationPlug do
import Plug.Conn
alias Pow.Plug
# No options
def init([]), do: []
# Assign pagination info
def call(conn, _opts) do
conn = conn |> fetch_query_params()
user = conn |> Plug.current_user()
conn = fetch_query_params(conn)
user = conn.assigns.current_user
params = conn.params
page_size = get_page_size(params)

View file

@ -1,176 +0,0 @@
defmodule PhilomenaWeb.PowInvalidatedSessionPlug do
@moduledoc """
This plug ensures that invalidated sessions can still be used for a short
amount of time.
This MAY introduce a slight timing attack vector, but in practice would be
unlikely as all tokens expires after 60 seconds.
## Example
plug MyAppWeb.PowInvalidatedSessionPlug, :pow_session
plug MyAppWeb.PowInvalidatedSessionPlug, :pow_persistent_session
plug Pow.Plug.Session, otp_app: :my_app
plug PowPersistentSession.Plug.Cookie
plug MyAppWeb.PowInvalidatedSessionPlug, :load
"""
alias Plug.Conn
alias Pow.{Config, Plug, Store.Backend.EtsCache}
@store_ttl :timer.minutes(1)
@otp_app :philomena
@session_key "#{@otp_app}_auth"
@session_signing_salt Atom.to_string(Pow.Plug.Session)
@persistent_cookie_key "#{@otp_app}_persistent_session"
@persistent_cookie_signing_salt Atom.to_string(PowPersistentSession.Plug.Cookie)
def init(:load), do: :load
def init(:pow_session) do
[
fetch_token: &__MODULE__.client_store_fetch_session/1,
namespace: :session
]
end
def init(:pow_persistent_session) do
[
fetch_token: &__MODULE__.client_store_fetch_persistent_cookie/1,
namespace: :persistent_session
]
end
def init({type, opts}) do
type
|> init()
|> Keyword.merge(opts)
end
def call(conn, :load) do
Enum.reduce(conn.private[:invalidated_session_opts], conn, fn opts, conn ->
maybe_load_from_cache(conn, Plug.current_user(conn), opts)
end)
end
def call(conn, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)
token = fetch_fn.(conn)
conn
|> put_opts_in_private(opts)
|> Conn.register_before_send(fn conn ->
maybe_put_cache(conn, Plug.current_user(conn), token, opts)
end)
end
defp maybe_load_from_cache(conn, nil, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)
case fetch_fn.(conn) do
nil -> conn
token -> load_from_cache(conn, token, opts)
end
end
defp maybe_load_from_cache(conn, _any, _opts), do: conn
defp put_opts_in_private(conn, opts) do
plug_opts = (conn.private[:invalidated_session_opts] || []) ++ [opts]
Conn.put_private(conn, :invalidated_session_opts, plug_opts)
end
defp maybe_put_cache(conn, nil, _old_token, _opts), do: conn
defp maybe_put_cache(conn, _user, nil, _opts), do: conn
defp maybe_put_cache(conn, user, old_token, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)
metadata =
conn.private
|> Map.get(:pow_session_metadata, [])
|> Keyword.take([:valid_totp_at])
case fetch_fn.(conn) do
^old_token -> conn
_token -> put_cache(conn, {user, metadata}, old_token, opts)
end
end
defp put_cache(conn, user, token, opts) do
{store, store_config} = invalidated_cache(conn, opts)
store.put(store_config, token, user)
conn
end
defp load_from_cache(conn, token, opts) do
config = Plug.fetch_config(conn)
{store, store_config} = invalidated_cache(conn, opts)
case store.get(store_config, token) do
:not_found ->
conn
{user, metadata} ->
metadata = Keyword.merge(metadata, conn.private[:pow_session_metadata] || [])
conn
|> Conn.put_private(:pow_session_metadata, metadata)
|> Plug.assign_current_user(user, config)
user ->
Plug.assign_current_user(conn, user, config)
end
end
@doc false
def client_store_fetch_session(conn) do
conn =
conn
|> Plug.put_config(otp_app: @otp_app)
|> Conn.fetch_session()
with session_id when is_binary(session_id) <- Conn.get_session(conn, @session_key),
{:ok, session_id} <- Plug.verify_token(conn, @session_signing_salt, session_id) do
session_id
else
_any -> nil
end
end
@doc false
def client_store_fetch_persistent_cookie(conn) do
conn =
conn
|> Plug.put_config(otp_app: @otp_app)
|> Conn.fetch_cookies()
with token when is_binary(token) <- conn.cookies[@persistent_cookie_key],
{:ok, token} <- Plug.verify_token(conn, @persistent_cookie_signing_salt, token) do
token
else
_any -> nil
end
end
defp invalidated_cache(conn, opts) do
store_config = store_config(opts)
config = Plug.fetch_config(conn)
store = Config.get(config, :cache_store_backend, EtsCache)
{store, store_config}
end
defp store_config(opts) do
namespace = Keyword.fetch!(opts, :namespace)
ttl = Keyword.get(opts, :ttl, @store_ttl)
[
ttl: ttl,
namespace: "invalidated_#{namespace}"
]
end
end

View file

@ -1,33 +0,0 @@
defmodule PhilomenaWeb.ReloadUserPlug do
alias Plug.Conn
alias Pow.Plug
alias Philomena.Users
alias PhilomenaWeb.UserIpUpdater
alias PhilomenaWeb.UserFingerprintUpdater
def init(opts), do: opts
def call(conn, _opts) do
config = Plug.fetch_config(conn)
case Plug.current_user(conn, config) do
nil ->
conn
user ->
update_usages(conn, user)
reloaded_user = Users.get_by(id: user.id)
Plug.assign_current_user(conn, reloaded_user, config)
end
end
defp update_usages(conn, user) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
conn = Conn.fetch_cookies(conn)
UserIpUpdater.cast(user.id, conn.remote_ip, now)
UserFingerprintUpdater.cast(user.id, conn.cookies["_ses"], now)
end
end

View file

@ -1,14 +1,13 @@
defmodule PhilomenaWeb.RequireUserPlug do
import Phoenix.Controller
import Plug.Conn
import Pow.Plug
# No options
def init([]), do: false
# Redirect if not logged in
def call(conn, _opts) do
user = conn |> current_user()
user = conn.assigns.current_user
if user do
conn

View file

@ -26,7 +26,7 @@ defmodule PhilomenaWeb.TorPlug do
def maybe_redirect(conn, nil, {127, 0, 0, 1}, true) do
conn
|> Controller.redirect(to: Routes.pow_session_path(conn, :new))
|> Controller.redirect(to: Routes.session_path(conn, :new))
|> Conn.halt()
end

View file

@ -16,9 +16,7 @@ defmodule PhilomenaWeb.TotpPlug do
@doc false
@spec call(Plug.Conn.t(), any()) :: Plug.Conn.t()
def call(conn, _opts) do
conn
|> Pow.Plug.current_user()
|> case do
case conn.assigns.current_user do
nil -> conn
user -> maybe_require_totp_phase(user, conn)
end
@ -28,39 +26,14 @@ defmodule PhilomenaWeb.TotpPlug do
defp maybe_require_totp_phase(%{otp_required_for_login: false}, conn), do: conn
defp maybe_require_totp_phase(_user, conn) do
conn.private
|> Map.get(:pow_session_metadata, [])
|> Keyword.get(:valid_totp_at)
|> case do
nil ->
case conn.assigns.totp_valid? do
true ->
conn
_falsy ->
conn
|> Phoenix.Controller.redirect(to: Routes.session_totp_path(conn, :new))
|> Plug.Conn.halt()
_valid_at ->
conn
end
end
@doc false
@spec update_valid_totp_at_for_session(Plug.Conn.t(), map()) :: Plug.Conn.t()
def update_valid_totp_at_for_session(conn, user) do
metadata =
conn.private
|> Map.get(:pow_session_metadata, [])
|> Keyword.put(:valid_totp_at, DateTime.utc_now())
config = Pow.Plug.fetch_config(conn)
plug = Pow.Plug.get_plug(config)
conn = Plug.Conn.put_private(conn, :pow_session_metadata, metadata)
conn =
conn
|> Plug.Conn.put_private(:pow_persistent_session_metadata,
session_metadata: Keyword.take(metadata, [:valid_totp_at])
)
|> PowPersistentSession.Plug.Cookie.create(user, config)
plug.do_create(conn, user, config)
end
end

View file

@ -19,7 +19,7 @@ defmodule PhilomenaWeb.UserAttributionPlug do
def call(conn, _opts) do
{:ok, remote_ip} = EctoNetwork.INET.cast(conn.remote_ip)
conn = Conn.fetch_cookies(conn)
user = Pow.Plug.current_user(conn)
user = conn.assigns.current_user
attributes = [
ip: remote_ip,

View file

@ -1,28 +0,0 @@
defmodule PhilomenaWeb.PowMailer do
use Pow.Phoenix.Mailer
alias PhilomenaWeb.Mailer
alias Philomena.Users.User
import Bamboo.Email
def cast(%{
user: %User{email: email},
subject: subject,
text: text,
html: html,
assigns: _assigns
}) do
# Build email struct to be used in `process/1`
new_email(
to: email,
from: Application.get_env(:philomena, :mailer_address),
subject: subject,
text_body: text,
html_body: html
)
end
def process(email) do
email
|> Mailer.deliver_later()
end
end

View file

@ -1,7 +1,7 @@
defmodule PhilomenaWeb.Router do
use PhilomenaWeb, :router
use Pow.Phoenix.Router
use Pow.Extension.Phoenix.Router, otp_app: :philomena
import PhilomenaWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
@ -9,6 +9,7 @@ defmodule PhilomenaWeb.Router do
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
plug PhilomenaWeb.ContentSecurityPolicyPlug
plug PhilomenaWeb.CurrentFilterPlug
plug PhilomenaWeb.ImageFilterPlug
@ -52,55 +53,56 @@ defmodule PhilomenaWeb.Router do
plug PhilomenaWeb.FilterBannedUsersPlug
end
pipeline :ensure_password_not_compromised do
plug PhilomenaWeb.CompromisedPasswordCheckPlug
end
scope "/", PhilomenaWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
pipeline :protected do
plug Pow.Plug.RequireAuthenticated,
error_handler: Pow.Phoenix.PlugErrorHandler
end
scope "/" do
pipe_through [
:browser,
:ensure_totp,
:ensure_not_banned,
:ensure_tor_authorized,
:ensure_password_not_compromised
]
pow_registration_routes()
end
scope "/" do
pipe_through [:browser, :ensure_totp]
pow_session_routes()
end
scope "/" do
pipe_through [:browser, :ensure_totp, :ensure_tor_authorized]
pow_extension_routes()
resources "/sessions", SessionController, only: [:new, :create], singleton: true
end
scope "/", PhilomenaWeb do
pipe_through [:browser, :protected]
pipe_through [:browser, :require_authenticated_user]
# Additional routes for TOTP
scope "/registrations", Registration, as: :registration do
resources "/totp", TotpController, only: [:edit, :update], singleton: true
resources "/name", NameController, only: [:edit, :update], singleton: true
end
scope "/sessions", Session, as: :session do
resources "/totp", TotpController, only: [:new, :create], singleton: true
end
end
scope "/", PhilomenaWeb do
pipe_through [
:browser,
:ensure_not_banned,
:ensure_tor_authorized,
:redirect_if_user_is_authenticated
]
resources "/registrations", RegistrationController, only: [:new, :create], singleton: true
resources "/passwords", PasswordController, only: [:new, :create, :edit, :update]
resources "/confirmations", ConfirmationController, only: [:new, :create, :show]
resources "/unlocks", UnlockController, only: [:new, :create, :show]
end
scope "/", PhilomenaWeb do
pipe_through [
:browser,
:ensure_totp,
:ensure_tor_authorized,
:require_authenticated_user
]
resources "/registrations", RegistrationController, only: [:edit, :update], singleton: true
resources "/sessions", SessionController, only: [:delete], singleton: true
scope "/registrations", Registration, as: :registration do
resources "/totp", TotpController, only: [:edit, :update], singleton: true
resources "/name", NameController, only: [:edit, :update], singleton: true
resources "/password", PasswordController, only: [:update], singleton: true
resources "/email", EmailController, only: [:create, :show]
end
end
scope "/api/v1/rss", PhilomenaWeb.Api.Rss, as: :api_rss do
pipe_through [:accepts_rss, :api, :protected]
pipe_through [:accepts_rss, :api, :require_authenticated_user]
resources "/watched", WatchedController, only: [:index]
end
@ -155,7 +157,7 @@ defmodule PhilomenaWeb.Router do
end
scope "/", PhilomenaWeb do
pipe_through [:browser, :ensure_totp, :protected]
pipe_through [:browser, :ensure_totp, :require_authenticated_user]
scope "/notifications", Notification, as: :notification do
resources "/unread", UnreadController, only: [:index]

View file

@ -1,5 +1,3 @@
.profile-top
.profile-top__avatar
= render PhilomenaWeb.UserAttributionView, "_user_avatar.html", object: %{user: @current_user}, conn: @conn
@ -43,7 +41,7 @@
br
- return_to = if blank?(@conn.params["profile"]) do
= Routes.pow_registration_path(@conn, :edit)
= Routes.registration_path(@conn, :edit)
- else
= Routes.profile_path(@conn, :show, @current_user)
= link "Back", to: return_to

View file

@ -17,7 +17,7 @@ elixir:
span.hide-mobile
' Unsubscribe
- else
a.media-box__header.media-box__header--channel.media-box__header--link href=Routes.pow_session_path(@conn, :new)
a.media-box__header.media-box__header--channel.media-box__header--link href=Routes.session_path(@conn, :new)
i.fa.fa-bell>
span.hide-mobile
' Subscribe

View file

@ -0,0 +1,12 @@
h1 Resend confirmation instructions
= form_for :user, Routes.confirmation_path(@conn, :create), fn f ->
.field
= email_input f, :email, placeholder: "Email", class: "input", required: true
.field
= checkbox f, :captcha, class: "js-captcha", value: 0
= label f, :captcha, "I am not a robot!"
div
= submit "Resend confirmation instructions", class: "button"

View file

@ -17,7 +17,7 @@ elixir:
span.hide-mobile
' Unsubscribe
- else
a href=Routes.pow_session_path(@conn, :new)
a href=Routes.session_path(@conn, :new)
i.fa.fa-bell>
span.hide-mobile
' Subscribe

View file

@ -17,7 +17,7 @@ elixir:
span.hide-mobile
' Unsubscribe
- else
a href=Routes.pow_session_path(@conn, :new)
a href=Routes.session_path(@conn, :new)
i.fa.fa-bell>
span.hide-mobile
' Subscribe

View file

@ -36,6 +36,6 @@
i.fa.fa-plus>
span.hide-limited-desktop.hide-mobile Create a gallery
- else
a.block__list__link.primary href=Routes.pow_registration_path(@conn, :new)
a.block__list__link.primary href=Routes.session_path(@conn, :new)
i.fa.fa-user-plus>
span.hide-limited-desktop.hide-mobile Register to create a gallery
span.hide-limited-desktop.hide-mobile Sign in to create a gallery

View file

@ -15,7 +15,7 @@ a href=Routes.image_report_path(@conn, :new, @image)
- else
p
' You must
a> href=Routes.pow_session_path(@conn, :new) log in
a> href=Routes.session_path(@conn, :new) log in
' to report duplicate images.
- target_reports = Enum.filter(@dupe_reports, & &1.duplicate_of_image_id == @image.id)

View file

@ -15,6 +15,6 @@ elixir:
i.fa.fa-bell-slash>
' Unsubscribe
- else
a href=Routes.pow_session_path(@conn, :new)
a href=Routes.session_path(@conn, :new)
i.fa.fa-bell>
' Subscribe

View file

@ -92,10 +92,10 @@ header.header
a.header__link href="/conversations"
i.fa.fa-fw.fa-envelope>
| Messages
a.header__link href=Routes.pow_registration_path(@conn, :edit)
a.header__link href=Routes.registration_path(@conn, :edit)
i.fa.fa-fw.fa-user>
| Account
a.header__link href="/session" data-method="delete"
a.header__link href=Routes.session_path(@conn, :delete) data-method="delete"
i.fa.fa-fw.fa-sign-out-alt>
| Logout
- else
@ -107,9 +107,9 @@ header.header
a.header__link href="/settings/edit"
i.fa.fa-fw.fa-cogs.hide-desktop>
| Settings
a.header__link href="/registration/new"
a.header__link href=Routes.registration_path(@conn, :new)
| Register
a.header__link href="/session/new"
a.header__link href=Routes.session_path(@conn, :new)
| Login
nav.header.header--secondary

View file

@ -1,6 +1,6 @@
h1 Reset password
= form_for @changeset, @action, [as: :user], fn f ->
= form_for @changeset, Routes.password_path(@conn, :update, @token), fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
@ -10,7 +10,8 @@ h1 Reset password
= error_tag f, :password
.field
= password_input f, :password_confirmation, class: "input", placeholder: "Confirm password"
= password_input f, :password_confirmation, class: "input", placeholder: "Confirm new password"
= error_tag f, :password_confirmation
div
= submit "Submit", class: "button"

View file

@ -0,0 +1,14 @@
h1 Forgot your password?
p
' Provide the email address you signed up with and we will email you
' password reset instructions.
= form_for :user, Routes.password_path(@conn, :create), fn f ->
.field
= email_input f, :email, class: "input", placeholder: "Email", required: true
.field
= checkbox f, :captcha, class: "js-captcha", value: 0
= label f, :captcha, "I am not a robot!"
= submit "Send instructions to reset password", class: "button"

View file

@ -1,28 +0,0 @@
h1 Sign in
= form_for @changeset, @action, [as: :user], fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
p = link "Forgot your password?", to: Routes.pow_reset_password_reset_password_path(@conn, :new)
.field
= text_input f, :email, class: "input", required: true, placeholder: "Email", autofocus: true, pattern: ".*@.*"
= error_tag f, :email
.field
= password_input f, :password, class: "input", required: true, placeholder: "Password"
= error_tag f, :password
.field
= checkbox f, :persistent_session
= label f, :persistent_session, "Remember me"
= submit "Sign in", class: "button"
p
strong
' Haven't read the
a<> href="/pages/rules" site rules
' lately? Make sure you read them before posting or editing metadata!

View file

@ -1,18 +0,0 @@
h1 Forgot your password?
p
' Provide the email address you signed up with and we will email you
' password reset instructions.
= form_for @changeset, @action, [as: :user], fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
.field
= text_input f, :email, class: "input", placeholder: "Email"
.field
= checkbox f, :captcha, class: "js-captcha", value: 0
= label f, :captcha, "I am not a robot!"
= submit "Submit", class: "button"

View file

@ -27,23 +27,31 @@ p
' Avoid sharing this key with others, as it could be used to compromise
' your account.
h3 Update Settings
p
strong
' Don't forget to confirm your changes by entering your current password
' at the bottom of the page!
h3 Change email
= form_for @changeset, @action, [as: :user], fn f ->
= if @changeset.action do
= form_for @email_changeset, Routes.registration_email_path(@conn, :create), [method: :post], fn f ->
= if @email_changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
h3 Email address
.field
= text_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ".*@.*"
= email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ".*@.*"
= error_tag f, :email
h3 Change Password
.field
= password_input f, :current_password, class: "input", required: true, name: "current_password", placeholder: "Current password"
= error_tag f, :current_password
div
= submit "Change email", class: "button"
h3 Change password
= form_for @password_changeset, Routes.registration_password_path(@conn, :update), fn f ->
= if @password_changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
.field
= password_input f, :password, class: "input", placeholder: "New password"
= error_tag f, :password
@ -52,17 +60,8 @@ p
= password_input f, :password_confirmation, class: "input", placeholder: "Confirm new password"
= error_tag f, :password_confirmation
.fieldlabel
' Leave these blank if you don't want to change your password.
br
.block.block--fixed.block--warning
h3 Confirm
.field
= password_input f, :current_password, class: "input", placeholder: "Current password"
= password_input f, :current_password, name: "current_password", class: "input", placeholder: "Current password"
= error_tag f, :current_password
.fieldlabel
' We need your current password to confirm all of these changes
= submit "Save Account", class: "button"
= submit "Change password", class: "button"

View file

@ -14,4 +14,4 @@ h1 Editing Name
.action
= submit "Save", class: "button"
p = link "Back", to: Routes.pow_registration_path(@conn, :edit)
p = link "Back", to: Routes.registration_path(@conn, :edit)

View file

@ -1,6 +1,6 @@
h1 Register
= form_for @changeset, @action, [as: :user], fn f ->
= form_for @changeset, Routes.registration_path(@conn, :create), fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
@ -16,11 +16,11 @@ h1 Register
' You'll use your email address to log in, and we'll use this to get in
' touch if we need to. Don't worry, we won't share this or spam you.
.field
= text_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ".*@.*"
= email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ".*@.*"
= error_tag f, :email
.fieldlabel
' Pick a good strong password - longer is better! Minimum of 6 characters.
' Pick a good strong password - longer is better! Minimum of 12 characters.
.field
= password_input f, :password, class: "input", placeholder: "Password", required: true
= error_tag f, :password

View file

@ -135,4 +135,4 @@ h1 Two Factor Authentication
= submit "Save Account", class: "button"
p = link "Back", to: Routes.pow_registration_path(@conn, :edit)
p = link "Back", to: Routes.registration_path(@conn, :edit)

View file

@ -0,0 +1,28 @@
h1 Sign in
= form_for @conn, Routes.session_path(@conn, :create), [as: :user], fn f ->
= if @error_message do
.alert.alert-danger
p = @error_message
p = link "Forgot your password?", to: Routes.password_path(@conn, :new)
.field
= email_input f, :email, class: "input", required: true, placeholder: "Email", autofocus: true, pattern: ".*@.*"
= error_tag f, :email
.field
= password_input f, :password, class: "input", required: true, placeholder: "Password"
= error_tag f, :password
.field
= checkbox f, :remember_me
= label f, :remember_me, "Remember me"
= submit "Sign in", class: "button"
p
strong
' Haven't read the
a<> href="/pages/rules" site rules
' lately? Make sure you read them before posting or editing metadata!

View file

@ -5,4 +5,8 @@ h1 Two Factor Authentication
h4 Please enter your 2FA code
= text_input f, :twofactor_token, class: "input", placeholder: "6-digit code", required: true, autofocus: true, autocomplete: "off"
.field
=> checkbox f, :remember_me, class: "input"
= label f, :remember_me, "Remember this device"
= submit "Sign in", class: "button"

View file

@ -132,7 +132,7 @@ h1 Content Settings
.block__tab.hidden data-tab="join-the-herd"
p
' Consider
=> link "creating an account!", to: Routes.pow_registration_path(@conn, :new)
=> link "creating an account!", to: Routes.registration_path(@conn, :new)
br
' You will be able to customize the number of images and comments you get on a single page, as well as change the appearance of the site with custom themes.

View file

@ -17,7 +17,7 @@ elixir:
span.hide-mobile
' Unsubscribe
- else
a href=Routes.pow_session_path(@conn, :new)
a href=Routes.session_path(@conn, :new)
i.fa.fa-bell>
span.hide-mobile
' Subscribe

View file

@ -0,0 +1,12 @@
h1 Resend unlock instructions
= form_for :user, Routes.unlock_path(@conn, :create), fn f ->
.field
= email_input f, :email, placeholder: "Email", class: "input", required: true
.field
= checkbox f, :captcha, class: "js-captcha", value: 0
= label f, :captcha, "I am not a robot!"
div
= submit "Resend unlock instructions", class: "button"

View file

@ -0,0 +1,196 @@
defmodule PhilomenaWeb.UserAuth do
import Plug.Conn
import Phoenix.Controller
alias Philomena.Users
alias PhilomenaWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 365 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
@max_age 60 * 60 * 24 * 365
@remember_me_cookie "user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
@totp_auth_cookie "user_totp_auth"
@totp_auth_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 = Users.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(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
@doc """
Writes TOTP session metadata for an authenticated user.
"""
def totp_auth_user(conn, user, params \\ %{}) do
token = Users.generate_user_totp_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> put_session(:totp_token, token)
|> maybe_write_totp_auth_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
defp maybe_write_totp_auth_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @totp_auth_cookie, token, @totp_auth_options)
end
defp maybe_write_totp_auth_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
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 && Users.delete_session_token(user_token)
totp_token = get_session(conn, :totp_token)
totp_token && Users.delete_totp_token(totp_token)
if live_socket_id = get_session(conn, :live_socket_id) do
PhilomenaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> delete_resp_cookie(@totp_auth_cookie)
|> redirect(to: "/")
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)
{totp_token, conn} = ensure_totp_token(conn)
user = user_token && Users.get_user_by_session_token(user_token)
totp = totp_token && Users.user_totp_token_valid?(user, totp_token)
conn
|> assign(:current_user, user)
|> assign(:totp_valid?, totp)
end
defp ensure_user_token(conn) do
if user_token = get_session(conn, :user_token) do
{user_token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)}
else
{nil, conn}
end
end
end
defp ensure_totp_token(conn) do
if totp_token = get_session(conn, :totp_token) do
{totp_token, conn}
else
conn = fetch_cookies(conn, signed: [@totp_auth_cookie])
if totp_token = conn.cookies[@totp_auth_cookie] do
{totp_token, put_session(conn, :totp_token, totp_token)}
else
{nil, conn}
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: Routes.session_path(conn, :new))
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET", request_path: request_path} = conn) do
put_session(conn, :user_return_to, request_path)
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/"
end

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.ConfirmationView do
use PhilomenaWeb, :view
end

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.PasswordView do
use PhilomenaWeb, :view
end

View file

@ -1,3 +0,0 @@
defmodule PhilomenaWeb.Pow.RegistrationView do
use PhilomenaWeb, :view
end

View file

@ -1,3 +0,0 @@
defmodule PhilomenaWeb.Pow.SessionView do
use PhilomenaWeb, :view
end

View file

@ -1,3 +0,0 @@
defmodule PhilomenaWeb.PowResetPassword.ResetPasswordView do
use PhilomenaWeb, :view
end

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.RegistrationView do
use PhilomenaWeb, :view
end

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.SessionView do
use PhilomenaWeb, :view
end

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.UnlockView do
use PhilomenaWeb, :view
end

View file

@ -1,7 +0,0 @@
defmodule PowCaptcha do
@moduledoc false
use Pow.Extension.Base
@impl true
def phoenix_controller_callbacks?(), do: true
end

View file

@ -1,45 +0,0 @@
defmodule PowCaptcha.Phoenix.ControllerCallbacks do
@moduledoc """
Controller callback logic for captcha verification.
"""
use Pow.Extension.Phoenix.ControllerCallbacks.Base
alias Pow.Config
alias Plug.Conn
alias Phoenix.Controller
alias Pow.Phoenix.RegistrationController
alias PowResetPassword.Phoenix.ResetPasswordController
@doc false
@impl true
def before_process(RegistrationController, :create, conn, config) do
verifier = Config.get(config, :captcha_verifier)
return_path = routes(conn).registration_path(conn, :new)
verifier.valid_solution?(conn.params)
|> maybe_halt(conn, return_path)
end
def before_process(ResetPasswordController, :create, conn, config) do
verifier = Config.get(config, :captcha_verifier)
return_path = routes(conn).path_for(conn, ResetPasswordController, :new)
verifier.valid_solution?(conn.params)
|> maybe_halt(conn, return_path)
end
defp maybe_halt(false, conn, return_path) do
conn
|> Controller.put_flash(
:error,
"There was an error verifying you're not a robot. Please try again."
)
|> Controller.redirect(to: return_path)
|> Conn.halt()
end
defp maybe_halt(true, conn, _return_path) do
conn
end
end

View file

@ -1,63 +0,0 @@
defmodule PowLockout.Ecto.Context do
@moduledoc """
Handles lockout context for user.
"""
alias Pow.{Config, Ecto.Context}
alias PowLockout.Ecto.Schema
@doc """
Finds a user by the `:unlock_token` column.
"""
@spec get_by_unlock_token(binary(), Config.t()) :: Context.user() | nil
def get_by_unlock_token(token, config),
do: Context.get_by([unlock_token: token], config)
@doc """
Checks if the user is current locked out.
"""
@spec locked_out?(Context.user(), Config.t()) :: boolean()
def locked_out?(%{locked_at: time}, _config) when not is_nil(time),
do: true
def locked_out?(_user, _config),
do: false
@doc """
Unlocks the account.
See `PowLockout.Ecto.Schema.unlock_changeset/1`.
"""
@spec unlock_account(Context.user(), Config.t()) ::
{:ok, Context.user()} | {:error, Context.changeset()}
def unlock_account(user, config) do
user
|> Schema.unlock_changeset()
|> Context.do_update(config)
end
@doc """
Increases the attempts counter and possibly locks the account.
See `PowLockout.Ecto.Schema.attempt_changeset/1`.
"""
@spec fail_attempt(Context.user(), Config.t()) ::
{:ok, Context.user()} | {:error, Context.changeset()}
def fail_attempt(user, config) do
user
|> Schema.attempt_changeset()
|> Context.do_update(config)
end
@doc """
Sets the attempts counter to zero.
See `PowLockout.Ecto.Schema.attempt_reset_changeset/1`.
"""
@spec succeed_attempt(Context.user(), Config.t()) ::
{:ok, Context.user()} | {:error, Context.changeset()}
def succeed_attempt(user, config) do
user
|> Schema.attempt_reset_changeset()
|> Context.do_update(config)
end
end

View file

@ -1,132 +0,0 @@
defmodule PowLockout.Ecto.Schema do
@moduledoc """
Handles the lockout schema for user.
## Customize PowLockout fields
If you need to modify any of the fields that `PowLockout` adds to
the user schema, you can override them by defining them before
`pow_user_fields/0`:
defmodule MyApp.Users.User do
use Ecto.Schema
use Pow.Ecto.Schema
use Pow.Extension.Ecto.Schema,
extensions: [PowLockout]
schema "users" do
field :unlock_token, :string
field :locked_at, :utc_datetime
field :failed_attempts, :integer
pow_user_fields()
timestamps()
end
end
"""
use Pow.Extension.Ecto.Schema.Base
alias Ecto.Changeset
alias Pow.UUID
@doc false
@impl true
def attrs(_config) do
[
{:unlock_token, :string},
{:locked_at, :utc_datetime},
{:failed_attempts, :integer}
]
end
@doc false
@impl true
def indexes(_config) do
[{:unlock_token, true}]
end
@doc """
Sets the account as unlocked.
This sets `:locked_at` and `:unlock_token` to nil, and sets
`failed_attempts` to 0.
"""
@spec unlock_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t()
def unlock_changeset(user_or_changeset) do
changes = [
locked_at: nil,
unlock_token: nil,
failed_attempts: 0
]
user_or_changeset
|> Changeset.change(changes)
end
@doc """
Sets the account as locked.
This sets `:locked_at` to now and sets `:unlock_token` to a random UUID.
"""
@spec lock_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t()
def lock_changeset(user_or_changeset) do
changeset = Changeset.change(user_or_changeset)
locked_at = Pow.Ecto.Schema.__timestamp_for__(changeset.data.__struct__, :locked_at)
changes = [
locked_at: locked_at,
unlock_token: UUID.generate()
]
changeset
|> Changeset.change(changes)
end
@doc """
Updates the failed attempt count.
This increments `:failed_attempts` by 1, or sets it to 1 if it is nil.
The first time it becomes greater than 10, it also locks the user.
"""
@spec attempt_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t()
def attempt_changeset(%Changeset{data: %{failed_attempts: attempts}} = changeset)
when is_integer(attempts) and attempts < 10 do
Changeset.change(changeset, failed_attempts: attempts + 1)
end
def attempt_changeset(
%Changeset{data: %{failed_attempts: attempts, locked_at: nil}} = changeset
)
when is_integer(attempts) do
lock_changeset(changeset)
end
def attempt_changeset(
%Changeset{data: %{failed_attempts: attempts, locked_at: _locked_at}} = changeset
)
when is_integer(attempts) do
changeset
end
def attempt_changeset(%Changeset{} = changeset) do
Changeset.change(changeset, failed_attempts: 1)
end
def attempt_changeset(user) do
user
|> Changeset.change()
|> attempt_changeset()
end
@doc """
Resets the failed attempt count.
This sets `:failed_attempts` to 0.
"""
@spec attempt_reset_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t()
def attempt_reset_changeset(user_or_changeset) do
user_or_changeset
|> Changeset.change(failed_attempts: 0)
end
end

View file

@ -1,16 +0,0 @@
defmodule PowLockout do
@moduledoc false
use Pow.Extension.Base
@impl true
def ecto_schema?(), do: true
@impl true
def phoenix_controller_callbacks?(), do: true
@impl true
def phoenix_router?(), do: true
@impl true
def phoenix_messages?(), do: true
end

View file

@ -1,93 +0,0 @@
defmodule PowLockout.Phoenix.ControllerCallbacks do
@moduledoc """
Controller callback logic for account lockout.
### User is locked out
Triggers on `Pow.Phoenix.SessionController.create/2`.
When a user is locked out, the credentials will be treated as if they were
invalid and the user will be redirected back to `Pow.Phoenix.Routes.`.
### User successfully authenticates
Triggers on `Pow.Phoenix.SessionController.create/2`.
When a user successfully signs in, the failed attempts counter will be
reset to zero.
### Users unsuccessfully authenticates
Triggers on `Pow.Phoenix.SessionController.create/2`.
When a user unsuccessfully signs in, the failed attempts counter will be
incremented, and the user may be locked out.
See `PowLockout.Ecto.Schema` for more.
"""
use Pow.Extension.Phoenix.ControllerCallbacks.Base
alias Plug.Conn
alias Pow.Plug
alias PowLockout.Phoenix.{UnlockController, Mailer}
alias PowLockout.Plug, as: PowLockoutPlug
@doc false
@impl true
def before_respond(Pow.Phoenix.SessionController, :create, {result, conn}, _config) do
PowLockoutPlug.user_for_attempts(conn)
|> maybe_fail_attempt(conn, result)
end
defp maybe_fail_attempt(nil, conn, result),
do: {result, conn}
defp maybe_fail_attempt(%{locked_at: nil} = user, conn, :ok) do
case PowLockoutPlug.succeed_attempt(conn, user) do
{:error, _changeset, conn} ->
{:halt, conn}
{:ok, _user, conn} ->
{:ok, conn}
end
end
defp maybe_fail_attempt(_locked_user, conn, :ok) do
{:error, invalid_credentials(conn)}
end
defp maybe_fail_attempt(user, conn, _error) do
PowLockoutPlug.fail_attempt(conn, user)
|> case do
{:error, _changeset, conn} ->
{:halt, conn}
{:ok, %{locked_at: nil}, conn} ->
{:error, invalid_credentials(conn)}
{:ok, user, conn} ->
send_unlock_email(user, conn)
{:error, invalid_credentials(conn)}
end
end
defp invalid_credentials(conn) do
Plug.delete(conn)
end
@doc """
Sends an unlock e-mail to the user.
"""
@spec send_unlock_email(map(), Conn.t()) :: any()
def send_unlock_email(user, conn) do
url = unlock_url(conn, user.unlock_token)
email = Mailer.email_unlock(conn, user, url)
Pow.Phoenix.Mailer.deliver(conn, email)
end
defp unlock_url(conn, token) do
routes(conn).url_for(conn, UnlockController, :show, [token])
end
end

View file

@ -1,23 +0,0 @@
defmodule PowLockout.Phoenix.UnlockController do
@moduledoc false
use Pow.Extension.Phoenix.Controller.Base
alias Plug.Conn
alias PowLockout.Plug
@spec process_show(Conn.t(), map()) :: {:ok | :error, map(), Conn.t()}
def process_show(conn, %{"id" => token}), do: Plug.unlock_account(conn, token)
@spec respond_show({:ok | :error, map(), Conn.t()}) :: Conn.t()
def respond_show({:ok, _user, conn}) do
conn
|> put_flash(:info, extension_messages(conn).account_has_been_unlocked(conn))
|> redirect(to: routes(conn).session_path(conn, :new))
end
def respond_show({:error, _changeset, conn}) do
conn
|> put_flash(:error, extension_messages(conn).account_unlock_failed(conn))
|> redirect(to: routes(conn).session_path(conn, :new))
end
end

View file

@ -1,11 +0,0 @@
defmodule PowLockout.Phoenix.Mailer do
@moduledoc false
alias Plug.Conn
alias Pow.Phoenix.Mailer.Mail
alias PowLockout.Phoenix.MailerView
@spec email_unlock(Conn.t(), map(), binary()) :: Mail.t()
def email_unlock(conn, user, url) do
Mail.new(conn, user, {MailerView, :email_unlock}, url: url)
end
end

View file

@ -1,25 +0,0 @@
defmodule PowLockout.Phoenix.MailerTemplate do
@moduledoc false
use Pow.Phoenix.Mailer.Template
template(
:email_unlock,
"Unlock your account",
"""
Hi,
Your account has been automatically disabled due to too many unsuccessful
attempts to sign in.
Please use the following link to unlock your account:
<%= @url %>
""",
"""
<%= content_tag(:h3, "Hi,") %>
<%= content_tag(:p, "Your account has been automatically disabled due to too many unsuccessful attempts to sign in.") %>
<%= content_tag(:p, "Please use the following link to unlock your account:") %>
<%= content_tag(:p, link(@url, to: @url)) %>
"""
)
end

View file

@ -1,4 +0,0 @@
defmodule PowLockout.Phoenix.MailerView do
@moduledoc false
use Pow.Phoenix.Mailer.View
end

View file

@ -1,13 +0,0 @@
defmodule PowLockout.Phoenix.Messages do
@moduledoc false
@doc """
Flash message to show when account has been unlocked.
"""
def account_has_been_unlocked(_conn), do: "Account successfully unlocked. You may now log in."
@doc """
Flash message to show when account couldn't be unlocked.
"""
def account_unlock_failed(_conn), do: "Account unlock failed."
end

View file

@ -1,12 +0,0 @@
defmodule PowLockout.Phoenix.Router do
@moduledoc false
use Pow.Extension.Phoenix.Router.Base
alias Pow.Phoenix.Router
defmacro routes(_config) do
quote location: :keep do
Router.pow_resources("/unlock", UnlockController, only: [:show])
end
end
end

View file

@ -1,86 +0,0 @@
defmodule PowLockout.Plug do
@moduledoc """
Plug helper methods.
"""
alias Plug.Conn
alias Pow.Plug
alias PowLockout.Ecto.Context
require IEx
@doc """
Check if the current user is locked out.
"""
@spec locked_out?(Conn.t()) :: boolean()
def locked_out?(conn) do
config = Plug.fetch_config(conn)
conn
|> Plug.current_user()
|> Context.locked_out?(config)
end
@doc """
Get the user belonging to the id field.
"""
@spec user_for_attempts(Conn.t()) :: map() | nil
def user_for_attempts(conn) do
config = Plug.fetch_config(conn)
id_field = Pow.Ecto.Schema.user_id_field(config)
id_value = to_string(conn.params["user"][to_string(id_field)])
Pow.Ecto.Context.get_by([{id_field, id_value}], config)
end
@doc """
Unlocks the user found by the provided unlock token.
"""
@spec unlock_account(Conn.t(), binary()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()}
def unlock_account(conn, token) do
config = Plug.fetch_config(conn)
token
|> Context.get_by_unlock_token(config)
|> maybe_unlock_account(conn, config)
end
defp maybe_unlock_account(nil, conn, _config) do
{:error, nil, conn}
end
defp maybe_unlock_account(user, conn, config) do
user
|> Context.unlock_account(config)
|> case do
{:error, changeset} -> {:error, changeset, conn}
{:ok, user} -> {:ok, user, conn}
end
end
@doc """
Increments the failed attempts counter and possibly locks the user out.
"""
@spec fail_attempt(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()}
def fail_attempt(conn, user) do
config = Plug.fetch_config(conn)
Context.fail_attempt(user, config)
|> case do
{:error, changeset} -> {:error, changeset, conn}
{:ok, user} -> {:ok, user, conn}
end
end
@doc """
Resets the failed attempts counter to 0.
"""
@spec succeed_attempt(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()}
def succeed_attempt(conn, user) do
config = Plug.fetch_config(conn)
Context.succeed_attempt(user, config)
|> case do
{:error, changeset} -> {:error, changeset, conn}
{:ok, user} -> {:ok, user, conn}
end
end
end

View file

@ -45,8 +45,7 @@ defmodule Philomena.MixProject do
{:plug_cowboy, "~> 2.3"},
{:phoenix_slime, "~> 0.13"},
{:ecto_network, "~> 1.3"},
{:pow, "~> 1.0"},
{:bcrypt_elixir, "~> 2.2"},
{:bcrypt_elixir, "~> 2.0"},
{:pot, "~> 0.11"},
{:secure_compare, "~> 0.1.0"},
{:elastix, "~> 0.8.0"},
@ -67,7 +66,6 @@ defmodule Philomena.MixProject do
{:tesla, "~> 1.3"},
{:castore, "~> 0.1"},
{:mint, "~> 1.1"},
{:libcluster, "~> 3.2"},
{:exq, "~> 0.13"},
{:dialyxir, "~> 1.0", only: :dev, runtime: false}
]
@ -90,8 +88,7 @@ defmodule Philomena.MixProject do
],
"ecto.reset": ["ecto.drop", "ecto.setup"],
"ecto.migrate": ["ecto.migrate", "ecto.dump"],
"ecto.rollback": ["ecto.rollback", "ecto.dump"],
test: ["ecto.create --quiet", "ecto.load", "test"]
"ecto.rollback": ["ecto.rollback", "ecto.dump"]
]
end
end

View file

@ -0,0 +1,31 @@
defmodule Philomena.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
alter table(:users) do
add :confirmed_at, :naive_datetime
end
create table(:user_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(inserted_at: :created_at, updated_at: false)
end
execute(&email_citext_up/0, &email_citext_down/0)
create index(:user_tokens, [:user_id])
create unique_index(:user_tokens, [:context, :token])
end
defp email_citext_up() do
repo().query!("create extension citext")
repo().query!("alter table users alter column email type citext")
end
defp email_citext_down() do
repo().query!("alter table users alter column email type character varying")
repo().query!("drop extension citext")
end
end

View file

@ -26,6 +26,7 @@ alias Philomena.{
}
alias Philomena.Elasticsearch
alias Philomena.Users
alias Philomena.Tags
import Ecto.Query
@ -76,9 +77,13 @@ end
IO.puts("---- Generating users")
for user_def <- resources["users"] do
%User{role: user_def["role"]}
|> User.creation_changeset(user_def)
|> Repo.insert(on_conflict: :nothing)
{:ok, user} = Users.register_user(user_def)
user
|> Repo.preload([:roles])
|> User.confirm_changeset()
|> User.update_changeset(%{role: user_def["role"]}, [])
|> Repo.update!()
end
IO.puts("---- Generating roles")

View file

@ -64,7 +64,6 @@
"name": "Administrator",
"email": "admin@example.com",
"password": "trixieisbestpony",
"password_confirmation": "trixieisbestpony",
"role": "admin"
}],
"rating_tags": [

View file

@ -10,7 +10,7 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Philomena.{Repo, Forums.Forum, Users.User}
alias Philomena.{Repo, Forums.Forum, Users, Users.User}
alias Philomena.Comments
alias Philomena.Images
alias Philomena.Topics
@ -27,9 +27,13 @@ resources =
IO.puts "---- Generating users"
for user_def <- resources["users"] do
%User{role: user_def["role"]}
|> User.creation_changeset(user_def)
|> Repo.insert(on_conflict: :nothing)
{:ok, user} = Users.register_user(user_def)
user
|> Repo.preload([:roles])
|> User.confirm_changeset()
|> User.update_changeset(%{role: user_def["role"]}, [])
|> Repo.update!()
end
pleb = Repo.get_by!(User, name: "Pleb")

View file

@ -3,21 +3,18 @@
"name": "Hot Pocket Consumer",
"email": "moderator@example.com",
"password": "willdeleteglimmerposts4hotpockets",
"password_confirmation": "willdeleteglimmerposts4hotpockets",
"role": "moderator"
},
{
"name": "Hoping For a Promotion",
"email": "assistant@example.com",
"password": "hotpocketfetchingass",
"password_confirmation": "hotpocketfetchingass",
"role": "assistant"
},
{
"name": "Pleb",
"email": "user@example.com",
"password": "glimmerpostingplebeian",
"password_confirmation": "glimmerpostingplebeian",
"role": "user"
}
],

View file

@ -16,6 +16,20 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: citext; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
--
-- Name: EXTENSION citext; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION citext IS 'data type for case-insensitive character strings';
SET default_tablespace = '';
SET default_table_access_method = heap;
@ -1816,6 +1830,39 @@ CREATE SEQUENCE public.user_statistics_id_seq
ALTER SEQUENCE public.user_statistics_id_seq OWNED BY public.user_statistics.id;
--
-- Name: user_tokens; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.user_tokens (
id bigint NOT NULL,
user_id bigint NOT NULL,
token bytea NOT NULL,
context character varying(255) NOT NULL,
sent_to character varying(255),
created_at timestamp(0) without time zone NOT NULL
);
--
-- Name: user_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.user_tokens_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: user_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.user_tokens_id_seq OWNED BY public.user_tokens.id;
--
-- Name: user_whitelists; Type: TABLE; Schema: public; Owner: -
--
@ -1854,7 +1901,7 @@ ALTER SEQUENCE public.user_whitelists_id_seq OWNED BY public.user_whitelists.id;
CREATE TABLE public.users (
id integer NOT NULL,
email character varying DEFAULT ''::character varying NOT NULL,
email public.citext DEFAULT ''::character varying NOT NULL,
encrypted_password character varying DEFAULT ''::character varying NOT NULL,
reset_password_token character varying,
reset_password_sent_at timestamp without time zone,
@ -1926,7 +1973,8 @@ CREATE TABLE public.users (
otp_required_for_login boolean,
otp_backup_codes character varying[],
last_renamed_at timestamp without time zone DEFAULT '1970-01-01 00:00:00'::timestamp without time zone NOT NULL,
forced_filter_id bigint
forced_filter_id bigint,
confirmed_at timestamp(0) without time zone
);
@ -2310,6 +2358,13 @@ ALTER TABLE ONLY public.user_name_changes ALTER COLUMN id SET DEFAULT nextval('p
ALTER TABLE ONLY public.user_statistics ALTER COLUMN id SET DEFAULT nextval('public.user_statistics_id_seq'::regclass);
--
-- Name: user_tokens id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.user_tokens ALTER COLUMN id SET DEFAULT nextval('public.user_tokens_id_seq'::regclass);
--
-- Name: user_whitelists id; Type: DEFAULT; Schema: public; Owner: -
--
@ -2691,6 +2746,14 @@ ALTER TABLE ONLY public.user_statistics
ADD CONSTRAINT user_statistics_pkey PRIMARY KEY (id);
--
-- Name: user_tokens user_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.user_tokens
ADD CONSTRAINT user_tokens_pkey PRIMARY KEY (id);
--
-- Name: user_whitelists user_whitelists_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -3891,6 +3954,20 @@ CREATE INDEX index_vpns_on_ip ON public.vpns USING gist (ip inet_ops);
CREATE INDEX intensities_index ON public.images USING btree (se_intensity, sw_intensity, ne_intensity, nw_intensity, average_intensity);
--
-- Name: user_tokens_context_token_index; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX user_tokens_context_token_index ON public.user_tokens USING btree (context, token);
--
-- Name: user_tokens_user_id_index; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX user_tokens_user_id_index ON public.user_tokens USING btree (user_id);
--
-- Name: channels fk_rails_021c624081; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -4691,6 +4768,14 @@ ALTER TABLE ONLY public.image_sources
ADD CONSTRAINT image_sources_image_id_fkey FOREIGN KEY (image_id) REFERENCES public.images(id);
--
-- Name: user_tokens user_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.user_tokens
ADD CONSTRAINT user_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: users users_forced_filter_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -4708,3 +4793,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20200607000511);
INSERT INTO public."schema_migrations" (version) VALUES (20200617111116);
INSERT INTO public."schema_migrations" (version) VALUES (20200617113333);
INSERT INTO public."schema_migrations" (version) VALUES (20200706171350);
INSERT INTO public."schema_migrations" (version) VALUES (20200725234412);

View file

@ -0,0 +1,627 @@
defmodule Philomena.UsersTest do
use Philomena.DataCase
alias Philomena.Users
import Philomena.UsersFixtures
alias Philomena.Users.{User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Users.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Users.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/3" do
test "does not return the user if the email does not exist" do
refute Users.get_user_by_email_and_password("unknown@example.com", "hello world!", & &1)
end
test "does not return the user if the password is not valid" do
user = user_fixture()
refute Users.get_user_by_email_and_password(user.email, "invalid", & &1)
user = Users.get_user!(user.id)
assert user.failed_attempts == 1
end
test "sends lock email if too many attempts to sign in are made" do
user = user_fixture()
Enum.map(1..10, fn _ ->
refute Users.get_user_by_email_and_password(user.email, "invalid", & &1)
end)
user = Users.get_user!(user.id)
token =
extract_user_token(fn url ->
Users.deliver_user_unlock_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 == "unlock"
assert user.failed_attempts == 10
refute is_nil(user.locked_at)
end
test "denies access to account if locked" do
user = user_fixture()
Enum.map(1..10, fn _ ->
refute Users.get_user_by_email_and_password(user.email, "invalid", & &1)
end)
refute Users.get_user_by_email_and_password(user.email, valid_user_password(), & &1)
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} =
Users.get_user_by_email_and_password(user.email, valid_user_password(), & &1)
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Users.get_user!(-1)
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Users.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires email and password to be set" do
{:error, changeset} = Users.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} = Users.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} = Users.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 80 character(s)" in errors_on(changeset).password
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Users.register_user(%{name: email, 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} =
Users.register_user(%{
name: String.upcase(email),
email: String.upcase(email),
password: valid_user_password()
})
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} =
Users.register_user(%{name: email, email: email, password: valid_user_password()})
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 = Users.change_user_registration(%User{})
assert changeset.required == [:password, :email, :name]
end
end
describe "change_user_email/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Users.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} = Users.apply_user_email(user, valid_user_password(), %{})
assert %{email: ["did not change"]} = errors_on(changeset)
end
test "validates email", %{user: user} do
{:error, changeset} =
Users.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} =
Users.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()
{:error, changeset} = Users.apply_user_email(user, valid_user_password(), %{email: email})
assert "has already been taken" in errors_on(changeset).email
end
test "validates current password", %{user: user} do
{:error, changeset} = Users.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} = Users.apply_user_email(user, valid_user_password(), %{email: email})
assert user.email == email
assert Users.get_user!(user.id).email != email
end
end
describe "deliver_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 ->
Users.deliver_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 ->
Users.deliver_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 Users.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 Users.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 Users.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: [created_at: ~N[2020-01-01 00:00:00]])
assert Users.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 = Users.change_user_password(%User{})
assert changeset.required == [:password]
end
end
describe "update_user_password/3" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Users.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", 200)
{:error, changeset} =
Users.update_user_password(user, valid_user_password(), %{password: too_long})
assert "should be at most 80 character(s)" in errors_on(changeset).password
end
test "validates current password", %{user: user} do
{:error, changeset} =
Users.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} =
Users.update_user_password(user, valid_user_password(), %{
password: "new valid password"
})
assert is_nil(user.password)
assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1)
end
test "deletes all tokens for the given user", %{user: user} do
_ = Users.generate_user_session_token(user)
{:ok, _} =
Users.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 = Users.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 = Users.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Users.get_user_by_session_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Users.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: [created_at: ~N[2019-01-01 00:00:00]])
refute Users.get_user_by_session_token(token)
end
end
describe "delete_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Users.generate_user_session_token(user)
assert Users.delete_session_token(token) == :ok
refute Users.get_user_by_session_token(token)
end
end
describe "generate_user_totp_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Users.generate_user_totp_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "totp"
# 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: "totp"
})
end
end
end
describe "user_totp_token_valid?/1" do
setup do
user = user_fixture()
token = Users.generate_user_totp_token(user)
%{user: user, token: token}
end
test "returns true for valid token", %{user: user, token: token} do
assert Users.user_totp_token_valid?(user, token)
end
test "returns false for invalid token", %{user: user} do
refute Users.user_totp_token_valid?(user, "oops")
end
test "returns false for expired token", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [created_at: ~N[2019-01-01 00:00:00]])
refute Users.user_totp_token_valid?(user, token)
end
end
describe "delete_totp_token/1" do
test "deletes the token" do
user = user_fixture()
token = Users.generate_user_totp_token(user)
assert Users.delete_totp_token(token) == :ok
refute Users.user_totp_token_valid?(user, 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 ->
Users.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/2" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Users.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} = Users.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 Users.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: [created_at: ~N[2020-01-01 00:00:00]])
assert Users.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_unlock_instructions/2" do
setup do
%{user: locked_user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Users.deliver_user_unlock_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 == "unlock"
end
end
describe "unlock_user/2" do
setup do
user = locked_user_fixture()
token =
extract_user_token(fn url ->
Users.deliver_user_unlock_instructions(user, url)
end)
%{user: user, token: token}
end
test "unlocks the user with a valid token", %{user: user, token: token} do
assert {:ok, unlocked_user} = Users.unlock_user(token)
refute unlocked_user.locked_at
refute Repo.get!(User, user.id).locked_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm with invalid token", %{user: user} do
assert Users.unlock_user("oops") == :error
assert Repo.get!(User, user.id).locked_at
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not unlocked if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [created_at: ~N[2020-01-01 00:00:00]])
assert Users.unlock_user(token) == :error
assert Repo.get!(User, user.id).locked_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 ->
Users.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/2" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Users.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} = Users.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 Users.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: [created_at: ~N[2020-01-01 00:00:00]])
refute Users.get_user_by_reset_password_token(token)
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "reset_user_password/3" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Users.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} = Users.reset_user_password(user, %{password: too_long})
assert "should be at most 80 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, updated_user} = Users.reset_user_password(user, %{password: "new valid password"})
assert is_nil(updated_user.password)
assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1)
end
test "deletes all tokens for the given user", %{user: user} do
_ = Users.generate_user_session_token(user)
{:ok, _} = Users.reset_user_password(user, %{password: "new valid password"})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "inspect/2" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end

View file

@ -0,0 +1,84 @@
defmodule PhilomenaWeb.ConfirmationControllerTest do
use PhilomenaWeb.ConnCase, async: true
alias Philomena.Users
alias Philomena.Repo
import Philomena.UsersFixtures
setup do
%{user: user_fixture()}
end
describe "GET /confirmations/new" do
test "renders the confirmation page", %{conn: conn} do
conn = get(conn, Routes.confirmation_path(conn, :new))
response = html_response(conn, 200)
assert response =~ "<h1>Resend confirmation instructions</h1>"
end
end
describe "POST /confirmations" do
@tag :capture_log
test "sends a new confirmation token", %{conn: conn, user: user} do
conn =
post(conn, Routes.confirmation_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.get_by!(Users.UserToken, user_id: user.id).context == "confirm"
end
test "does not send confirmation token if account is confirmed", %{conn: conn, user: user} do
Repo.update!(Users.User.confirm_changeset(user))
conn =
post(conn, Routes.confirmation_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
refute Repo.get_by(Users.UserToken, user_id: user.id)
end
test "does not send confirmation token if email is invalid", %{conn: conn} do
conn =
post(conn, Routes.confirmation_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.all(Users.UserToken) == []
end
end
describe "GET /confirmations/:id" do
test "confirms the given token once", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Users.deliver_user_confirmation_instructions(user, url)
end)
conn = get(conn, Routes.confirmation_path(conn, :show, token))
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "Account confirmed successfully"
assert Users.get_user!(user.id).confirmed_at
refute get_session(conn, :user_token)
assert Repo.all(Users.UserToken) == []
conn = get(conn, Routes.confirmation_path(conn, :show, token))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired"
end
test "does not confirm email with invalid token", %{conn: conn, user: user} do
conn = get(conn, Routes.confirmation_path(conn, :show, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired"
refute Users.get_user!(user.id).confirmed_at
end
end
end

View file

@ -0,0 +1,111 @@
defmodule PhilomenaWeb.PasswordControllerTest do
use PhilomenaWeb.ConnCase, async: true
alias Philomena.Users
alias Philomena.Repo
import Philomena.UsersFixtures
setup do
%{user: confirmed_user_fixture()}
end
describe "GET /passwords/new" do
test "renders the reset password page", %{conn: conn} do
conn = get(conn, Routes.password_path(conn, :new))
html_response(conn, 200)
end
end
describe "POST /passwords" do
@tag :capture_log
test "sends a new reset password token", %{conn: conn, user: user} do
conn =
post(conn, Routes.password_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.get_by!(Users.UserToken, user_id: user.id).context == "reset_password"
end
test "does not send reset password token if email is invalid", %{conn: conn} do
conn =
post(conn, Routes.password_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.all(Users.UserToken) == []
end
end
describe "GET /passwords/:token" do
setup %{user: user} do
token =
extract_user_token(fn url ->
Users.deliver_user_reset_password_instructions(user, url)
end)
%{token: token}
end
test "renders reset password", %{conn: conn, token: token} do
conn = get(conn, Routes.password_path(conn, :edit, token))
html_response(conn, 200)
end
test "does not render reset password with invalid token", %{conn: conn} do
conn = get(conn, Routes.password_path(conn, :edit, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
end
end
describe "PUT /passwords/:token" do
setup %{user: user} do
token =
extract_user_token(fn url ->
Users.deliver_user_reset_password_instructions(user, url)
end)
%{token: token}
end
test "resets password once", %{conn: conn, user: user, token: token} do
conn =
put(conn, Routes.password_path(conn, :update, token), %{
"user" => %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(conn) == Routes.session_path(conn, :new)
refute get_session(conn, :user_token)
assert get_flash(conn, :info) =~ "Password reset successfully"
assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1)
end
test "does not reset password on invalid data", %{conn: conn, token: token} do
conn =
put(conn, Routes.password_path(conn, :update, token), %{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
response = html_response(conn, 200)
assert response =~ "should be at least 12 character"
assert response =~ "does not match password"
end
test "does not reset password with invalid token", %{conn: conn} do
conn = put(conn, Routes.password_path(conn, :update, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
end
end
end

View file

@ -0,0 +1,71 @@
defmodule PhilomenaWeb.Registration.EmailControllerTest do
use PhilomenaWeb.ConnCase, async: true
alias Philomena.Users
import Philomena.UsersFixtures
setup :register_and_log_in_user
describe "POST /registrations/email" do
@tag :capture_log
test "updates the user email", %{conn: conn, user: user} do
conn =
post(conn, Routes.registration_email_path(conn, :create), %{
"current_password" => valid_user_password(),
"user" => %{"email" => unique_user_email()}
})
assert redirected_to(conn) == Routes.registration_path(conn, :edit)
assert get_flash(conn, :info) =~ "A link to confirm your email"
assert Users.get_user_by_email(user.email)
end
test "does not update email on invalid data", %{conn: conn} do
conn =
post(conn, Routes.registration_email_path(conn, :create), %{
"current_password" => "invalid",
"user" => %{"email" => "with spaces"}
})
assert get_flash(conn, :error) =~ "Failed to update email"
end
end
describe "GET /registrations/email/:token" do
setup %{user: user} do
email = unique_user_email()
token =
extract_user_token(fn url ->
Users.deliver_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{token: token, email: email}
end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
conn = get(conn, Routes.registration_email_path(conn, :show, token))
assert redirected_to(conn) == Routes.registration_path(conn, :edit)
assert get_flash(conn, :info) =~ "Email changed successfully"
refute Users.get_user_by_email(user.email)
assert Users.get_user_by_email(email)
conn = get(conn, Routes.registration_email_path(conn, :show, token))
assert redirected_to(conn) == Routes.registration_path(conn, :edit)
assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
end
test "does not update email with invalid token", %{conn: conn, user: user} do
conn = get(conn, Routes.registration_email_path(conn, :show, "oops"))
assert redirected_to(conn) == Routes.registration_path(conn, :edit)
assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
assert Users.get_user_by_email(user.email)
end
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
conn = get(conn, Routes.registration_email_path(conn, :show, token))
assert redirected_to(conn) == Routes.session_path(conn, :new)
end
end
end

View file

@ -0,0 +1,41 @@
defmodule PhilomenaWeb.Registration.PasswordControllerTest do
use PhilomenaWeb.ConnCase, async: true
alias Philomena.Users
import Philomena.UsersFixtures
setup :register_and_log_in_user
describe "PUT /registrations/password" do
test "updates the user password and resets tokens", %{conn: conn, user: user} do
new_password_conn =
put(conn, Routes.registration_password_path(conn, :update), %{
"current_password" => valid_user_password(),
"user" => %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(new_password_conn) == Routes.registration_path(conn, :edit)
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert get_flash(new_password_conn, :info) =~ "Password updated successfully"
assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1)
end
test "does not update password on invalid data", %{conn: conn} do
old_password_conn =
put(conn, Routes.registration_password_path(conn, :update), %{
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
assert redirected_to(old_password_conn) == Routes.registration_path(conn, :edit)
assert get_flash(old_password_conn, :error) =~ "Failed to update password"
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
end
end
end

View file

@ -0,0 +1,64 @@
defmodule PhilomenaWeb.RegistrationControllerTest do
use PhilomenaWeb.ConnCase, async: true
import Philomena.UsersFixtures
describe "GET /registrations/new" do
test "renders registration page", %{conn: conn} do
conn = get(conn, Routes.registration_path(conn, :new))
html_response(conn, 200)
end
test "redirects if already logged in", %{conn: conn} do
conn =
conn |> log_in_user(confirmed_user_fixture()) |> get(Routes.registration_path(conn, :new))
assert redirected_to(conn) == "/"
end
end
describe "POST /registrations" do
@tag :capture_log
test "creates account but doesn't log the user in", %{conn: conn} do
email = unique_user_email()
conn =
post(conn, Routes.registration_path(conn, :create), %{
"user" => %{"name" => email, "email" => email, "password" => valid_user_password()}
})
assert redirected_to(conn) =~ "/"
conn = get(conn, "/sessions/new")
html_response(conn, 200)
assert get_flash(conn, :info) =~ "email for confirmation instructions"
end
test "render errors for invalid data", %{conn: conn} do
conn =
post(conn, Routes.registration_path(conn, :create), %{
"user" => %{"email" => "with spaces", "password" => "too short"}
})
response = html_response(conn, 200)
assert response =~ "must have the @ sign and no spaces"
assert response =~ "should be at least 12 character"
end
end
describe "GET /registration/edit" do
setup :register_and_log_in_user
test "renders settings page", %{conn: conn} do
conn = get(conn, Routes.registration_path(conn, :edit))
response = html_response(conn, 200)
assert response =~ "Settings"
end
test "redirects if user is not logged in" do
conn = build_conn()
conn = get(conn, Routes.registration_path(conn, :edit))
assert redirected_to(conn) == Routes.session_path(conn, :new)
end
end
end

Some files were not shown because too many files have changed in this diff Show more