Allow second-factor re-auth for sudo mode; gate SSH keys behind it

a986805 · Gabriel Morell · 2026-09-09 20:18

14 files +648 -26
Message
{commit_body(@commit)}

Files changed

modified assets/js/webauthn.js
+12 −3
@@ -3,7 +3,9 @@
3 3 // Exposes two hooks the LiveView pages bind to:
4 4 // * "WebauthnRegister" — on the security-key enrollment button
5 5 // * "WebauthnAuthenticate" — on the "use security key" button on
6 // the 2FA challenge page
6 +// the 2FA challenge page, and on the sudo
7 +// re-auth prompt (override the endpoints
8 +// with data-start-path / data-finish-path)
7 9
8 10 const b64uToBuf = s => {
9 11 // Pad + swap URL-safe chars back to standard base64.
@@ -156,11 +158,16 @@ export const WebauthnAuthenticate = {
156 158 try {
157 159 setStatus(this.el, "Touch your security key…")
158 160
159 const start = await postJson("/api/webauthn/auth/start", {})
161 + // The login challenge and the sudo re-auth prompt run the same
162 + // ceremony against different endpoints.
163 + const startPath = this.el.dataset.startPath || "/api/webauthn/auth/start"
164 + const finishPath = this.el.dataset.finishPath || "/api/webauthn/auth/finish"
165 +
166 + const start = await postJson(startPath, {})
160 167 const publicKey = decodeRequestOptions(start.publicKey)
161 168 const cred = await navigator.credentials.get({publicKey})
162 169
163 const finish = await postJson("/api/webauthn/auth/finish", {
170 + const finish = await postJson(finishPath, {
164 171 id: bufToB64u(cred.rawId),
165 172 rawId: bufToB64u(cred.rawId),
166 173 type: cred.type,
@@ -168,6 +175,8 @@ export const WebauthnAuthenticate = {
168 175 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
169 176 signature: bufToB64u(cred.response.signature),
170 177 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
178 + // Only set on the sudo re-auth prompt; the server sanitizes it.
179 + returnTo: this.el.dataset.returnTo || null,
171 180 })
172 181
173 182 if (finish.redirect) {
modified lib/git_gud/accounts.ex
+34 −4
@@ -8,6 +8,8 @@ defmodule GitGud.Accounts do
8 8
9 9 alias GitGud.Accounts.{User, UserToken, UserNotifier}
10 10 alias GitGud.Accounts.PersonalAccessToken
11 + alias GitGud.Accounts.TwoFactor
12 + alias GitGud.Accounts.Webauthn
11 13
12 14 @pat_prefix "ggp_"
13 15
@@ -25,10 +27,15 @@ defmodule GitGud.Accounts do
25 27 the `ggp_…` plaintext is only available herewe store its hash.
26 28 """
27 29 def create_personal_access_token(%User{id: uid}, name, scopes) do
28 plaintext = @pat_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
30 + plaintext =
31 + @pat_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
29 32
30 33 %PersonalAccessToken{user_id: uid}
31 |> PersonalAccessToken.changeset(%{name: name, token_hash: pat_hash(plaintext), scopes: scopes})
34 + |> PersonalAccessToken.changeset(%{
35 + name: name,
36 + token_hash: pat_hash(plaintext),
37 + scopes: scopes
38 + })
32 39 |> Repo.insert()
33 40 |> case do
34 41 {:ok, token} -> {:ok, token, plaintext}
@@ -298,6 +305,29 @@ defmodule GitGud.Accounts do
298 305
299 306 def sudo_mode?(_user, _minutes), do: false
300 307
308 + @doc """
309 + Can this user re-enter sudo mode with their second factor instead of
310 + their password?
311 +
312 + Requires both the opt-in flag and an actually usable factor — TOTP
313 + enrollment finished, or at least one registered security key. A user
314 + who flips the flag on and later removes every factor silently falls
315 + back to password-only re-auth.
316 + """
317 + def second_factor_reauth_available?(%User{second_factor_reauth_enabled: true} = user),
318 + do: TwoFactor.enabled?(user) or Webauthn.has_credentials?(user)
319 +
320 + def second_factor_reauth_available?(_user), do: false
321 +
322 + @doc """
323 + Turn second-factor re-authentication on or off for a user.
324 + """
325 + def set_second_factor_reauth(%User{} = user, value) when is_boolean(value) do
326 + user
327 + |> User.second_factor_reauth_changeset(value)
328 + |> Repo.update()
329 + end
330 +
301 331 @doc """
302 332 Returns an `%Ecto.Changeset{}` for changing the user email.
303 333
@@ -363,9 +393,9 @@ defmodule GitGud.Accounts do
363 393 {:error, %Ecto.Changeset{}}
364 394
365 395 """
366 def update_user_password(user, attrs) do
396 + def update_user_password(user, attrs, opts \\ []) do
367 397 user
368 |> User.password_changeset(attrs)
398 + |> User.password_changeset(attrs, opts)
369 399 |> update_user_and_delete_all_tokens()
370 400 end
371 401
modified lib/git_gud/accounts/user.ex
+41 −1
@@ -7,6 +7,7 @@ defmodule GitGud.Accounts.User do
7 7 schema "users" do
8 8 field :email, :string
9 9 field :password, :string, virtual: true, redact: true
10 + field :current_password, :string, virtual: true, redact: true
10 11 field :hashed_password, :string, redact: true
11 12 field :confirmed_at, :utc_datetime
12 13 field :authenticated_at, :utc_datetime, virtual: true
@@ -30,6 +31,7 @@ defmodule GitGud.Accounts.User do
30 31 field :totp_iv, :binary
31 32 field :totp_tag, :binary
32 33 field :totp_enabled_at, :utc_datetime
34 + field :second_factor_reauth_enabled, :boolean, default: false
33 35
34 36 timestamps(type: :utc_datetime)
35 37 end
@@ -37,6 +39,13 @@ defmodule GitGud.Accounts.User do
37 39 @doc "Has the user finished TOTP enrollment?"
38 40 def totp_enabled?(%__MODULE__{totp_enabled_at: t}), do: not is_nil(t)
39 41
42 + @doc """
43 + Toggle whether the user may satisfy sudo-mode re-authentication with
44 + their second factor instead of their password.
45 + """
46 + def second_factor_reauth_changeset(user, value) when is_boolean(value),
47 + do: Ecto.Changeset.change(user, second_factor_reauth_enabled: value)
48 +
40 49 @doc "Promote or demote an admin."
41 50 def admin_changeset(user, value) when is_boolean(value),
42 51 do: Ecto.Changeset.change(user, is_admin: value)
@@ -165,9 +174,40 @@ defmodule GitGud.Accounts.User do
165 174 """
166 175 def password_changeset(user, attrs, opts \\ []) do
167 176 user
168 |> cast(attrs, [:password])
177 + |> cast(attrs, [:password, :current_password])
169 178 |> validate_confirmation(:password, message: "does not match password")
170 179 |> validate_password(opts)
180 + |> maybe_validate_current_password(user, opts)
181 + end
182 +
183 + @doc "Has the user ever set a password? Magic-link-only accounts haven't."
184 + def has_password?(%__MODULE__{hashed_password: h}), do: is_binary(h)
185 +
186 + # Sudo mode alone is no longer proof of the current password — it can
187 + # be opened with a second factor — so changing an existing password
188 + # asks for it again. Accounts that never set one are exempt, or they
189 + # could never set a first password.
190 + defp maybe_validate_current_password(changeset, user, opts) do
191 + if Keyword.get(opts, :require_current_password, false) and has_password?(user) do
192 + changeset
193 + |> validate_required([:current_password])
194 + |> verify_current_password(user, opts)
195 + else
196 + changeset
197 + end
198 + end
199 +
200 + # Bcrypt is deliberately slow, so skip the comparison on the
201 + # keystroke-by-keystroke `phx-change` pass (which asks for
202 + # `hash_password: false`) and check it on real submits only.
203 + defp verify_current_password(changeset, user, opts) do
204 + if Keyword.get(opts, :hash_password, true) do
205 + validate_change(changeset, :current_password, fn :current_password, given ->
206 + if valid_password?(user, given), do: [], else: [current_password: "is not valid"]
207 + end)
208 + else
209 + changeset
210 + end
171 211 end
172 212
173 213 defp validate_password(changeset, opts) do
modified lib/git_gud_web/controllers/user_session_controller.ex
+65 −8
@@ -2,6 +2,7 @@ defmodule GitGudWeb.UserSessionController do
2 2 use GitGudWeb, :controller
3 3
4 4 alias GitGud.Accounts
5 + alias GitGud.Accounts.TwoFactor
5 6 alias GitGudWeb.UserAuth
6 7
7 8 def create(conn, %{"_action" => "confirmed"} = params) do
@@ -12,8 +13,21 @@ defmodule GitGudWeb.UserSessionController do
12 13 create(conn, params, "Welcome back!")
13 14 end
14 15
16 + # The login page forwards the sudo gate's `return_to` as a hidden
17 + # field. Stash it where `UserAuth.log_in_user/3` looks for it.
18 + defp stash_return_to(conn, %{"return_to" => raw}) do
19 + case UserAuth.safe_return_to(raw) do
20 + nil -> conn
21 + path -> put_session(conn, :user_return_to, path)
22 + end
23 + end
24 +
25 + defp stash_return_to(conn, _params), do: conn
26 +
15 27 # magic link login
16 defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
28 + defp create(conn, %{"user" => %{"token" => token} = user_params} = params, info) do
29 + conn = stash_return_to(conn, params)
30 +
17 31 case Accounts.login_user_by_magic_link(token) do
18 32 {:ok, {user, tokens_to_disconnect}} ->
19 33 UserAuth.disconnect_sessions(tokens_to_disconnect)
@@ -30,8 +44,9 @@ defmodule GitGudWeb.UserSessionController do
30 44 end
31 45
32 46 # email + password login
33 defp create(conn, %{"user" => user_params}, info) do
47 + defp create(conn, %{"user" => user_params} = params, info) do
34 48 %{"email" => email, "password" => password} = user_params
49 + conn = stash_return_to(conn, params)
35 50
36 51 if user = Accounts.get_user_by_email_and_password(email, password) do
37 52 conn
@@ -46,17 +61,59 @@ defmodule GitGudWeb.UserSessionController do
46 61 end
47 62 end
48 63
64 + @doc """
65 + Re-open sudo mode for an already-logged-in user who submitted a valid
66 + TOTP code instead of their password.
67 +
68 + Only reachable when the user opted in *and* still has a usable second
69 + factor (`Accounts.second_factor_reauth_available?/1`). This can never
70 + log anyone inthe pipeline requires an authenticated sessionit
71 + only refreshes `authenticated_at`.
72 + """
73 + def reauth_second_factor(conn, %{"code" => code} = params) do
74 + user = conn.assigns.current_scope.user
75 + code = String.replace(code, ~r/\s+/, "")
76 +
77 + if Accounts.second_factor_reauth_available?(user) and
78 + TwoFactor.verify_code(user, code) == :ok do
79 + conn
80 + |> put_flash(:info, "Welcome back!")
81 + |> UserAuth.reauthenticate(user)
82 + |> redirect(to: reauth_return_to(conn, params))
83 + else
84 + conn
85 + |> put_flash(:error, "That code didn't match.")
86 + |> redirect(to: ~p"/users/log-in")
87 + end
88 + end
89 +
90 + defp reauth_return_to(conn, params) do
91 + UserAuth.safe_return_to(params["return_to"]) ||
92 + get_session(conn, :user_return_to) ||
93 + ~p"/users/settings"
94 + end
95 +
49 96 def update_password(conn, %{"user" => user_params} = params) do
50 97 user = conn.assigns.current_scope.user
51 98 true = Accounts.sudo_mode?(user)
52 {:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
53 99
54 # disconnect all existing LiveViews with old sessions
55 UserAuth.disconnect_sessions(expired_tokens)
100 + # Sudo mode can now be opened with a second factor, so it no longer
101 + # proves the current password. This is the enforcement point — the
102 + # LiveView's copy of the check is only for live feedback.
103 + case Accounts.update_user_password(user, user_params, require_current_password: true) do
104 + {:ok, {_user, expired_tokens}} ->
105 + # disconnect all existing LiveViews with old sessions
106 + UserAuth.disconnect_sessions(expired_tokens)
56 107
57 conn
58 |> put_session(:user_return_to, ~p"/users/settings")
59 |> create(params, "Password updated successfully!")
108 + conn
109 + |> put_session(:user_return_to, ~p"/users/settings")
110 + |> create(params, "Password updated successfully!")
111 +
112 + {:error, _changeset} ->
113 + conn
114 + |> put_flash(:error, "Password not updated. Check your current password and try again.")
115 + |> redirect(to: ~p"/users/settings")
116 + end
60 117 end
61 118
62 119 def delete(conn, _params) do
modified lib/git_gud_web/controllers/webauthn_controller.ex
+59 −0
@@ -14,6 +14,10 @@ defmodule GitGudWeb.WebauthnController do
14 14 * `POST /api/webauthn/auth/finish` — verifies the assertion.
15 15 On success, stashes a flag in the session so the challenge
16 16 LiveView can redirect to the session-finalizing endpoint.
17 + * `POST /api/webauthn/sudo/start` — authed user re-opening the
18 + sudo-mode window with a security key instead of their password.
19 + * `POST /api/webauthn/sudo/finish` — verifies that assertion and
20 + refreshes the session token, granting sudo mode.
17 21 """
18 22
19 23 use GitGudWeb, :controller
@@ -101,6 +105,61 @@ defmodule GitGudWeb.WebauthnController do
101 105 end
102 106 end
103 107
108 + # ── sudo re-authentication (already logged in) ─────────────────────
109 +
110 + # Unlike `auth_start`/`auth_finish`, the user here is already logged
111 + # in — we're only re-opening the sudo window, so the identity comes
112 + # from the session token rather than a pending-2FA stash.
113 +
114 + def sudo_start(conn, _params) do
115 + with %{} = user <- sudo_candidate(conn) do
116 + challenge = Webauthn.authentication_challenge(user)
117 + options = Webauthn.serialize_authentication_options(challenge)
118 +
119 + conn
120 + |> put_session(:webauthn_sudo_challenge, serialize_challenge(challenge))
121 + |> json(%{"publicKey" => options})
122 + else
123 + _ -> conn |> put_status(:unauthorized) |> json(%{"error" => "not_available"})
124 + end
125 + end
126 +
127 + def sudo_finish(conn, params) do
128 + with %{} = user <- sudo_candidate(conn),
129 + %Wax.Challenge{} = challenge <-
130 + load_session_challenge(conn, :webauthn_sudo_challenge),
131 + {:ok, _cred} <- Webauthn.verify_assertion(user, params, challenge) do
132 + return_to =
133 + GitGudWeb.UserAuth.safe_return_to(params["returnTo"]) ||
134 + get_session(conn, :user_return_to) ||
135 + ~p"/users/settings"
136 +
137 + conn
138 + |> delete_session(:webauthn_sudo_challenge)
139 + |> GitGudWeb.UserAuth.reauthenticate(user)
140 + |> json(%{"ok" => true, "redirect" => return_to})
141 + else
142 + nil ->
143 + conn |> put_status(:unauthorized) |> json(%{"error" => "not_available"})
144 +
145 + {:error, reason} ->
146 + conn
147 + |> put_status(:unprocessable_entity)
148 + |> json(%{"error" => inspect(reason)})
149 + end
150 + end
151 +
152 + # The logged-in user, but only if they opted into second-factor
153 + # re-auth and still have a factor registered.
154 + defp sudo_candidate(conn) do
155 + with %{} = user <- conn.assigns[:current_scope] && conn.assigns.current_scope.user,
156 + true <- Accounts.second_factor_reauth_available?(user) do
157 + user
158 + else
159 + _ -> nil
160 + end
161 + end
162 +
104 163 # ── helpers ────────────────────────────────────────────────────────
105 164
106 165 defp require_user(conn) do
modified lib/git_gud_web/live/ssh_key_live/index.ex
+14 −2

Click to load diff…

modified lib/git_gud_web/live/user_live/login.ex
+66 −2

Click to load diff…

modified lib/git_gud_web/live/user_live/settings.ex
+17 −3

Click to load diff…

modified lib/git_gud_web/live/user_live/two_factor_setup.ex
+38 −1

Click to load diff…

modified lib/git_gud_web/router.ex
+11 −0

Click to load diff…

modified lib/git_gud_web/user_auth.ex
+52 −2

Click to load diff…

added priv/repo/migrations/20260909010000_add_second_factor_reauth_flag.exs
+12 −0

Click to load diff…

added test/git_gud_web/password_change_test.exs
+83 −0

Click to load diff…

added test/git_gud_web/second_factor_reauth_test.exs
+144 −0

Click to load diff…

Parents: 845b545