Skip to content

STORY-F-006: phx.gen.auth scaffold + Argon2 + TOTP MFA

Epic: Auth Priority: Must Have Story Points: 3 Status: Not Started Assigned To: Unassigned Created: 2026-04-17 Sprint: 2


User Story

As a user of any role, I want to log in with email + password (with optional TOTP MFA) and have my session secured by Phoenix conventions, so that authenticated access is possible from Sprint 2 onwards and my password is never stored in plaintext.


Description

Background

PRD E1.3 specifies Phoenix-native auth with Argon2 hashing (SE-08), optional TOTP MFA, and mandatory FIDO2 for admin/payroll/director roles. FIDO2 WebAuthn is complex — defer to a later sprint (during Scout + Verify go-live). This story delivers email + password + TOTP MFA as a baseline. FIDO2 gets its own story in Sprint 7–8 or Migration Phase 1.

AgenticAI-app uses phx.gen.auth — carry-forward pattern. Gautham is familiar; this is largely generated code.

Scope

In scope:

  • Run mix phx.gen.auth Accounts User users --web equivalent manually (umbrella shape requires care — scaffold generates into finnest_web with Repo from finnest_core)
  • Finnest.Core.Users.User Ecto schema with: id UUID, org_id UUID (FK), email citext, hashed_password, confirmed_at, totp_secret_encrypted (Cloak-encrypted), inserted_at, updated_at, deleted_at
  • Note: public.users in the architecture has role and more fields — F-007 expands this schema; F-006 lands the minimum for phx.gen.auth to work
  • Argon2 hashing via {:argon2_elixir, "~> 4.0"} (SE-08)
  • Registration, login, logout, email confirmation, password reset routes under /users/*
  • Email delivery via Swoosh.Adapters.Local in dev (Mailpit); SES in prod — CommEmail port stubs added, real port implementation deferred
  • TOTP MFA (optional): user enables TOTP → QR code shown → scan in authenticator app → verify → totp_secret_encrypted written; subsequent logins require 6-digit code
  • Session timeout: 60 min rolling (commercial default). IRAP override to 15 min deferred to IRAP phase.
  • Lockout after 5 failed login attempts in 10 min (account lockout for 15 min) — E1.3 AC4
  • Every auth event emits user_registered / user_logged_in / user_logged_out / mfa_enabled / password_reset_requested / account_locked events — event store (F-016) not ready yet, so buffer in memory / Oban and re-play when F-016 lands, OR write to a temporary public.audit_log table and migrate

Out of scope:

  • FIDO2 / WebAuthn (deferred to PRD Scout/Verify sprints or Migration Phase 1)
  • M365 SSO (deferred — PRD E1.3 AC6 lands in Migration Phase 1)
  • public.users.role + full schema (expanded in F-007)
  • Per-org lockout policies, admin-visible audit of auth events (F-007 + F-016)
  • Account deletion cascade (PRD E1.7 AC8 — Migration Phase ½)

Technical Notes

  • phx.gen.auth generates into finnest_web — acceptable per architecture (web edge can call any domain), but the User schema must live in finnest_core (foundational). Adjust the generated code to split schema (in finnest_core/lib/finnest/core/users/user.ex) vs context (finnest_core/lib/finnest/core/users.ex) vs LiveView + controllers (in finnest_web).
  • Cloak encryption for totp_secret_encrypted: use Finnest.Core.Vault.Binary Ecto type (implement in F-007 when schema fully lands, OR stub in F-006 with plain BYTEA + @TODO Cloak in F-007)
  • Session storage: default Plug session with signed cookie — fine for Sprint 2. Upgrade to DB-backed session store later if needed.
  • UI: login/signup/reset pages styled with DaisyUI (F-004 asset pipeline). Accessible (WCAG AA): semantic form, labels, aria-describedby for errors.
  • Rate limit /users/log_in at 5 attempts / 10 min per email + 20 attempts / hour per IP — Hammer library (defer full Hammer setup to F-020; use in-memory token bucket for F-006)
  • Lockout: new users.locked_until column; login pipeline checks; auto-unlock on expiry

Dependencies

  • Blocked by: STORY-F-003 (Repo), STORY-F-004 (web endpoint)

Acceptance Criteria

  • User can register with email + password; email confirmation link sent (Mailpit in dev)
  • Confirmed user can log in; session persists 60 min rolling
  • Logout clears session; GET / shows unauthenticated state
  • Password reset flow works end-to-end (request → email → click link → new password)
  • User can enable TOTP MFA via /users/mfa; QR code displayed; verification via 6-digit code
  • After enabling MFA, login requires TOTP code; wrong code → error, rate-limited
  • 5 failed login attempts in 10 min → account locked until locked_until; attempted login returns "account locked" error (not "invalid password" — specific message per E1.3 AC4)
  • Passwords hashed with Argon2 (assert in repo query: User.hashed_password starts with $argon2)
  • TOTP secret encrypted at rest (BYTEA column populated; not plaintext)
  • Auth events emitted (to temporary audit_log table until F-016) with correlation_id + user_id
  • Security headers present on auth pages (inherited from F-004 plug stack)
  • mix format, credo --strict, dialyzer all green

Testing Requirements

  • Registration flow: ExUnit + Phoenix ConnTest + LiveView test end-to-end
  • Login flow: valid + invalid password + locked account + MFA required
  • MFA setup: QR code rendered, TOTP code validation, wrong code rejection
  • Password reset: expired token, used token, valid token
  • Rate limiting: 5 rapid failed logins → 6th locked; 15-min auto-unlock test
  • Security: attempt SQL injection in email field → Changeset rejects
  • Coverage ≥80% for the Users context

References