neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
Log in to mark this repository.
Allow second-factor re-auth for sudo mode; gate SSH keys behind it
a986805 · Gabriel Morell · 2026-09-09 20:18
Message
{commit_body(@commit)}
Files changed
modified
assets/js/webauthn.js
+12
−3
| 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 | 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 | 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 | 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 | 27 | the `ggp_…` plaintext is only available here — we 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 in — the pipeline requires an authenticated session — it | |
| 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 | 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 | 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