rename to memex
This commit is contained in:
.drone.yml.gitignoreDockerfilememex.exmix.exsreadme.md
assets
config
contributing.mddocker-compose.ymllib
lokal_web
views
memex
memex_web.exmemex_web
components
controllers
email_controller.exhome_controller.exuser_auth.exuser_confirmation_controller.exuser_registration_controller.exuser_reset_password_controller.exuser_session_controller.exuser_settings_controller.ex
endpoint.exgettext.exlive
router.extelemetry.extemplates
email
confirm_email.html.heexconfirm_email.txt.eexreset_password.html.heexreset_password.txt.eexupdate_email.html.heexupdate_email.txt.eex
error
layout
user_confirmation
user_registration
user_reset_password
user_session
user_settings
views
priv
gettext
repo
test
48
lib/memex/accounts/email.ex
Normal file
48
lib/memex/accounts/email.ex
Normal file
@ -0,0 +1,48 @@
|
||||
defmodule Memex.Email do
|
||||
@moduledoc """
|
||||
Emails that can be sent using Swoosh.
|
||||
|
||||
You can find the base email templates at
|
||||
`lib/memex_web/templates/layout/email.html.heex` for html emails and
|
||||
`lib/memex_web/templates/layout/email.txt.heex` for text emails.
|
||||
"""
|
||||
|
||||
use Phoenix.Swoosh, view: MemexWeb.EmailView, layout: {MemexWeb.LayoutView, :email}
|
||||
import MemexWeb.Gettext
|
||||
alias Memex.Accounts.User
|
||||
alias MemexWeb.EmailView
|
||||
|
||||
@typedoc """
|
||||
Represents an HTML and text body email that can be sent
|
||||
"""
|
||||
@type t() :: Swoosh.Email.t()
|
||||
|
||||
@spec base_email(User.t(), String.t()) :: t()
|
||||
defp base_email(%User{email: email}, subject) do
|
||||
from = Application.get_env(:Memex, Memex.Mailer)[:email_from] || "noreply@localhost"
|
||||
name = Application.get_env(:Memex, Memex.Mailer)[:email_name]
|
||||
new() |> to(email) |> from({name, from}) |> subject(subject)
|
||||
end
|
||||
|
||||
@spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t()
|
||||
def generate_email("welcome", user, %{"url" => url}) do
|
||||
user
|
||||
|> base_email(dgettext("emails", "Confirm your Memex account"))
|
||||
|> render_body("confirm_email.html", %{user: user, url: url})
|
||||
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
|
||||
end
|
||||
|
||||
def generate_email("reset_password", user, %{"url" => url}) do
|
||||
user
|
||||
|> base_email(dgettext("emails", "Reset your Memex password"))
|
||||
|> render_body("reset_password.html", %{user: user, url: url})
|
||||
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
|
||||
end
|
||||
|
||||
def generate_email("update_email", user, %{"url" => url}) do
|
||||
user
|
||||
|> base_email(dgettext("emails", "Update your Memex email"))
|
||||
|> render_body("update_email.html", %{user: user, url: url})
|
||||
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url}))
|
||||
end
|
||||
end
|
13
lib/memex/accounts/email_worker.ex
Normal file
13
lib/memex/accounts/email_worker.ex
Normal file
@ -0,0 +1,13 @@
|
||||
defmodule Memex.EmailWorker do
|
||||
@moduledoc """
|
||||
Oban worker that dispatches emails
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :mailers, tags: ["email"]
|
||||
alias Memex.{Accounts, Email, Mailer}
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"email" => email, "user_id" => user_id, "attrs" => attrs}}) do
|
||||
Email.generate_email(email, user_id |> Accounts.get_user!(), attrs) |> Mailer.deliver()
|
||||
end
|
||||
end
|
200
lib/memex/accounts/user.ex
Normal file
200
lib/memex/accounts/user.ex
Normal file
@ -0,0 +1,200 @@
|
||||
defmodule Memex.Accounts.User do
|
||||
@moduledoc """
|
||||
A Memex user
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import MemexWeb.Gettext
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Invites.Invite}
|
||||
|
||||
@derive {Inspect, except: [:password]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true
|
||||
field :hashed_password, :string
|
||||
field :confirmed_at, :naive_datetime
|
||||
field :role, Ecto.Enum, values: [:admin, :user], default: :user
|
||||
field :locale, :string
|
||||
|
||||
has_many :invites, Invite, on_delete: :delete_all
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %User{
|
||||
id: id(),
|
||||
email: String.t(),
|
||||
password: String.t(),
|
||||
hashed_password: String.t(),
|
||||
confirmed_at: NaiveDateTime.t(),
|
||||
role: atom(),
|
||||
invites: [Invite.t()],
|
||||
locale: String.t() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type new_user :: %User{}
|
||||
@type id :: UUID.t()
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
|
||||
It is important to validate the length of both email and password.
|
||||
Otherwise databases may truncate the email without warnings, which
|
||||
could lead to unpredictable or insecure behaviour. Long passwords may
|
||||
also be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
@spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user())
|
||||
@spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :role, :locale])
|
||||
|> validate_email()
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for role.
|
||||
|
||||
"""
|
||||
@spec role_changeset(t(), role :: atom()) :: Changeset.t(t())
|
||||
def role_changeset(user, role) do
|
||||
user |> cast(%{"role" => role}, [:role])
|
||||
end
|
||||
|
||||
@spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user())
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/,
|
||||
message: dgettext("errors", "must have the @ sign and no spaces")
|
||||
)
|
||||
|> validate_length(:email, max: 160)
|
||||
|> unsafe_validate_unique(:email, Memex.Repo)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
|
||||
@spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 80)
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
@spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
"""
|
||||
@spec email_changeset(t(), attrs :: map()) :: Changeset.t(t())
|
||||
def email_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email()
|
||||
|> case do
|
||||
%{changes: %{email: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
@spec password_changeset(t(), attrs :: map()) :: Changeset.t(t())
|
||||
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t())
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: dgettext("errors", "does not match password"))
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
@spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t())
|
||||
def confirm_changeset(user_or_changeset) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
user_or_changeset |> change(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.
|
||||
"""
|
||||
@spec valid_password?(t(), String.t()) :: boolean()
|
||||
def valid_password?(%User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
@spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t())
|
||||
def validate_current_password(changeset, password) do
|
||||
if valid_password?(changeset.data, password),
|
||||
do: changeset,
|
||||
else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))
|
||||
end
|
||||
|
||||
@doc """
|
||||
A changeset for changing the user's locale
|
||||
"""
|
||||
@spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t())
|
||||
def locale_changeset(user_or_changeset, locale) do
|
||||
user_or_changeset
|
||||
|> cast(%{"locale" => locale}, [:locale])
|
||||
|> validate_required(:locale)
|
||||
end
|
||||
end
|
77
lib/memex/accounts/user_notifier.ex
Normal file
77
lib/memex/accounts/user_notifier.ex
Normal file
@ -0,0 +1,77 @@
|
||||
defmodule Memex.Accounts.UserNotifier do
|
||||
@moduledoc """
|
||||
Contains templates and messages for user messages
|
||||
"""
|
||||
|
||||
# For simplicity, this module simply logs messages to the terminal.
|
||||
# You should replace it by a proper email or notification tool, such as:
|
||||
#
|
||||
# * Swoosh - https://hexdocs.pm/swoosh
|
||||
# * Bamboo - https://hexdocs.pm/bamboo
|
||||
#
|
||||
defp deliver(to, body) do
|
||||
require Logger
|
||||
Logger.debug(body)
|
||||
{:ok, %{to: to, body: body}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
145
lib/memex/accounts/user_token.ex
Normal file
145
lib/memex/accounts/user_token.ex
Normal file
@ -0,0 +1,145 @@
|
||||
defmodule Memex.Accounts.UserToken do
|
||||
@moduledoc """
|
||||
Schema for a user's session token
|
||||
"""
|
||||
|
||||
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
|
||||
@session_validity_in_days 60
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Memex.Accounts.User
|
||||
|
||||
timestamps(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, %Memex.Accounts.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.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: user
|
||||
|
||||
{: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),
|
||||
%Memex.Accounts.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.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("confirm"), do: @confirm_validity_in_days
|
||||
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user 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.inserted_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 Memex.Accounts.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 Memex.Accounts.UserToken, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user