From fbe99b3d3b3d0d780ae65db9b416ad7e47d07d2c Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 27 Sep 2026 08:37:50 +0000 Subject: [PATCH 1/2] Email registration is not a sign-in; a password needs a confirmed email POST /api/v1/register answered 201 with a full session while the confirmation email was still queued, as device login does, and password login worked on an address nobody had confirmed: anyone could register any address and play as it. The two flows are now separate. Device login still creates an account and signs it in. Registering creates the account, queues the email and answers 201 with the account (Registration: user_id, username, display_name, email_confirmed), never tokens. Password login, API and browser, answers 403 email_not_confirmed until the emailed link is opened, and only after the right password. The first account is confirmed as it is created, so it logs in at once. An emailed login link still confirms, as it proves the inbox. On an account registered with a password it used to raise; it now confirms and removes that password, which whoever registered the address chose before anyone proved they own it. The page the link opens says so, and the confirmation email's link keeps the password. SDKs: Godot's authenticate_register keeps no session, the C++ Auth::register_email takes a plain Callback and leaves the session alone, and Balaur's client::register_email is a REST call rather than gamend::register. --- CHANGELOG.md | 2 + apps/gamend_core/lib/gamend/accounts.ex | 20 ++++- .../lib/gamend/accounts/registration.ex | 15 +++- .../lib/gamend/accounts/sessions.ex | 34 ++++----- .../gamend_core/test/gamend/accounts_test.exs | 69 ++++++++++++++++-- .../test/gamend/password_hash_test.exs | 8 +- .../controllers/api/v1/session_controller.ex | 61 ++++++++++------ .../controllers/user_session_controller.ex | 12 +++ .../gamend_web/live/user_live/confirmation.ex | 11 +++ .../gamend_web/live/user_live/registration.ex | 4 +- .../gamend_web/lib/gamend_web/schemas/auth.ex | 48 +++++++++++- .../priv/gettext/ar/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/bg/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/cs/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/da/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/de/LC_MESSAGES/default.po | 10 +++ apps/gamend_web/priv/gettext/default.pot | 10 +++ .../priv/gettext/el/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/en/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/es/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/fi/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/fr/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/hu/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/id/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/it/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/ja/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/ko/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/nl/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/no/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/pl/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/pt/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/pt_BR/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/ro/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/ru/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/sv/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/th/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/tr/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/uk/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/vi/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/zh_CN/LC_MESSAGES/default.po | 10 +++ .../priv/gettext/zh_TW/LC_MESSAGES/default.po | 10 +++ .../api/v1/session_controller_test.exs | 73 +++++++++++++++++-- .../user_session_controller_test.exs | 65 +++++++++++++++++ .../live/user_live/confirmation_test.exs | 18 ++++- balaur_addons/addons/gamend/client.rn | 12 ++- clients/balaur_template/client.rn | 12 ++- clients/cpp_template/README.md | 7 +- clients/cpp_template/include/gamend/auth.hpp | 10 ++- clients/cpp_template/src/auth.cpp | 6 +- clients/cpp_template/tests/auth_test.cpp | 25 ++++++- clients/gamend_template/GamendApi.gd | 11 ++- clients/sdkgen/cpp.py | 5 +- .../20-authentication/10-authentication.md | 31 ++++++-- priv/docs/30-clients/10-godot-sdk.md | 12 ++- priv/docs/30-clients/25-cpp-sdk.md | 7 +- sdk/lib/gamend/accounts.ex | 42 +++++++---- 56 files changed, 806 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6db3178..a76336082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # September 2026 +- [breaking] **Registering with an email is no longer a sign-in, and a password signs in only once its email is confirmed.** `POST /api/v1/register` answered `201` with a full session while the confirmation email was still in the queue, as if it were device login, and password login worked the same on an address nobody had confirmed, so anyone could register any address and play as it. The two flows are now separate: device login still creates an account and signs it in, while registering creates the account, queues the email and answers `201` with the account under `data` (`Registration`: `user_id`, `username`, `display_name`, `email_confirmed`), never tokens. `POST /api/v1/login` answers `403 email_not_confirmed` until the emailed link is opened, and the browser password form says to confirm first; both only after the right password, so a guesser learns nothing. `Accounts.authenticate_by_password/2` returns `{:error, :email_not_confirmed}`, and `get_user_by_email_and_password/2` nil. The first account is confirmed as it is created (`email_confirmed: true`), on the API as in the browser, so it logs in at once. An emailed login link still confirms, as it proves the inbox; on an account registered with a password it used to crash, and now confirms and removes the password, which whoever registered the address chose before anyone proved they own it (the page the link opens says so; the confirmation email's link keeps it). The SDKs follow: Godot's `authenticate_register` no longer keeps a session, the C++ `Auth::register_email` takes a plain `Callback` and answers the account without touching the session, and Balaur's `client::register_email` is a REST call rather than `gamend::register`. + - [fixed] **The Hex packages compile as the version they were published as.** Each package's `mix.exs` took its version from the environment of whoever compiled it: `GAMEND_CONTENT_APP_VERSION` for gamend_core and gamend_web, `APP_VERSION` for gamend_sdk and gamend_plugin_tools. A host whose Dockerfile exports `GAMEND_CONTENT_APP_VERSION=1.0.0` (gamend_starter's did) built the engine as 1.0.0, and the SDK pair, whose `@version` publishing never stamped, built as 1.0.26 wherever `APP_VERSION` was unset. Either one failed a host's `>= 1.0.1266` requirement with "the dependency does not match the requirement". The publish job now writes the release's version into all four `mix.exs` files and removes the env lookup before anything is published; this repository's own image and docs still take the CI version from the environment. - [fixed] **`GamendWeb.BrotliCompressor` no longer fails the digest when `brotli` is not installed.** Its docs promised that a missing binary keeps the gzip, but `System.cmd/3` raises `:enoent` for a command it cannot find, so a host that lists it in `:phoenix, :static_compressors` had `mix phx.digest`, and with it `mix assets.deploy`, crash on any machine without `brotli` on `PATH`. It now looks the binary up first and returns `:error` when it is missing, so the digest writes only the `.gz` files. diff --git a/apps/gamend_core/lib/gamend/accounts.ex b/apps/gamend_core/lib/gamend/accounts.ex index 1037e6adf..92d442448 100644 --- a/apps/gamend_core/lib/gamend/accounts.ex +++ b/apps/gamend_core/lib/gamend/accounts.ex @@ -315,8 +315,9 @@ defmodule Gamend.Accounts do to: Registration @doc """ - Gets a user by email and password. `nil` for a wrong password, and for an - address locked by too many failures (`authenticate_by_password/2` says which). + Gets a user by email and password. `nil` for a wrong password, for an + address locked by too many failures, and for an email not yet confirmed + (`authenticate_by_password/2` says which). ## Examples @@ -336,15 +337,25 @@ defmodule Gamend.Accounts do end end + @typedoc "Why `authenticate_by_password/2` signed nobody in." + @type password_error() :: + :invalid_credentials | :email_not_confirmed | {:locked, pos_integer()} + @doc """ Checks an email and password, counting failures per address (`Gamend.Accounts.LoginLockouts`). `{:error, {:locked, seconds}}` when the address is locked, before the password is looked at, and for the failure that locks it. + + `{:error, :email_not_confirmed}` for the right password on an account whose + email was never confirmed. Anyone can register any address with a password, + so the password signs nobody in until the inbox's owner has confirmed it. + It is answered only after the password matched, so it tells nothing to + someone who does not know it. """ @spec authenticate_by_password(String.t(), String.t()) :: - {:ok, User.t()} | {:error, :invalid_credentials | {:locked, pos_integer()}} + {:ok, User.t()} | {:error, password_error()} def authenticate_by_password(email, password) when is_binary(email) and is_binary(password) do case LoginLockouts.check(email) do @@ -359,7 +370,8 @@ defmodule Gamend.Accounts do if User.valid_password?(user, password) do maybe_upgrade_password_hash(user, password) LoginLockouts.clear(email) - {:ok, user} + + if user.confirmed_at, do: {:ok, user}, else: {:error, :email_not_confirmed} else case LoginLockouts.record_failure(email) do :ok -> {:error, :invalid_credentials} diff --git a/apps/gamend_core/lib/gamend/accounts/registration.ex b/apps/gamend_core/lib/gamend/accounts/registration.ex index f9c7a6be5..603525e74 100644 --- a/apps/gamend_core/lib/gamend/accounts/registration.ex +++ b/apps/gamend_core/lib/gamend/accounts/registration.ex @@ -40,6 +40,15 @@ defmodule Gamend.Accounts.Registration do def maybe_make_first_user_admin(changeset, false), do: changeset + # The first account is confirmed as it is created: it gets no email to + # confirm with (there may be no mail server configured yet), and a password + # does not sign in an unconfirmed account. + defp maybe_confirm_first_user(changeset, true = _is_first_user) do + Ecto.Changeset.put_change(changeset, :confirmed_at, DateTime.utc_now(:second)) + end + + defp maybe_confirm_first_user(changeset, false), do: changeset + # When account activation is required, new non-admin users start deactivated. # The first user (admin) is always activated. @doc false @@ -105,7 +114,7 @@ defmodule Gamend.Accounts.Registration do email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), enqueued in the transaction that inserts the user: the call returns once both are committed, without waiting on SMTP, and a failed send is retried - there. The first user becomes the admin and gets no email. + there. The first user becomes the admin and is confirmed, with no email. """ @spec register_user_and_deliver(Types.user_registration_attrs(), (String.t() -> String.t())) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | term()} @@ -126,7 +135,8 @@ defmodule Gamend.Accounts.Registration do @doc """ Register a user with an email and a password and queue the confirmation email, as `register_user_and_deliver/3` does for the browser form: how a - game client signs up (`POST /api/v1/register`). + game client signs up (`POST /api/v1/register`). The password signs in once + the email is confirmed (`Gamend.Accounts.authenticate_by_password/2`). """ @spec register_user_with_password_and_deliver( Types.user_registration_attrs(), @@ -154,6 +164,7 @@ defmodule Gamend.Accounts.Registration do |> base_changeset.(attrs, opts) |> User.username_changeset(attrs) |> maybe_make_first_user_admin(is_first_user) + |> maybe_confirm_first_user(is_first_user) |> maybe_deactivate_new_user(is_first_user) end diff --git a/apps/gamend_core/lib/gamend/accounts/sessions.ex b/apps/gamend_core/lib/gamend/accounts/sessions.ex index b8d225b18..c3766d2f5 100644 --- a/apps/gamend_core/lib/gamend/accounts/sessions.ex +++ b/apps/gamend_core/lib/gamend/accounts/sessions.ex @@ -59,15 +59,18 @@ defmodule Gamend.Accounts.Sessions do 1. The user has already confirmed their email. They are logged in and the magic link is expired. - 2. The user has not confirmed their email and no password is set. - In this case, the user gets confirmed, logged in, and all tokens - - including session ones - are expired. In theory, no other tokens - exist but we delete all of them for best security practices. - - 3. The user has not confirmed their email but a password is set. - This cannot happen in the default implementation but may be the - source of security pitfalls. See the "Mixing magic link and password registration" section of - `mix help phx.gen.auth`. + 2. The user has not confirmed their email. Opening the link proves they + own the inbox, so the user gets confirmed, logged in, and all tokens - + including session ones - are expired. + + 3. As 2, with a password set: registered with one (`POST /api/v1/register`) + and never confirmed. The password is removed as the email is confirmed. + Whoever registered the address chose it before anyone proved they own + the inbox, so it may be someone else's, and kept it would sign them into + the account its owner has just claimed (the "Mixing magic link and + password registration" section of `mix help phx.gen.auth`). The owner + sets a new one in settings; the link in the confirmation email confirms + the account and keeps the password. """ @spec login_user_by_magic_link(String.t()) :: {:ok, {User.t(), [UserToken.t()]}} | {:error, :not_found | Ecto.Changeset.t() | term()} @@ -75,16 +78,6 @@ defmodule Gamend.Accounts.Sessions do {:ok, query} = UserToken.verify_magic_link_token_query(token) case Repo.one(query) do - # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password - {%User{confirmed_at: nil, hashed_password: hash}, _token} when hash != nil -> - raise """ - magic link log in is not allowed for unconfirmed users with a password set! - - This cannot happen with the default implementation, which indicates that you - might have adapted the code to a different use case. Please make sure to read the - "Mixing magic link and password registration" section of `mix help phx.gen.auth`. - """ - {%User{confirmed_at: nil} = user, _token} -> handle_unconfirmed_login(user) @@ -103,10 +96,13 @@ defmodule Gamend.Accounts.Sessions do end end + # Dropping the password is what makes confirming safe (case 3 above); a + # user without one is unchanged by it. defp handle_unconfirmed_login(user) do result = user |> User.confirm_changeset() + |> Ecto.Changeset.put_change(:hashed_password, nil) |> Accounts.update_user_and_delete_all_tokens() case result do diff --git a/apps/gamend_core/test/gamend/accounts_test.exs b/apps/gamend_core/test/gamend/accounts_test.exs index 24544351a..aef4bb8b7 100644 --- a/apps/gamend_core/test/gamend/accounts_test.exs +++ b/apps/gamend_core/test/gamend/accounts_test.exs @@ -52,6 +52,35 @@ defmodule Gamend.AccountsTest do assert %User{id: ^id} = Accounts.get_user_by_email_and_password(user.email, valid_user_password()) end + + test "does not return a user whose email is not confirmed" do + user = unconfirmed_user_fixture() |> set_password() + refute Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "authenticate_by_password/2" do + test "refuses the right password on an unconfirmed email, and says why" do + user = unconfirmed_user_fixture() |> set_password() + + assert {:error, :email_not_confirmed} = + Accounts.authenticate_by_password(user.email, valid_user_password()) + end + + test "a wrong password on an unconfirmed email is only invalid" do + user = unconfirmed_user_fixture() |> set_password() + + assert {:error, :invalid_credentials} = + Accounts.authenticate_by_password(user.email, "wrong password!") + end + + test "signs the user in once the email is confirmed" do + %{id: id} = user = unconfirmed_user_fixture() |> set_password() + {:ok, _} = Accounts.confirm_user(user) + + assert {:ok, %User{id: ^id}} = + Accounts.authenticate_by_password(user.email, valid_user_password()) + end end describe "get_user!/1" do @@ -186,8 +215,34 @@ defmodule Gamend.AccountsTest do ) assert user.is_admin + assert user.confirmed_at refute_enqueued(worker: ConfirmationMailer) end + + test "the first user registered with a password signs in with it at once" do + email = unique_user_email() + + {:ok, user} = + Accounts.register_user_with_password_and_deliver( + %{"email" => email, "password" => valid_user_password()}, + fn t -> "http://x/#{t}" end + ) + + assert user.is_admin + assert {:ok, _} = Accounts.authenticate_by_password(email, valid_user_password()) + end + + test "later users start unconfirmed" do + _existing = user_fixture() + + {:ok, user} = + Accounts.register_user_with_password_and_deliver( + valid_user_attributes(%{"password" => valid_user_password()}), + fn t -> "http://x/#{t}" end + ) + + refute user.confirmed_at + end end describe "find_or_create_from_device/2" do @@ -515,14 +570,16 @@ defmodule Gamend.AccountsTest do assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) end - test "raises when unconfirmed user has password set" do - user = unconfirmed_user_fixture() - {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) + # The password was chosen before anyone proved they own the inbox, by + # whoever registered the address: it must not survive the owner claiming it. + test "confirms an unconfirmed user with a password, and removes the password" do + user = unconfirmed_user_fixture() |> set_password() {encoded_token, _hashed_token} = generate_user_magic_link_token(user) - assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> - Accounts.login_user_by_magic_link(encoded_token) - end + assert {:ok, {user, _expired}} = Accounts.login_user_by_magic_link(encoded_token) + assert user.confirmed_at + refute user.hashed_password + refute Accounts.get_user_by_email_and_password(user.email, valid_user_password()) end end diff --git a/apps/gamend_core/test/gamend/password_hash_test.exs b/apps/gamend_core/test/gamend/password_hash_test.exs index 42cfe55f1..435dea5a0 100644 --- a/apps/gamend_core/test/gamend/password_hash_test.exs +++ b/apps/gamend_core/test/gamend/password_hash_test.exs @@ -55,10 +55,14 @@ defmodule Gamend.Accounts.PasswordHashTest do email = "legacy-#{System.unique_integer([:positive])}@example.com" {:ok, user} = Accounts.register_user(%{email: email, password: @password}) - # Put the row back the way a pre-Argon2id database would hold it. + # Put the row back the way a pre-Argon2id database would hold it, on a + # confirmed account: an unconfirmed one does not sign in by password. {:ok, user} = user - |> Ecto.Changeset.change(hashed_password: Bcrypt.hash_pwd_salt(@password)) + |> Ecto.Changeset.change( + hashed_password: Bcrypt.hash_pwd_salt(@password), + confirmed_at: DateTime.utc_now(:second) + ) |> Repo.update() assert String.starts_with?(user.hashed_password, "$2") diff --git a/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex index 51dad2475..62316df17 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex @@ -7,7 +7,7 @@ defmodule GamendWeb.Api.V1.SessionController do alias GamendWeb.Auth.Guardian alias GamendWeb.Auth.Tokens alias GamendWeb.Schemas - alias GamendWeb.Schemas.{OkResponse, SessionResponse} + alias GamendWeb.Schemas.{OkResponse, RegistrationResponse, SessionResponse} alias OpenApiSpex.Schema tags(["Authentication"]) @@ -35,7 +35,11 @@ defmodule GamendWeb.Api.V1.SessionController do responses: [ ok: {"Login successful", "application/json", SessionResponse}, unauthorized: Schemas.error("Invalid credentials"), - forbidden: Schemas.error("Account awaiting activation, or scheduled for deletion"), + forbidden: + Schemas.error( + "The email is not confirmed yet (`email_not_confirmed`), the account awaits " <> + "activation, or it is scheduled for deletion" + ), too_many_requests: Schemas.error( "Too many failed passwords for this email: password sign-in is locked for the " <> @@ -65,6 +69,16 @@ defmodule GamendWeb.Api.V1.SessionController do "Too many failed sign-in attempts. Try again later, or sign in with an emailed link." ) + {:error, :email_not_confirmed} -> + reply_error( + conn, + :forbidden, + "email_not_confirmed", + "Confirm your email address with the link we sent to it, then log in again. " <> + "If the link has expired, sign in on the website with an emailed login link, " <> + "then set a new password in your account settings." + ) + {:error, :invalid_credentials} -> reply_error(conn, :unauthorized, "invalid_credentials", "Invalid email or password") end @@ -74,12 +88,16 @@ defmodule GamendWeb.Api.V1.SessionController do operation_id: "register", summary: "Register", description: - "Create an account with an email and a password, queue its confirmation email " <> - "as browser sign-up does, and sign it in: the tokens come back as from login. " <> + "Create an account with an email and a password and queue its confirmation email, " <> + "as browser sign-up does. Registering is not a sign-in: it answers the new account, " <> + "never tokens. The password signs in with `login` once the player has opened the " <> + "emailed link; until then `login` answers `403 email_not_confirmed`. " <> "The response does not wait for the email, which is sent and retried in the background. " <> - "The first account becomes the admin and is confirmed without an email; account " <> - "activation applies as for every sign-up. When the server requires it " <> - "(`GAMEND_CAPTCHA_API_REGISTER`), a Cloudflare Turnstile token goes in `captcha_token`.", + "The server's first account becomes the admin and is confirmed without an email " <> + "(`email_confirmed: true`), so it can log in at once. Account activation " <> + "(`GAMEND_AUTH_REQUIRE_ACTIVATION`) applies at login, as for every sign-up. " <> + "When the server requires it (`GAMEND_CAPTCHA_API_REGISTER`), a Cloudflare " <> + "Turnstile token goes in `captcha_token`.", request_body: { "Registration", "application/json", @@ -105,12 +123,11 @@ defmodule GamendWeb.Api.V1.SessionController do } }, responses: [ - created: {"Account created and signed in", "application/json", SessionResponse}, + created: {"Account created; not signed in", "application/json", RegistrationResponse}, bad_request: Schemas.error("Email or password missing (missing_param)"), forbidden: Schemas.error( - "The account awaits activation by an admin, the captcha failed, or a plugin " <> - "refused the sign-up (registration_refused)" + "The captcha failed, or a plugin refused the sign-up (registration_refused)" ), conflict: Schemas.error("Email or username already taken"), unprocessable_entity: Schemas.error("Invalid email, username or password"), @@ -154,17 +171,17 @@ defmodule GamendWeb.Api.V1.SessionController do reply_error(conn, :bad_request, "missing_param", "email and password are required") end + # Not a sign-in, unlike device login, which creates an account and signs it + # in at once: registering proves nothing about the inbox, and a token here + # would let anyone play as any address. The password signs in through + # `create/2` once the email is confirmed. defp registered({:ok, user}, conn) do - if Accounts.user_activated?(user) do - conn |> put_status(:created) |> issue_tokens(user) - else - reply_error( - conn, - :forbidden, - "account_not_activated", - "Your account is pending activation by an administrator." - ) - end + reply_data(conn, :created, %{ + user_id: user.id, + username: user.username || "", + display_name: user.display_name || "", + email_confirmed: not is_nil(user.confirmed_at) + }) end defp registered({:error, %Ecto.Changeset{} = changeset}, conn) do @@ -334,8 +351,8 @@ defmodule GamendWeb.Api.V1.SessionController do :ok end - # Only real logins reach here (password, device and registration); `refresh/2` - # keeps its refresh token. Provider sign-ins go through the same + # Only real logins reach here (password and device; registering is not one); + # `refresh/2` keeps its refresh token. Provider sign-ins go through the same # `Tokens.sign_in/1`. defp issue_tokens(conn, user), do: reply_data(conn, Tokens.sign_in(user)) end diff --git a/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex index d1f31ea16..644e123f9 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex @@ -69,6 +69,18 @@ defmodule GamendWeb.UserSessionController do |> put_flash(:email, String.slice(email, 0, Gamend.Limits.get(:max_email))) |> redirect(to: ~p"/users/log_in") + # Only after the right password, so it reveals nothing to a guesser. + {:error, :email_not_confirmed} -> + conn + |> put_flash( + :error, + gettext( + "Confirm your email first with the link we sent you, or log in with an emailed link." + ) + ) + |> put_flash(:email, String.slice(email, 0, Gamend.Limits.get(:max_email))) + |> redirect(to: ~p"/users/log_in") + {:error, :invalid_credentials} -> # In order to prevent user enumeration attacks, don't disclose whether the email is registered. conn diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/confirmation.ex b/apps/gamend_web/lib/gamend_web/live/user_live/confirmation.ex index c67de27aa..60dac49d4 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/confirmation.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/confirmation.ex @@ -22,6 +22,17 @@ defmodule GamendWeb.UserLive.Confirmation do phx-trigger-action={@trigger_submit} > + <%!-- Confirming by magic link drops a password set before the email + was confirmed (Accounts.login_user_by_magic_link/1). --%> +

+ {gettext( + "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." + )} +

<.button name={@form[:remember_me].name} value="true" diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex b/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex index 929709ec8..878852c8c 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex @@ -106,9 +106,7 @@ defmodule GamendWeb.UserLive.Registration do is_first_user = user.is_admin if is_first_user do - # First user: auto-confirm and auto-login - {:ok, user} = Accounts.confirm_user(user) - + # First user: registered confirmed, and logged in straight away. # Generate a magic link token for auto-login {token, user_token} = UserToken.build_email_token(user, "login") Repo.insert!(user_token) diff --git a/apps/gamend_web/lib/gamend_web/schemas/auth.ex b/apps/gamend_web/lib/gamend_web/schemas/auth.ex index 4acc67b4b..c4e98ff17 100644 --- a/apps/gamend_web/lib/gamend_web/schemas/auth.ex +++ b/apps/gamend_web/lib/gamend_web/schemas/auth.ex @@ -1,7 +1,8 @@ defmodule GamendWeb.Schemas.Session do @moduledoc """ - A signed-in session: what every sign-in (email, device, registration, a - provider) and a refresh answer under `data`. + A signed-in session: what every sign-in (email, device, a provider) and a + refresh answer under `data`. Registering is not a sign-in + (`GamendWeb.Schemas.Registration`). """ require OpenApiSpex alias OpenApiSpex.Schema @@ -43,6 +44,49 @@ defmodule GamendWeb.Schemas.SessionResponse do use GamendWeb.Schemas.Envelope, data: GamendWeb.Schemas.Session end +defmodule GamendWeb.Schemas.Registration do + @moduledoc """ + The account `POST /api/v1/register` created. Not a session: registering + signs nobody in, and the password signs in with `POST /api/v1/login` once + the email is confirmed. + """ + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Registration", + description: "An account just created with an email and a password", + type: :object, + properties: %{ + user_id: %Schema{type: :string, format: :uuid}, + username: %Schema{ + type: :string, + description: "Unique handle, generated when none was given" + }, + display_name: %Schema{type: :string, description: "Chosen name"}, + email_confirmed: %Schema{ + type: :boolean, + description: + "False until the player opens the emailed link; login answers " <> + "`403 email_not_confirmed` until then. True only for the server's first " <> + "account, the admin, which is confirmed without an email" + } + }, + required: [:user_id, :username, :display_name, :email_confirmed], + example: %{ + user_id: "0198c0de-0002-7000-8000-000000000002", + username: "coolplayer-1234", + display_name: "", + email_confirmed: false + } + }) +end + +defmodule GamendWeb.Schemas.RegistrationResponse do + @moduledoc "A new account under `data`." + use GamendWeb.Schemas.Envelope, data: GamendWeb.Schemas.Registration +end + defmodule GamendWeb.Schemas.OAuthAuthorization do @moduledoc "Where to send the player to sign in, and the session to poll for the result." require OpenApiSpex diff --git a/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po index 91c8aae23..c2876d86d 100644 --- a/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po @@ -3434,3 +3434,13 @@ msgstr[2] "" msgstr[3] "" msgstr[4] "" msgstr[5] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "أكّد بريدك الإلكتروني أولاً عبر الرابط الذي أرسلناه إليك، أو سجّل الدخول برابط يصلك عبر البريد الإلكتروني." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "التأكيد عبر هذا الرابط يزيل كلمة المرور التي سُجّل بها هذا الحساب، لذا عيّن كلمة مرور جديدة من إعدادات حسابك بعد ذلك. أما الرابط في رسالة التأكيد فيُبقي عليها." diff --git a/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po index 14004090b..f223b7948 100644 --- a/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Първо потвърди имейла си с връзката, която ти изпратихме, или влез с връзка по имейл." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Потвърждаването с тази връзка премахва паролата, с която е регистриран акаунтът, затова после задай нова в настройките на акаунта си. Връзката в имейла за потвърждение я запазва." diff --git a/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po index 18ca54d7e..b96401a34 100644 --- a/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po @@ -3287,3 +3287,13 @@ msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" msgstr[2] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Nejdřív potvrď svůj e-mail odkazem, který jsme ti poslali, nebo se přihlas odkazem z e-mailu." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Potvrzení tímto odkazem odstraní heslo, se kterým byl účet zaregistrován, takže si pak nastav nové v nastavení účtu. Odkaz v potvrzovacím e-mailu ho zachová." diff --git a/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po index ebb8395e3..74dee3f6d 100644 --- a/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Bekræft først din e-mail med linket, vi sendte dig, eller log ind med et link på e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Bekræftelse med dette link fjerner den adgangskode, kontoen blev oprettet med, så vælg en ny i dine kontoindstillinger bagefter. Linket i bekræftelsesmailen beholder den." diff --git a/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po index cd85f7d08..52760f498 100644 --- a/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Bestätige zuerst deine E-Mail mit dem Link, den wir dir geschickt haben, oder melde dich mit einem Anmeldelink per E-Mail an." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Die Bestätigung über diesen Link entfernt das Passwort, mit dem dieses Konto registriert wurde. Lege danach in deinen Kontoeinstellungen ein neues fest. Der Link in der Bestätigungs-E-Mail behält es bei." diff --git a/apps/gamend_web/priv/gettext/default.pot b/apps/gamend_web/priv/gettext/default.pot index 51f7aeecc..c422ad745 100644 --- a/apps/gamend_web/priv/gettext/default.pot +++ b/apps/gamend_web/priv/gettext/default.pot @@ -3243,3 +3243,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "" diff --git a/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po index 84c50442a..d9ad8cc17 100644 --- a/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Επιβεβαιώστε πρώτα το email σας με τον σύνδεσμο που σας στείλαμε ή συνδεθείτε με έναν σύνδεσμο μέσω email." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Η επιβεβαίωση με αυτόν τον σύνδεσμο αφαιρεί το συνθηματικό με το οποίο καταχωρίστηκε ο λογαριασμός, οπότε ορίστε ένα νέο στις ρυθμίσεις του λογαριασμού σας μετά. Ο σύνδεσμος στο email επιβεβαίωσης το διατηρεί." diff --git a/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po index 580a03c4e..fcb92c7b3 100644 --- a/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po @@ -3240,3 +3240,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "" diff --git a/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po index 8807a4fc7..304914e67 100644 --- a/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po @@ -3231,3 +3231,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Primero confirma tu correo con el enlace que te enviamos, o inicia sesión con un enlace por correo." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confirmar con este enlace elimina la contraseña con la que se registró esta cuenta, así que después define una nueva en los ajustes de tu cuenta. El enlace del correo de confirmación la conserva." diff --git a/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po index 26bab8798..ca847a83a 100644 --- a/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Vahvista ensin sähköpostiosoitteesi lähettämällämme linkillä tai kirjaudu sisään sähköpostiin lähetettävällä linkillä." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Tällä linkillä vahvistaminen poistaa salasanan, jolla tili rekisteröitiin, joten aseta sen jälkeen uusi tilisi asetuksissa. Vahvistussähköpostin linkki säilyttää sen." diff --git a/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po index 3eeb16c1d..19832ed4f 100644 --- a/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Confirmez d'abord votre adresse e-mail avec le lien que nous vous avons envoyé, ou connectez-vous avec un lien reçu par e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confirmer avec ce lien supprime le mot de passe avec lequel ce compte a été créé : définissez-en un nouveau ensuite dans les paramètres de votre compte. Le lien de l'e-mail de confirmation le conserve." diff --git a/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po index 80f4b151c..2786e0922 100644 --- a/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po @@ -3239,3 +3239,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Előbb erősítsd meg az e-mail-címedet az általunk küldött linkkel, vagy jelentkezz be egy e-mailben kapott linkkel." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Az ezzel a linkkel történő megerősítés törli a jelszót, amellyel a fiókot regisztrálták, ezért utána állíts be újat a fiókbeállításaidban. A megerősítő e-mailben lévő link megtartja." diff --git a/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po index cead0b512..839b3c50e 100644 --- a/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Konfirmasikan email Anda terlebih dahulu dengan tautan yang kami kirim, atau masuk dengan tautan melalui email." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Mengonfirmasi dengan tautan ini akan menghapus kata sandi yang dipakai saat akun ini didaftarkan, jadi setel yang baru di pengaturan akun Anda setelahnya. Tautan di email konfirmasi mempertahankannya." diff --git a/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po index 40d28a17d..9be0e48a9 100644 --- a/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po @@ -3239,3 +3239,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Conferma prima la tua email con il link che ti abbiamo inviato, oppure accedi con un link via email." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confermare con questo link rimuove la password con cui è stato registrato l'account, quindi impostane una nuova nelle impostazioni dell'account. Il link nell'email di conferma la mantiene." diff --git a/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po index 3c6bd734a..ec678f9bf 100644 --- a/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "まず送信したリンクでメールアドレスを確認するか、メールで届くログインリンクでログインしてください。" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "このリンクで確認すると、このアカウントの登録時に設定されたパスワードは削除されます。確認後にアカウント設定で新しいパスワードを設定してください。確認メールのリンクを使うとパスワードは保持されます。" diff --git a/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po index 3072e2c4d..c0a328d3e 100644 --- a/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "먼저 보내 드린 링크로 이메일을 인증하거나, 이메일로 받은 링크로 로그인하세요." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "이 링크로 인증하면 이 계정을 등록할 때 설정한 비밀번호가 삭제되므로, 이후 계정 설정에서 새 비밀번호를 설정하세요. 인증 이메일의 링크를 사용하면 비밀번호가 유지됩니다." diff --git a/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po index 65ab1c41b..3e86927d6 100644 --- a/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Bevestig eerst je e-mailadres met de link die we je stuurden, of log in met een link per e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Bevestigen met deze link verwijdert het wachtwoord waarmee dit account is geregistreerd, dus stel daarna een nieuw in bij je accountinstellingen. De link in de bevestigingsmail behoudt het." diff --git a/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po index fb3a0c17b..63180d225 100644 --- a/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Bekreft e-postadressen din først med lenken vi sendte deg, eller logg inn med en lenke på e-post." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Bekrefter du med denne lenken, fjernes passordet kontoen ble registrert med, så velg et nytt i kontoinnstillingene dine etterpå. Lenken i bekreftelsese-posten beholder det." diff --git a/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po index 016d5d666..0af309520 100644 --- a/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po @@ -3287,3 +3287,13 @@ msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" msgstr[2] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Najpierw potwierdź e-mail linkiem, który ci wysłaliśmy, albo zaloguj się linkiem z e-maila." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Potwierdzenie tym linkiem usuwa hasło, z którym zarejestrowano to konto, więc potem ustaw nowe w ustawieniach konta. Link z e-maila potwierdzającego je zachowuje." diff --git a/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po index 906c407a2..f2d955430 100644 --- a/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Confirma primeiro o teu e-mail com o link que te enviámos, ou inicia sessão com um link por e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confirmar com este link remove a palavra-passe com que esta conta foi registada, por isso define uma nova nas definições da tua conta depois. O link do e-mail de confirmação mantém-na." diff --git a/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po index 25e469ca2..fedb9ec65 100644 --- a/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Confirme primeiro seu e-mail com o link que enviamos, ou entre com um link por e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confirmar com este link remove a senha com que esta conta foi registrada, então defina uma nova nas configurações da sua conta depois. O link do e-mail de confirmação a mantém." diff --git a/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po index 1013e10ec..65cb444e8 100644 --- a/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po @@ -3287,3 +3287,13 @@ msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" msgstr[2] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Confirmă-ți mai întâi e-mailul cu linkul pe care ți l-am trimis sau conectează-te cu un link primit pe e-mail." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Confirmarea cu acest link elimină parola cu care a fost înregistrat contul, așa că setează apoi una nouă în setările contului. Linkul din e-mailul de confirmare o păstrează." diff --git a/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po index 5b367dacc..dad4e7067 100644 --- a/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po @@ -3287,3 +3287,13 @@ msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" msgstr[2] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Сначала подтвердите адрес по ссылке, которую мы отправили, или войдите по ссылке из письма." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Подтверждение по этой ссылке удаляет пароль, с которым был зарегистрирован аккаунт, поэтому затем задайте новый в настройках аккаунта. Ссылка из письма с подтверждением его сохраняет." diff --git a/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po index 6e28f3663..b38bf9b97 100644 --- a/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Bekräfta först din e-post med länken vi skickade, eller logga in med en länk via e-post." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Att bekräfta med den här länken tar bort lösenordet som kontot registrerades med, så välj ett nytt i dina kontoinställningar efteråt. Länken i bekräftelsemejlet behåller det." diff --git a/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po index 52d604eab..718c21805 100644 --- a/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "ยืนยันอีเมลของคุณก่อนด้วยลิงก์ที่เราส่งให้ หรือเข้าสู่ระบบด้วยลิงก์ทางอีเมล" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "การยืนยันด้วยลิงก์นี้จะลบรหัสผ่านที่ใช้ลงทะเบียนบัญชีนี้ ดังนั้นให้ตั้งรหัสผ่านใหม่ในการตั้งค่าบัญชีภายหลัง ส่วนลิงก์ในอีเมลยืนยันจะเก็บรหัสผ่านไว้" diff --git a/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po index 512a27aa0..88f7c86e3 100644 --- a/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po @@ -3238,3 +3238,13 @@ msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Önce sana gönderdiğimiz bağlantıyla e-postanı onayla ya da e-postayla gelen bir bağlantıyla giriş yap." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Bu bağlantıyla onaylamak, hesabın kaydedildiği parolayı kaldırır; bu yüzden ardından hesap ayarlarından yeni bir parola belirle. Onay e-postasındaki bağlantı parolayı korur." diff --git a/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po index 51d73add5..39b2a2145 100644 --- a/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po @@ -3287,3 +3287,13 @@ msgid_plural "latest %{count} runs" msgstr[0] "" msgstr[1] "" msgstr[2] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Спершу підтвердьте адресу за посиланням, яке ми надіслали, або увійдіть за посиланням з листа." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Підтвердження за цим посиланням видаляє пароль, з яким зареєстровано обліковий запис, тож потім задайте новий у налаштуваннях облікового запису. Посилання з листа підтвердження його зберігає." diff --git a/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po index 7ded4ce8d..edf28efff 100644 --- a/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "Hãy xác nhận email trước bằng liên kết chúng tôi đã gửi, hoặc đăng nhập bằng liên kết qua email." + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "Xác nhận bằng liên kết này sẽ xóa mật khẩu mà tài khoản này đã dùng khi đăng ký, vì vậy hãy đặt mật khẩu mới trong phần cài đặt tài khoản sau đó. Liên kết trong email xác nhận sẽ giữ lại mật khẩu." diff --git a/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po index cea044b5d..f6453402b 100644 --- a/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "请先通过我们发送的链接确认邮箱,或使用邮件中的登录链接登录。" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "通过此链接确认会删除注册此账户时设置的密码,请在确认后到账户设置中设置新密码。使用确认邮件中的链接则会保留密码。" diff --git a/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po index 4e8abf3e0..6310e6ef2 100644 --- a/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po @@ -3189,3 +3189,13 @@ msgstr "" msgid "latest %{count} run" msgid_plural "latest %{count} runs" msgstr[0] "" + +#: lib/gamend_web/controllers/user_session_controller.ex:77 +#, elixir-autogen, elixir-format +msgid "Confirm your email first with the link we sent you, or log in with an emailed link." +msgstr "請先透過我們寄出的連結確認電子郵件,或使用電子郵件中的登入連結登入。" + +#: lib/gamend_web/live/user_live/confirmation.ex:32 +#, elixir-autogen, elixir-format +msgid "Confirming with this link removes the password this account was registered with, so set a new one in your account settings afterwards. The link in the confirmation email keeps it." +msgstr "透過此連結確認會移除註冊此帳戶時設定的密碼,請在確認後到帳戶設定中設定新密碼。使用確認電子郵件中的連結則會保留密碼。" diff --git a/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs b/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs index 69247fa23..c42b0f6b7 100644 --- a/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs @@ -52,6 +52,26 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do assert access_token != refresh_token end + test "returns 403 email_not_confirmed for the right password on an unconfirmed email", %{ + conn: conn, + user: user + } do + user |> Ecto.Changeset.change(confirmed_at: nil) |> Repo.update!() + + conn = post(conn, "/api/v1/login", %{email: @valid_email, password: @valid_password}) + + assert %{"error" => "email_not_confirmed", "message" => _} = json_response(conn, 403) + refute json_response(conn, 403)["data"] + end + + test "a wrong password on an unconfirmed email is still 401", %{conn: conn, user: user} do + user |> Ecto.Changeset.change(confirmed_at: nil) |> Repo.update!() + + conn = post(conn, "/api/v1/login", %{email: @valid_email, password: "wrong password!"}) + + assert json_response(conn, 401)["error"] == "invalid_credentials" + end + test "returns 401 with invalid credentials", %{conn: conn} do conn = post(conn, "/api/v1/login", %{ @@ -289,18 +309,48 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do :ok end - test "creates an account and signs it in, and its password logs in", %{conn: conn} do + test "creates an account without signing it in; its password logs in once confirmed", %{ + conn: conn + } do created = post(conn, "/api/v1/register", %{email: "new@example.com", password: @valid_password}) - assert %{"data" => %{"access_token" => token, "user_id" => user_id}} = + assert %{"data" => %{"user_id" => user_id, "email_confirmed" => false} = data} = + json_response(created, 201) + + refute Map.has_key?(data, "access_token") + refute Map.has_key?(data, "refresh_token") + + login = fn -> + post(build_conn(), "/api/v1/login", %{email: "new@example.com", password: @valid_password}) + end + + assert json_response(login.(), 403)["error"] == "email_not_confirmed" + + {:ok, _} = Gamend.Accounts.confirm_user(Repo.get!(User, user_id)) + + assert json_response(login.(), 200)["data"]["user_id"] == user_id + end + + test "the first account is the admin, confirmed, and logs in at once", %{ + conn: conn, + user: user + } do + Repo.delete!(user) + + created = + post(conn, "/api/v1/register", %{email: "first@example.com", password: @valid_password}) + + assert %{"data" => %{"user_id" => user_id, "email_confirmed" => true} = data} = json_response(created, 201) - assert is_binary(token) + refute Map.has_key?(data, "access_token") + assert %User{is_admin: true} = Repo.get(User, user_id) + refute_enqueued(worker: Gamend.Accounts.ConfirmationMailer) login = post(build_conn(), "/api/v1/login", %{ - email: "new@example.com", + email: "first@example.com", password: @valid_password }) @@ -387,8 +437,19 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do pending = post(conn, "/api/v1/register", %{email: "beta@example.com", password: @valid_password}) - assert json_response(pending, 403)["error"] == "account_not_activated" - assert %User{is_activated: false} = Repo.get_by(User, email: "beta@example.com") + assert json_response(pending, 201)["data"]["email_confirmed"] == false + assert %User{is_activated: false} = user = Repo.get_by(User, email: "beta@example.com") + + # Confirming the email is not activation: that stays an admin's call. + {:ok, _} = Gamend.Accounts.confirm_user(user) + + login = + post(build_conn(), "/api/v1/login", %{ + email: "beta@example.com", + password: @valid_password + }) + + assert json_response(login, 403)["error"] == "account_not_activated" end end diff --git a/apps/gamend_web/test/gamend_web/controllers/user_session_controller_test.exs b/apps/gamend_web/test/gamend_web/controllers/user_session_controller_test.exs index 51fd13fba..27add50ee 100644 --- a/apps/gamend_web/test/gamend_web/controllers/user_session_controller_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/user_session_controller_test.exs @@ -60,6 +60,36 @@ defmodule GamendWeb.UserSessionControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Success." end + test "refuses the right password until the email is confirmed", %{ + conn: conn, + unconfirmed_user: user + } do + user = set_password(user) + + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + refute get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/users/log_in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Confirm your email first" + end + + test "a wrong password on an unconfirmed email says only Failed", %{ + conn: conn, + unconfirmed_user: user + } do + user = set_password(user) + + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed" + end + test "redirects to login page with invalid credentials", %{conn: conn, user: user} do conn = post(conn, ~p"/users/log_in?mode=password", %{ @@ -113,6 +143,23 @@ defmodule GamendWeb.UserSessionControllerTest do assert response =~ ~p"/users/log_out" end + test "confirms an unconfirmed user with a password, and removes the password", %{ + conn: conn, + unconfirmed_user: user + } do + user = set_password(user) + {token, _hashed_token} = generate_user_magic_link_token(user) + + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"token" => token}, + "_action" => "confirmed" + }) + + assert get_session(conn, :user_token) + assert %{confirmed_at: %DateTime{}, hashed_password: nil} = Accounts.get_user!(user.id) + end + test "redirects to login page when magic link is invalid", %{conn: conn} do conn = post(conn, ~p"/users/log_in", %{ @@ -142,6 +189,24 @@ defmodule GamendWeb.UserSessionControllerTest do assert Accounts.get_user!(user.id).confirmed_at end + test "GET /users/confirm/:token keeps the password, which then logs in", %{ + conn: conn, + unconfirmed_user: user + } do + user = set_password(user) + {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "confirm") + Gamend.Repo.insert!(user_token) + + get(conn, ~p"/users/confirm/#{encoded_token}") + + conn = + post(build_conn(), ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + end + test "GET /users/confirm/:token handles invalid token", %{conn: conn} do conn = get(conn, ~p"/users/confirm/invalid-token") diff --git a/apps/gamend_web/test/gamend_web/live/user_live/confirmation_test.exs b/apps/gamend_web/test/gamend_web/live/user_live/confirmation_test.exs index 1184ee9f4..e5b06e7cb 100644 --- a/apps/gamend_web/test/gamend_web/live/user_live/confirmation_test.exs +++ b/apps/gamend_web/test/gamend_web/live/user_live/confirmation_test.exs @@ -17,8 +17,24 @@ defmodule GamendWeb.UserLive.ConfirmationTest do Accounts.deliver_login_instructions(user, url) end) - {:ok, _lv, html} = live(conn, ~p"/users/log_in/#{token}") + {:ok, lv, html} = live(conn, ~p"/users/log_in/#{token}") assert html =~ "Confirm" + refute has_element?(lv, "#confirmation-password-notice") + end + + test "says a password set before confirming goes away", %{ + conn: conn, + unconfirmed_user: user + } do + user = set_password(user) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in/#{token}") + assert has_element?(lv, "#confirmation_form #confirmation-password-notice") end test "renders login page for confirmed user", %{conn: conn, confirmed_user: user} do diff --git a/balaur_addons/addons/gamend/client.rn b/balaur_addons/addons/gamend/client.rn index af13212eb..fbd06cfb7 100644 --- a/balaur_addons/addons/gamend/client.rn +++ b/balaur_addons/addons/gamend/client.rn @@ -41,15 +41,19 @@ pub fn login_email(node, email, password) { return gamend::login(node, #{ "email": email, "password": password }); } -/// Make an account from an email and a password and open its session. The -/// reply reaches `node`'s handler as `kind = "login"`; `username` may be nil, -/// and the server picks one. +/// Make an account from an email and a password. Not a sign-in, as +/// `login_device` is: no session opens, and the reply, a REST call's like any +/// other (await the returned id, then `gamend::core::unpack`), carries the +/// account, whose `email_confirmed` says whether it can log in yet. Its +/// password opens a session with `login_email` once the player opens the +/// emailed link; until then that answers `email_not_confirmed`. `username` +/// may be nil, and the server picks one. pub fn register_email(node, email, password, username) { let account = #{ "email": email, "password": password }; if username is String { account["username"] = username; } - return gamend::register(node, account); + return gamend::authentication::register(node, account); } /// Open the realtime socket. The worker joins this session's own `user:` diff --git a/clients/balaur_template/client.rn b/clients/balaur_template/client.rn index af13212eb..fbd06cfb7 100644 --- a/clients/balaur_template/client.rn +++ b/clients/balaur_template/client.rn @@ -41,15 +41,19 @@ pub fn login_email(node, email, password) { return gamend::login(node, #{ "email": email, "password": password }); } -/// Make an account from an email and a password and open its session. The -/// reply reaches `node`'s handler as `kind = "login"`; `username` may be nil, -/// and the server picks one. +/// Make an account from an email and a password. Not a sign-in, as +/// `login_device` is: no session opens, and the reply, a REST call's like any +/// other (await the returned id, then `gamend::core::unpack`), carries the +/// account, whose `email_confirmed` says whether it can log in yet. Its +/// password opens a session with `login_email` once the player opens the +/// emailed link; until then that answers `email_not_confirmed`. `username` +/// may be nil, and the server picks one. pub fn register_email(node, email, password, username) { let account = #{ "email": email, "password": password }; if username is String { account["username"] = username; } - return gamend::register(node, account); + return gamend::authentication::register(node, account); } /// Open the realtime socket. The worker joins this session's own `user:` diff --git a/clients/cpp_template/README.md b/clients/cpp_template/README.md index ac6351f23..f98f6b687 100644 --- a/clients/cpp_template/README.md +++ b/clients/cpp_template/README.md @@ -137,7 +137,7 @@ and never `operator[]` on a *const* json with a key that may be missing. ## Sessions `Auth` signs in and keeps the session: `login_device`, `login_email`, -`register_email`, `login_steam` (an `ISteamUser::GetAuthTicketForWebApi` +`login_steam` (an `ISteamUser::GetAuthTicketForWebApi` ticket), and `sign_in(provider)`, which opens the provider's page through `config.open_url` and polls until the player finishes. Every call after that carries the access token. It is refreshed when three quarters of its 15 @@ -148,6 +148,11 @@ Signing in never links. To add a provider to the signed-in account, `link(provider)` opens its page and polls as `sign_in` does, and `link_steam(ticket)` links Steam directly; the session stays as it is. +Registering is not a sign-in either. `register_email(email, password)` makes +the account and answers it (`models::Registration`); its password signs in +with `login_email` once the player opens the emailed link, and fails with +`email_not_confirmed` until then. + Keep the session between runs where your platform keeps secrets: ```cpp diff --git a/clients/cpp_template/include/gamend/auth.hpp b/clients/cpp_template/include/gamend/auth.hpp index f10671d5e..a139a3e0e 100644 --- a/clients/cpp_template/include/gamend/auth.hpp +++ b/clients/cpp_template/include/gamend/auth.hpp @@ -50,10 +50,14 @@ class Auth { /// has device sign-in enabled (its default). void login_device(std::string device_id, AuthCallback done = {}); void login_email(std::string email, std::string password, AuthCallback done = {}); - /// A new account with an email and a password, signed in as it is made. - /// The server picks a username when `username` is empty. + /// A new account with an email and a password. Not a sign-in, as + /// `login_device` is: the session does not change, and `done` gets the new + /// account (`models::Registration`). Its password signs in with + /// `login_email` once the player opens the emailed link; until then that + /// fails with `email_not_confirmed`. The server picks a username when + /// `username` is empty. void register_email(std::string email, std::string password, std::string username = {}, - AuthCallback done = {}); + Callback done = {}); /// A Steam session ticket (`ISteamUser::GetAuthTicketForWebApi`, hex). void login_steam(std::string ticket, AuthCallback done = {}); /// Sign in through a provider (`google`, `discord`, `apple`, ...): opens diff --git a/clients/cpp_template/src/auth.cpp b/clients/cpp_template/src/auth.cpp index 652c1f434..6bb758c11 100644 --- a/clients/cpp_template/src/auth.cpp +++ b/clients/cpp_template/src/auth.cpp @@ -61,11 +61,13 @@ void Auth::login_email(std::string email, std::string password, AuthCallback don std::move(done)); } +// Not `sign_in_with`: registering answers the account, never tokens. void Auth::register_email(std::string email, std::string password, std::string username, - AuthCallback done) { + Callback done) { json params = {{"email", std::move(email)}, {"password", std::move(password)}}; if (!username.empty()) params["username"] = std::move(username); - sign_in_with("/api/v1/register", Body::of(std::move(params)), std::move(done)); + core_.rest.send_anonymous("POST", "/api/v1/register", Body::of(std::move(params)), + std::move(done)); } void Auth::login_steam(std::string ticket, AuthCallback done) { diff --git a/clients/cpp_template/tests/auth_test.cpp b/clients/cpp_template/tests/auth_test.cpp index 0e089fe64..fa373a27e 100644 --- a/clients/cpp_template/tests/auth_test.cpp +++ b/clients/cpp_template/tests/auth_test.cpp @@ -55,19 +55,38 @@ TEST_CASE("a refused sign-in says why and keeps nothing") { TEST_CASE("register sends a username only when there is one") { Harness h; - SeenAuth seen; + Seen seen; h.client->auth().register_email("ann@example.com", "secret-pass", {}, seen.callback()); auto plain = h.server->next(); CHECK(plain.url == "http://game.test/api/v1/register"); CHECK(json::parse(plain.body) == json{{"email", "ann@example.com"}, {"password", "secret-pass"}}); - h.server->reply(201, harness::session_reply()); + CHECK(FakeHttp::header(plain, "authorization").empty()); + h.server->reply( + 201, R"({"data": {"user_id": "u1", "username": "ann", "display_name": "", "email_confirmed": false}})"); h.client->poll(); - CHECK(seen.last.ok); + CHECK(seen.calls == 1); + CHECK(seen.last.ok()); + CHECK(seen.last.data()["email_confirmed"] == false); h.client->auth().register_email("bob@example.com", "secret-pass", "bob", nullptr); CHECK(json::parse(h.server->next().body)["username"] == "bob"); } +TEST_CASE("registering is not a sign-in") { + Harness h; + h.signed_in(); + Seen seen; + h.client->auth().register_email("ann@example.com", "secret-pass", {}, seen.callback()); + h.server->next(); + // Even an answer that carried tokens would not be taken as a session. + h.server->reply(201, harness::session_reply(2)); + h.client->poll(); + h.client->poll(); + CHECK(seen.calls == 1); + CHECK(h.client->auth().session()->access_token == "a1"); + CHECK(h.changes.empty()); +} + TEST_CASE("Steam signs in with a ticket; an answer with no token signs nobody in") { Harness h; h.signed_in(); diff --git a/clients/gamend_template/GamendApi.gd b/clients/gamend_template/GamendApi.gd index 166c4ae1b..fb1e1f52a 100644 --- a/clients/gamend_template/GamendApi.gd +++ b/clients/gamend_template/GamendApi.gd @@ -484,7 +484,8 @@ func _schedule_token_refresh() -> void: _refresh_timer.start() func _verify_login_result(method_name: String, data): - if data && method_name in ["oauth_session_status", "oauth_api_callback", "login", "register", "device_login", "refresh_token", "oauth_callback_api_apple_ios", "oauth_google_id_token"]: + # Not "register": registering answers the new account, never a session. + if data && method_name in ["oauth_session_status", "oauth_api_callback", "login", "device_login", "refresh_token", "oauth_callback_api_apple_ios", "oauth_google_id_token"]: # Every answer is {data: ...}; a polled OAuth sign-in carries its tokens # one level further in, under data.session (null until it completes). var inner = data.bzz_normalize().get("data") @@ -1061,8 +1062,12 @@ func authenticate_refresh_token(refresh_token: String) -> GamendResult: refresh_param.refresh_token = refresh_token return await _call_api(AuthenticationApi.new(_config), "refresh_token", [refresh_param]) -## Register: a new account with an email and a password, signed in as it is -## made. The server generates a username when none is given. +## Register: a new account with an email and a password. Not a sign-in, as +## device login is: the answer is the account (`GamendRegistration`, with +## `email_confirmed`), and no session is kept. Its password signs in with +## `authenticate_login` once the player opens the emailed link; until then that +## answers the error `email_not_confirmed`. The server generates a username when +## none is given. func authenticate_register(email: String, password: String, username := "") -> GamendResult: var register_request := GamendRegisterRequest.new() register_request.email = email diff --git a/clients/sdkgen/cpp.py b/clients/sdkgen/cpp.py index 1e87c4591..7779b04f6 100644 --- a/clients/sdkgen/cpp.py +++ b/clients/sdkgen/cpp.py @@ -44,9 +44,10 @@ } # Operations that answer a session. `Auth` makes these calls and keeps what -# they answer; the generated method only makes the call. +# they answer; the generated method only makes the call. Not `register`: it +# answers the new account, and signs nobody in. SESSION_OPS = { - "device_login", "login", "register", "refresh_token", "oauth_api_callback", + "device_login", "login", "refresh_token", "oauth_api_callback", "oauth_google_id_token", "oauth_callback_api_apple_ios", "oauth_session_status", } diff --git a/priv/docs/20-authentication/10-authentication.md b/priv/docs/20-authentication/10-authentication.md index 8038861cc..f34788943 100644 --- a/priv/docs/20-authentication/10-authentication.md +++ b/priv/docs/20-authentication/10-authentication.md @@ -26,11 +26,15 @@ magic-link forms; it does not apply to any of the game-client flows. ## JWT token flow (Email / Password / Device) A game client signs a player up with `POST /api/v1/register` (`email`, -`password`, optional `username`). It answers `201` with the same tokens as -login without waiting on the mail server: the confirmation email is queued, -as for the browser form, and sent and retried in the background. A taken -email or username is `409`, and a plugin that refuses the sign-up is -`403 registration_refused`. +`password`, optional `username`). Registering is not a sign-in, as device +login is: it creates the account, queues the confirmation email as the browser +form does, and answers `201` with the account (`user_id`, `username`, +`display_name`, `email_confirmed`) and no tokens, without waiting on the mail +server. Once the player opens the emailed link, `POST /api/v1/login` with the +same email and password signs in. A taken email or username is `409`, and a +plugin that refuses the sign-up is `403 registration_refused`. The first +account on a server is the admin: it is confirmed without an email +(`email_confirmed: true`), so it can log in at once. Deleting an account (`DELETE /api/v1/me`) sends `current_password` when the account has one. @@ -60,6 +64,23 @@ Refresh returns a new access token and sends back the same refresh token; it doe Token responses wrap their fields in a `data` object (`{"data": {"access_token": "..."}}`); the diagrams leave that wrapper out. +### Unconfirmed email + +Anyone can register any address, so a password signs nobody in until its +email is confirmed: `POST /api/v1/login` answers `403 email_not_confirmed`, +and the browser form says to confirm first. Both say so only after the right +password, so a guesser learns nothing. Either emailed link confirms: + +| Link | The password | +|---|---| +| The one in the confirmation email | is kept | +| An emailed login link (magic link) | is removed: set a new one in the account settings | + +A login link proves the player owns the inbox, so it confirms the email too. +It removes a password set before that, because whoever registered the address +chose it and may not be its owner; the page the link opens says so first. An +admin can also mark an email confirmed in **Admin → Users**. + ## Browser sessions and emailed links The website signs in with a session cookie rather than JWTs. The windows are settings on the `auth` group: diff --git a/priv/docs/30-clients/10-godot-sdk.md b/priv/docs/30-clients/10-godot-sdk.md index 2a775d5fa..302151961 100644 --- a/priv/docs/30-clients/10-godot-sdk.md +++ b/priv/docs/30-clients/10-godot-sdk.md @@ -53,14 +53,18 @@ Request bodies follow the operation: `GamendCreateLobbyRequest`, ## Authentication The SDK captures tokens for you: after `authenticate_login`, -`authenticate_register`, `authenticate_device_login`, -`authenticate_refresh_token`, `authenticate_oauth_session_status` (once it -answers a `session`), `authenticate_oauth_api_callback`, -`authenticate_oauth_google_id_token` or +`authenticate_device_login`, `authenticate_refresh_token`, +`authenticate_oauth_session_status` (once it answers a `session`), +`authenticate_oauth_api_callback`, `authenticate_oauth_google_id_token` or `authenticate_oauth_callback_api_apple_ios` it stores the access and refresh tokens, calls `authorize()` itself, and schedules a refresh before expiry. You never pass a token to a later call. +`authenticate_register` is not a sign-in: it answers the new account +(`email_confirmed` says whether it can log in yet) and keeps no session. Once +the player opens the emailed link, `authenticate_login` with the same email +and password signs in; until then it answers `email_not_confirmed`. + Those always sign in, even when a player is signed in already. To add a provider to the signed-in account, use the link calls instead: `authenticate_link_provider`, `authenticate_link_google_id_token`, diff --git a/priv/docs/30-clients/25-cpp-sdk.md b/priv/docs/30-clients/25-cpp-sdk.md index 6e3806c8e..8ba5d9fd8 100644 --- a/priv/docs/30-clients/25-cpp-sdk.md +++ b/priv/docs/30-clients/25-cpp-sdk.md @@ -70,12 +70,17 @@ Callbacks run inside `poll()`, on the thread that calls it, never on a network thread. That is where your game can touch its own state safely. Besides a device id, `Auth` signs in with an email and password -(`login_email`, `register_email`), a Steam session ticket (`login_steam`), +(`login_email`), a Steam session ticket (`login_steam`), and any configured provider (`sign_in("google")`), which opens the provider's page through `config.open_url` and waits for the player to finish. Signing in never links: `link("google")` and `link_steam(ticket)` add a provider to the signed-in account. +`register_email` makes an account and is not a sign-in: its callback gets the +new account (`models::Registration`), and the session does not change. The +password signs in with `login_email` once the player opens the emailed link; +until then that fails with `email_not_confirmed`. + ## Calls and replies Each method takes the path's parameters, then the request body, then the diff --git a/sdk/lib/gamend/accounts.ex b/sdk/lib/gamend/accounts.ex index 3084cec10..9d47497f7 100644 --- a/sdk/lib/gamend/accounts.ex +++ b/sdk/lib/gamend/accounts.ex @@ -21,6 +21,8 @@ defmodule Gamend.Accounts do The actual implementation runs on the Gamend. """ + @type password_error() :: :invalid_credentials | :email_not_confirmed | {:locked, pos_integer()} + @doc ~S""" Attach a device_id to an existing user record. Returns {:ok, user} or {:error, changeset} if the device_id is already used. @@ -54,10 +56,15 @@ defmodule Gamend.Accounts do `{:error, {:locked, seconds}}` when the address is locked, before the password is looked at, and for the failure that locks it. + `{:error, :email_not_confirmed}` for the right password on an account whose + email was never confirmed. Anyone can register any address with a password, + so the password signs nobody in until the inbox's owner has confirmed it. + It is answered only after the password matched, so it tells nothing to + someone who does not know it. + """ @spec authenticate_by_password(String.t(), String.t()) :: - {:ok, Gamend.Accounts.User.t()} - | {:error, :invalid_credentials | {:locked, pos_integer()}} + {:ok, Gamend.Accounts.User.t()} | {:error, password_error()} def authenticate_by_password(_email, _password) do case Application.get_env(:gamend_sdk, :stub_mode, :raise) do :placeholder -> @@ -1311,8 +1318,9 @@ defmodule Gamend.Accounts do end @doc ~S""" - Gets a user by email and password. `nil` for a wrong password, and for an - address locked by too many failures (`authenticate_by_password/2` says which). + Gets a user by email and password. `nil` for a wrong password, for an + address locked by too many failures, and for an email not yet confirmed + (`authenticate_by_password/2` says which). ## Examples @@ -1685,15 +1693,18 @@ defmodule Gamend.Accounts do 1. The user has already confirmed their email. They are logged in and the magic link is expired. - 2. The user has not confirmed their email and no password is set. - In this case, the user gets confirmed, logged in, and all tokens - - including session ones - are expired. In theory, no other tokens - exist but we delete all of them for best security practices. + 2. The user has not confirmed their email. Opening the link proves they + own the inbox, so the user gets confirmed, logged in, and all tokens - + including session ones - are expired. - 3. The user has not confirmed their email but a password is set. - This cannot happen in the default implementation but may be the - source of security pitfalls. See the "Mixing magic link and password registration" section of - `mix help phx.gen.auth`. + 3. As 2, with a password set: registered with one (`POST /api/v1/register`) + and never confirmed. The password is removed as the email is confirmed. + Whoever registered the address chose it before anyone proved they own + the inbox, so it may be someone else's, and kept it would sign them into + the account its owner has just claimed (the "Mixing magic link and + password registration" section of `mix help phx.gen.auth`). The owner + sets a new one in settings; the link in the confirmation email confirms + the account and keeps the password. """ @spec login_user_by_magic_link(String.t()) :: @@ -1862,7 +1873,7 @@ defmodule Gamend.Accounts do email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), enqueued in the transaction that inserts the user: the call returns once both are committed, without waiting on SMTP, and a failed send is retried - there. The first user becomes the admin and gets no email. + there. The first user becomes the admin and is confirmed, with no email. """ @spec register_user_and_deliver(Gamend.Types.user_registration_attrs(), (String.t() -> @@ -1894,7 +1905,7 @@ defmodule Gamend.Accounts do email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), enqueued in the transaction that inserts the user: the call returns once both are committed, without waiting on SMTP, and a failed send is retried - there. The first user becomes the admin and gets no email. + there. The first user becomes the admin and is confirmed, with no email. """ @spec register_user_and_deliver( @@ -1924,7 +1935,8 @@ defmodule Gamend.Accounts do @doc ~S""" Register a user with an email and a password and queue the confirmation email, as `register_user_and_deliver/3` does for the browser form: how a - game client signs up (`POST /api/v1/register`). + game client signs up (`POST /api/v1/register`). The password signs in once + the email is confirmed (`Gamend.Accounts.authenticate_by_password/2`). """ @spec register_user_with_password_and_deliver( From 1ab00a4e4a7c50a367965aedd35235609bc797f1 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 27 Sep 2026 08:37:59 +0000 Subject: [PATCH 2/2] A provider claiming an unconfirmed account drops its password Signing in with a provider that asserts a verified email links it to the account holding the address and confirms that account. When the account had never been confirmed, it kept the password whoever registered the address had chosen, so someone who registered a player's address first could still sign in with it after the player claimed the account through Google, Discord or another provider. As an emailed login link already does, claiming an unconfirmed account now removes its password and revokes every session, access and refresh token it held (update_user_and_delete_all_tokens bumps token_version). The old struct's cache keys are dropped and the revoked struct is re-warmed last. Linking to a confirmed account is unchanged. --- CHANGELOG.md | 2 + .../lib/gamend/accounts/identities.ex | 48 ++++++++++++---- .../test/gamend/accounts/oauth_test.exs | 56 +++++++++++++++++++ .../20-authentication/10-authentication.md | 19 ++++--- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76336082..22cd59dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - [breaking] **Registering with an email is no longer a sign-in, and a password signs in only once its email is confirmed.** `POST /api/v1/register` answered `201` with a full session while the confirmation email was still in the queue, as if it were device login, and password login worked the same on an address nobody had confirmed, so anyone could register any address and play as it. The two flows are now separate: device login still creates an account and signs it in, while registering creates the account, queues the email and answers `201` with the account under `data` (`Registration`: `user_id`, `username`, `display_name`, `email_confirmed`), never tokens. `POST /api/v1/login` answers `403 email_not_confirmed` until the emailed link is opened, and the browser password form says to confirm first; both only after the right password, so a guesser learns nothing. `Accounts.authenticate_by_password/2` returns `{:error, :email_not_confirmed}`, and `get_user_by_email_and_password/2` nil. The first account is confirmed as it is created (`email_confirmed: true`), on the API as in the browser, so it logs in at once. An emailed login link still confirms, as it proves the inbox; on an account registered with a password it used to crash, and now confirms and removes the password, which whoever registered the address chose before anyone proved they own it (the page the link opens says so; the confirmation email's link keeps it). The SDKs follow: Godot's `authenticate_register` no longer keeps a session, the C++ `Auth::register_email` takes a plain `Callback` and answers the account without touching the session, and Balaur's `client::register_email` is a REST call rather than `gamend::register`. +- [fixed] **A provider sign-in that claims an unconfirmed account no longer keeps its password.** Signing in with a provider asserting a verified email links that provider to the account holding the address, and confirms it. When that account had never been confirmed, it kept the password whoever registered the address had chosen, so someone who registered a player's address first could still sign in with it once the player claimed the account with Google, Discord or any other provider. As with an emailed login link, the password is now removed and every session, access and refresh token the account held is revoked (`token_version` bumped). Linking to a confirmed account is unchanged. + - [fixed] **The Hex packages compile as the version they were published as.** Each package's `mix.exs` took its version from the environment of whoever compiled it: `GAMEND_CONTENT_APP_VERSION` for gamend_core and gamend_web, `APP_VERSION` for gamend_sdk and gamend_plugin_tools. A host whose Dockerfile exports `GAMEND_CONTENT_APP_VERSION=1.0.0` (gamend_starter's did) built the engine as 1.0.0, and the SDK pair, whose `@version` publishing never stamped, built as 1.0.26 wherever `APP_VERSION` was unset. Either one failed a host's `>= 1.0.1266` requirement with "the dependency does not match the requirement". The publish job now writes the release's version into all four `mix.exs` files and removes the env lookup before anything is published; this repository's own image and docs still take the CI version from the environment. - [fixed] **`GamendWeb.BrotliCompressor` no longer fails the digest when `brotli` is not installed.** Its docs promised that a missing binary keeps the gzip, but `System.cmd/3` raises `:enoent` for a command it cannot find, so a host that lists it in `:phoenix, :static_compressors` had `mix phx.digest`, and with it `mix assets.deploy`, crash on any machine without `brotli` on `PATH`. It now looks the binary up first and returns `:error` when it is missing, so the digest writes only the `.gz` files. diff --git a/apps/gamend_core/lib/gamend/accounts/identities.ex b/apps/gamend_core/lib/gamend/accounts/identities.ex index 270b83aec..5b7b44ad2 100644 --- a/apps/gamend_core/lib/gamend/accounts/identities.ex +++ b/apps/gamend_core/lib/gamend/accounts/identities.ex @@ -297,16 +297,7 @@ defmodule Gamend.Accounts.Identities do defp link_provider_to_user(user, attrs, provider_id_field, changeset_fn) do if Map.get(attrs, :email_verified) == true do attrs = scrub_attrs_for_update(user, attrs, provider_id_field) - - case user |> changeset_fn.(attrs) |> drop_device_credential() |> Repo.update() do - {:ok, %User{} = updated} = ok -> - Accounts.invalidate_user_cache(user) - Accounts.invalidate_user_cache(updated) - ok - - other -> - other - end + claim(user, user |> changeset_fn.(attrs) |> drop_device_credential()) else changeset = user @@ -320,6 +311,43 @@ defmodule Gamend.Accounts.Identities do end end + # The provider's changeset confirms the email, and a provider vouching for + # the address proves the inbox as an emailed login link does + # (`Gamend.Accounts.Sessions.login_user_by_magic_link/1`). Like that link, it + # must not keep what was set on an unconfirmed account before anyone proved + # the inbox: whoever registered the address chose its password, and was + # handed any token issued to it, and may not be its owner. So the password + # goes, and every token is revoked, as the owner claims the account. + defp claim(%User{confirmed_at: nil} = user, changeset) do + result = + changeset + |> Ecto.Changeset.put_change(:hashed_password, nil) + |> Accounts.update_user_and_delete_all_tokens() + + case result do + {:ok, {%User{} = updated, _expired}} -> + # Drop what the old struct was cached under (a retired device id among + # it), then re-warm with the revoked one last, as the revocation does. + Accounts.invalidate_user_cache(user) + {:ok, Accounts.cache_user(updated)} + + other -> + other + end + end + + defp claim(%User{} = user, changeset) do + case Repo.update(changeset) do + {:ok, %User{} = updated} = ok -> + Accounts.invalidate_user_cache(user) + Accounts.invalidate_user_cache(updated) + ok + + other -> + other + end + end + # Linking a provider to an existing account retires that account's device # credential. The provider is linked as usual — the account keeps working, and # gains a sign-in method — but device auth is no longer one of its methods. diff --git a/apps/gamend_core/test/gamend/accounts/oauth_test.exs b/apps/gamend_core/test/gamend/accounts/oauth_test.exs index d74ff29ad..8b79bdca8 100644 --- a/apps/gamend_core/test/gamend/accounts/oauth_test.exs +++ b/apps/gamend_core/test/gamend/accounts/oauth_test.exs @@ -36,6 +36,62 @@ defmodule Gamend.Accounts.OAuthTest do assert returned.discord_id == "d_link" end + # Registered with a password by whoever typed the address, before anyone + # proved the inbox: the owner's provider sign-in claims the account, and + # nothing the registrant was given may still open it. + test "claiming an unconfirmed account drops its password and revokes its tokens" do + user = + %{email: "claimed@example.com"} + |> AccountsFixtures.unconfirmed_user_fixture() + |> AccountsFixtures.set_password() + + session = Accounts.generate_user_session_token(user) + version = Accounts.get_user!(user.id).token_version + + {:ok, returned} = + Accounts.find_or_create_from_discord(%{ + discord_id: "d_claim", + email: "claimed@example.com", + email_verified: true + }) + + assert returned.id == user.id + assert returned.confirmed_at + refute returned.hashed_password + assert returned.token_version > version + assert %{hashed_password: nil, discord_id: "d_claim"} = Accounts.get_user!(user.id) + refute Accounts.get_user_by_session_token(session) + + assert {:error, :invalid_credentials} = + Accounts.authenticate_by_password( + "claimed@example.com", + AccountsFixtures.valid_user_password() + ) + end + + test "a confirmed account keeps its password when a provider links to it" do + user = + %{email: "kept@example.com"} + |> AccountsFixtures.user_fixture() + |> AccountsFixtures.set_password() + + {:ok, returned} = + Accounts.find_or_create_from_discord(%{ + discord_id: "d_kept", + email: "kept@example.com", + email_verified: true + }) + + assert returned.id == user.id + assert returned.hashed_password + + assert {:ok, _} = + Accounts.authenticate_by_password( + "kept@example.com", + AccountsFixtures.valid_user_password() + ) + end + test "refuses to link to an existing account when email is unverified" do user = AccountsFixtures.unconfirmed_user_fixture(%{email: "victim@example.com"}) diff --git a/priv/docs/20-authentication/10-authentication.md b/priv/docs/20-authentication/10-authentication.md index f34788943..e2e3d20db 100644 --- a/priv/docs/20-authentication/10-authentication.md +++ b/priv/docs/20-authentication/10-authentication.md @@ -69,17 +69,20 @@ Token responses wrap their fields in a `data` object (`{"data": {"access_token": Anyone can register any address, so a password signs nobody in until its email is confirmed: `POST /api/v1/login` answers `403 email_not_confirmed`, and the browser form says to confirm first. Both say so only after the right -password, so a guesser learns nothing. Either emailed link confirms: +password, so a guesser learns nothing. Three things confirm it: -| Link | The password | +| What | The password | |---|---| -| The one in the confirmation email | is kept | +| The link in the confirmation email | is kept | | An emailed login link (magic link) | is removed: set a new one in the account settings | - -A login link proves the player owns the inbox, so it confirms the email too. -It removes a password set before that, because whoever registered the address -chose it and may not be its owner; the page the link opens says so first. An -admin can also mark an email confirmed in **Admin → Users**. +| Signing in with a provider that vouches for the address (a verified email) | is removed, as for a login link | + +A login link or a provider proves the player owns the inbox, so either +confirms the email too. Both remove a password set before that, and revoke +every session and token the account held, because whoever registered the +address chose that password and may not be its owner; the page a login link +opens says so first. An admin can also mark an email confirmed in +**Admin → Users**. ## Browser sessions and emailed links