mirror of
https://github.com/plausible/analytics.git
synced 2024-12-21 00:21:43 +03:00
19f1b03043
Fixes a potential timing attack where someone could find out whether an email address is in our database or not.
560 lines
16 KiB
Elixir
560 lines
16 KiB
Elixir
defmodule PlausibleWeb.AuthController do
|
|
use PlausibleWeb, :controller
|
|
use Plausible.Repo
|
|
alias Plausible.Auth
|
|
require Logger
|
|
|
|
plug PlausibleWeb.RequireLoggedOutPlug
|
|
when action in [
|
|
:register_form,
|
|
:register,
|
|
:register_from_invitation_form,
|
|
:register_from_invitation,
|
|
:login_form,
|
|
:login
|
|
]
|
|
|
|
plug PlausibleWeb.RequireAccountPlug
|
|
when action in [
|
|
:user_settings,
|
|
:save_settings,
|
|
:delete_me,
|
|
:password_form,
|
|
:set_password,
|
|
:activate_form
|
|
]
|
|
|
|
def register_form(conn, _params) do
|
|
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
|
|
redirect(conn, to: Routes.auth_path(conn, :login_form))
|
|
else
|
|
changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
|
|
|
|
render(conn, "register_form.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def register(conn, params) do
|
|
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
|
|
redirect(conn, to: Routes.auth_path(conn, :login_form))
|
|
else
|
|
user = Plausible.Auth.User.new(params["user"])
|
|
|
|
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
case Repo.insert(user) do
|
|
{:ok, user} ->
|
|
conn = set_user_session(conn, user)
|
|
|
|
case user.email_verified do
|
|
false ->
|
|
send_email_verification(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
|
|
true ->
|
|
redirect(conn, to: Routes.site_path(conn, :new))
|
|
end
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "register_form.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
else
|
|
render(conn, "register_form.html",
|
|
changeset: user,
|
|
captcha_error: "Please complete the captcha to register",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def register_from_invitation_form(conn, %{"invitation_id" => invitation_id}) do
|
|
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
|
|
redirect(conn, to: Routes.auth_path(conn, :login_form))
|
|
else
|
|
invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
|
|
changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
|
|
|
|
if invitation do
|
|
render(conn, "register_from_invitation_form.html",
|
|
changeset: changeset,
|
|
invitation: invitation,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
else
|
|
render(conn, "invitation_expired.html",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def register_from_invitation(conn, %{"invitation_id" => invitation_id} = params) do
|
|
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
|
|
redirect(conn, to: Routes.auth_path(conn, :login_form))
|
|
else
|
|
invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
|
|
user = Plausible.Auth.User.new(params["user"])
|
|
|
|
user =
|
|
case invitation.role do
|
|
:owner -> user
|
|
_ -> Plausible.Auth.User.remove_trial_expiry(user)
|
|
end
|
|
|
|
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
case Repo.insert(user) do
|
|
{:ok, user} ->
|
|
conn = set_user_session(conn, user)
|
|
|
|
case user.email_verified do
|
|
false ->
|
|
send_email_verification(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
|
|
true ->
|
|
redirect(conn, to: Routes.site_path(conn, :index))
|
|
end
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "register_from_invitation_form.html",
|
|
invitation: invitation,
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
else
|
|
render(conn, "register_from_invitation_form.html",
|
|
invitation: invitation,
|
|
changeset: user,
|
|
captcha_error: "Please complete the captcha to register",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp send_email_verification(user) do
|
|
code = Auth.issue_email_verification(user)
|
|
Logger.info("VERIFICATION CODE: #{code}")
|
|
email_template = PlausibleWeb.Email.activation_email(user, code)
|
|
Plausible.Mailer.send_email(email_template)
|
|
end
|
|
|
|
defp set_user_session(conn, user) do
|
|
conn
|
|
|> put_session(:current_user_id, user.id)
|
|
|> put_resp_cookie("logged_in", "true",
|
|
http_only: false,
|
|
max_age: 60 * 60 * 24 * 365 * 5000
|
|
)
|
|
end
|
|
|
|
def activate_form(conn, _params) do
|
|
user = conn.assigns[:current_user]
|
|
|
|
has_invitation =
|
|
Repo.exists?(
|
|
from i in Plausible.Auth.Invitation,
|
|
where: i.email == ^user.email
|
|
)
|
|
|
|
has_code =
|
|
Repo.exists?(
|
|
from c in "email_verification_codes",
|
|
where: c.user_id == ^user.id
|
|
)
|
|
|
|
render(conn, "activate.html",
|
|
has_pin: has_code,
|
|
has_invitation: has_invitation,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def activate(conn, %{"code" => code}) do
|
|
user = conn.assigns[:current_user]
|
|
|
|
has_invitation =
|
|
Repo.exists?(
|
|
from i in Plausible.Auth.Invitation,
|
|
where: i.email == ^user.email
|
|
)
|
|
|
|
{code, ""} = Integer.parse(code)
|
|
|
|
case Auth.verify_email(user, code) do
|
|
:ok ->
|
|
if has_invitation do
|
|
redirect(conn, to: Routes.site_path(conn, :index))
|
|
else
|
|
redirect(conn, to: Routes.site_path(conn, :new))
|
|
end
|
|
|
|
{:error, :incorrect} ->
|
|
render(conn, "activate.html",
|
|
error: "Incorrect activation code",
|
|
has_pin: true,
|
|
has_invitation: has_invitation,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:error, :expired} ->
|
|
render(conn, "activate.html",
|
|
error: "Code is expired, please request another one",
|
|
has_pin: false,
|
|
has_invitation: has_invitation,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def request_activation_code(conn, _params) do
|
|
user = conn.assigns[:current_user]
|
|
code = Auth.issue_email_verification(user)
|
|
|
|
email_template = PlausibleWeb.Email.activation_email(user, code)
|
|
Plausible.Mailer.send_email(email_template)
|
|
|
|
conn
|
|
|> put_flash(:success, "Activation code was sent to #{user.email}")
|
|
|> redirect(to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
|
|
def password_reset_request_form(conn, _) do
|
|
render(conn, "password_reset_request_form.html",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def password_reset_request(conn, %{"email" => ""}) do
|
|
render(conn, "password_reset_request_form.html",
|
|
error: "Please enter an email address",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def password_reset_request(conn, %{"email" => email} = params) do
|
|
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
user = Repo.get_by(Plausible.Auth.User, email: email)
|
|
|
|
if user do
|
|
token = Auth.Token.sign_password_reset(email)
|
|
url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
|
|
Logger.debug("PASSWORD RESET LINK: " <> url)
|
|
email_template = PlausibleWeb.Email.password_reset_email(email, url)
|
|
Plausible.Mailer.deliver_later(email_template)
|
|
|
|
render(conn, "password_reset_request_success.html",
|
|
email: email,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
else
|
|
render(conn, "password_reset_request_success.html",
|
|
email: email,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
else
|
|
render(conn, "password_reset_request_form.html",
|
|
error: "Please complete the captcha to reset your password",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def password_reset_form(conn, %{"token" => token}) do
|
|
case Auth.Token.verify_password_reset(token) do
|
|
{:ok, _} ->
|
|
render(conn, "password_reset_form.html",
|
|
token: token,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:error, :expired} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token has expired. Please request another password reset link."
|
|
)
|
|
|
|
{:error, _} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token is invalid. Please request another password reset link."
|
|
)
|
|
end
|
|
end
|
|
|
|
def password_reset(conn, %{"token" => token, "password" => pw}) do
|
|
case Auth.Token.verify_password_reset(token) do
|
|
{:ok, %{email: email}} ->
|
|
user = Repo.get_by(Auth.User, email: email)
|
|
changeset = Auth.User.set_password(user, pw)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, _updated} ->
|
|
conn
|
|
|> put_flash(:login_title, "Password updated successfully")
|
|
|> put_flash(:login_instructions, "Please log in with your new credentials")
|
|
|> put_session(:current_user_id, nil)
|
|
|> delete_resp_cookie("logged_in")
|
|
|> redirect(to: Routes.auth_path(conn, :login_form))
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "password_reset_form.html",
|
|
changeset: changeset,
|
|
token: token,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
{:error, :expired} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token has expired. Please request another password reset link."
|
|
)
|
|
|
|
{:error, _} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token is invalid. Please request another password reset link."
|
|
)
|
|
end
|
|
end
|
|
|
|
def login(conn, %{"email" => email, "password" => password}) do
|
|
with :ok <- check_ip_rate_limit(conn),
|
|
{:ok, user} <- find_user(email),
|
|
:ok <- check_user_rate_limit(user),
|
|
:ok <- check_password(user, password) do
|
|
login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index)
|
|
|
|
conn
|
|
|> put_session(:current_user_id, user.id)
|
|
|> put_resp_cookie("logged_in", "true",
|
|
http_only: false,
|
|
max_age: 60 * 60 * 24 * 365 * 5000
|
|
)
|
|
|> put_session(:login_dest, nil)
|
|
|> redirect(to: login_dest)
|
|
else
|
|
:wrong_password ->
|
|
render(conn, "login_form.html",
|
|
error: "Wrong email or password. Please try again.",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
:user_not_found ->
|
|
Plausible.Auth.Password.dummy_calculation()
|
|
|
|
render(conn, "login_form.html",
|
|
error: "Wrong email or password. Please try again.",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:rate_limit, _} ->
|
|
render_error(
|
|
conn,
|
|
429,
|
|
"Too many login attempts. Wait a minute before trying again."
|
|
)
|
|
end
|
|
end
|
|
|
|
@login_interval 60_000
|
|
@login_limit 5
|
|
defp check_ip_rate_limit(conn) do
|
|
ip_address = PlausibleWeb.RemoteIp.get(conn)
|
|
|
|
case Hammer.check_rate("login:ip:#{ip_address}", @login_interval, @login_limit) do
|
|
{:allow, _} -> :ok
|
|
{:deny, _} -> {:rate_limit, :ip_address}
|
|
end
|
|
end
|
|
|
|
defp find_user(email) do
|
|
user =
|
|
Repo.one(
|
|
from u in Plausible.Auth.User,
|
|
where: u.email == ^email
|
|
)
|
|
|
|
if user, do: {:ok, user}, else: :user_not_found
|
|
end
|
|
|
|
defp check_user_rate_limit(user) do
|
|
case Hammer.check_rate("login:user:#{user.id}", @login_interval, @login_limit) do
|
|
{:allow, _} -> :ok
|
|
{:deny, _} -> {:rate_limit, :user}
|
|
end
|
|
end
|
|
|
|
defp check_password(user, password) do
|
|
if Plausible.Auth.Password.match?(password, user.password_hash || "") do
|
|
:ok
|
|
else
|
|
:wrong_password
|
|
end
|
|
end
|
|
|
|
def login_form(conn, _params) do
|
|
render(conn, "login_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
|
|
end
|
|
|
|
def password_form(conn, _params) do
|
|
render(conn, "password_form.html",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
|
|
def set_password(conn, %{"password" => pw}) do
|
|
changeset = Auth.User.set_password(conn.assigns[:current_user], pw)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, _user} ->
|
|
redirect(conn, to: "/sites/new")
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "password_form.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def user_settings(conn, _params) do
|
|
user = conn.assigns[:current_user]
|
|
changeset = Auth.User.changeset(user)
|
|
|
|
{usage_pageviews, usage_custom_events} = Plausible.Billing.usage_breakdown(user)
|
|
|
|
render(conn, "user_settings.html",
|
|
user: user |> Repo.preload(:api_keys),
|
|
changeset: changeset,
|
|
subscription: user.subscription,
|
|
invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription),
|
|
theme: user.theme || "system",
|
|
usage_pageviews: usage_pageviews,
|
|
usage_custom_events: usage_custom_events
|
|
)
|
|
end
|
|
|
|
def save_settings(conn, %{"user" => user_params}) do
|
|
changes = Auth.User.changeset(conn.assigns[:current_user], user_params)
|
|
|
|
case Repo.update(changes) do
|
|
{:ok, _user} ->
|
|
conn
|
|
|> put_flash(:success, "Account settings saved successfully")
|
|
|> redirect(to: Routes.auth_path(conn, :user_settings))
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "user_settings.html",
|
|
changeset: changeset,
|
|
subscription: conn.assigns[:current_user].subscription
|
|
)
|
|
end
|
|
end
|
|
|
|
def new_api_key(conn, _params) do
|
|
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
|
|
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, %{key: key})
|
|
|
|
render(conn, "new_api_key.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def create_api_key(conn, %{"api_key" => key_params}) do
|
|
api_key = %Auth.ApiKey{user_id: conn.assigns[:current_user].id}
|
|
changeset = Auth.ApiKey.changeset(api_key, key_params)
|
|
|
|
case Repo.insert(changeset) do
|
|
{:ok, _api_key} ->
|
|
conn
|
|
|> put_flash(:success, "API key created successfully")
|
|
|> redirect(to: "/settings#api-keys")
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "new_api_key.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def delete_api_key(conn, %{"id" => id}) do
|
|
Repo.get_by(Auth.ApiKey, id: id)
|
|
|> Repo.delete!()
|
|
|
|
conn
|
|
|> put_flash(:success, "API key revoked successfully")
|
|
|> redirect(to: "/settings#api-keys")
|
|
end
|
|
|
|
def delete_me(conn, params) do
|
|
user =
|
|
conn.assigns[:current_user]
|
|
|> Repo.preload(site_memberships: :site)
|
|
|> Repo.preload(:subscription)
|
|
|
|
for membership <- user.site_memberships do
|
|
Repo.delete!(membership)
|
|
|
|
if membership.role == :owner do
|
|
Plausible.Sites.delete!(membership.site)
|
|
end
|
|
end
|
|
|
|
if user.subscription, do: Repo.delete!(user.subscription)
|
|
Repo.delete!(user)
|
|
|
|
logout(conn, params)
|
|
end
|
|
|
|
def logout(conn, params) do
|
|
redirect_to = Map.get(params, "redirect", "/")
|
|
|
|
conn
|
|
|> configure_session(drop: true)
|
|
|> delete_resp_cookie("logged_in")
|
|
|> redirect(to: redirect_to)
|
|
end
|
|
|
|
def google_auth_callback(conn, %{"code" => code, "state" => site_id}) do
|
|
res = Plausible.Google.Api.fetch_access_token(code)
|
|
id_token = res["id_token"]
|
|
[_, body, _] = String.split(id_token, ".")
|
|
id = body |> Base.decode64!(padding: false) |> Jason.decode!()
|
|
|
|
Plausible.Site.GoogleAuth.changeset(%Plausible.Site.GoogleAuth{}, %{
|
|
email: id["email"],
|
|
refresh_token: res["refresh_token"],
|
|
access_token: res["access_token"],
|
|
expires: NaiveDateTime.utc_now() |> NaiveDateTime.add(res["expires_in"]),
|
|
user_id: conn.assigns[:current_user].id,
|
|
site_id: site_id
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
site = Repo.get(Plausible.Site, site_id)
|
|
|
|
redirect(conn, to: "/#{URI.encode_www_form(site.domain)}/settings/search-console")
|
|
end
|
|
end
|