Skip to content

Latest commit

 

History

History
117 lines (100 loc) · 6.74 KB

File metadata and controls

117 lines (100 loc) · 6.74 KB

Authentication & sessions

vutuv is passwordless. The baseline login is a two-step email-PIN flow (/login mails a 6-digit PIN, the second step verifies it; no password is ever stored).

The PIN lives in login_pins (one row per (user, type), shared by the login, email change and delete flows). It stays valid for 30 minutes from when it is minted (minted_at) and it is one-time: a successful check stamps consumed_at, so it can never be replayed. A classic (non-LiveView) PIN form can be posted twice — a double-tap, a back-navigation, a retried request — so Vutuv.Accounts.check_pin/3 tells an already-used PIN (consumed_at set → {:already_used, _}, a calm "already used" notice because the first submit already logged the member in) apart from a genuinely expired one (minted_at past the window, or cleared → {:expired, _}). Collapsing the two once told members who had just logged in that their fresh PIN had expired (issue #839).

Returning members can also enrol one or more passkeys (WebAuthn / FIDO2 — Touch ID, Windows Hello, a security key) from the Account hub and sign in with one as an alternative first factor, skipping the email round-trip entirely (Vutuv.Credentials, the wax_ library, table user_credentials; the browser ceremony is assets/js/webauthn.js, revealed only on supporting browsers).

A passkey is enrolled only while logged in, so the email PIN stays the always-available fallback and the only way to bootstrap an account — a passkey is a faster return login, never the root of trust.

If a member types their address and clicks "Sign in with a passkey" but that account has no passkey, the challenge endpoint quietly falls back to the email-PIN flow — it mails a PIN and drops them on the PIN screen with a friendly note — instead of stranding them at an empty native prompt (issue #834).

Passkey verification funnels into the same Accounts.login/2 exit as the PIN, so it gets the identical server-side per-device session row, new-device security email and live-socket wiring.

Power users can also enrol two kinds of alternative login codes (issue #912, Vutuv.LoginCodes) that work in the normal PIN field at step 2 — same screen, same form, so a member who sets nothing up never sees any difference:

  • an authenticator app (RFC 6238 TOTP, nimble_totp, table user_totps, one row per member): set up under Sign-in & security by scanning a QR code (rendered server-side with eqrcode, air-gap safe) and confirming with a first code — the row is unusable until that confirmation, so a mis-scan cannot enrol a broken secret. Verification accepts the current and previous 30-second window (clock drift) and passes last_used_at to NimbleTOTP as since:, so a code can never be replayed.
  • a printable one-time code list ("Kennwortliste", table login_list_codes): ten XXXX-XXXX codes from an unambiguous alphabet (no 0/O/1/I/L), each logging the member in exactly once (consumed atomically). Viewable, regenerable and deletable under /settings/login_codes.

The wiring is one seam: Accounts.check_login_code/2 first runs the normal check_pin/3 and only on failure tries LoginCodes.redeem_login_code/2 for the account. Every alternate failure returns exactly the PIN check's own result, so messages, attempt counters, the lockout and the enumeration safety of the PIN flow are byte-for-byte unchanged; a success also consumes the outstanding emailed PIN. Like passkeys these are alternative first factors, enrolled only while logged in — the email PIN remains the always-available fallback and the only root of trust, so there is nothing a member can lock themselves out with. The PIN screen shows a one-line reminder that app/list codes work, but only to members who actually enrolled (LoginCodes.any_for_email?/1 — the same deliberate reveals-enrolment cost as the passkey fallback).

Each login is a tracked server-side session (Vutuv.Sessions, table user_sessions, SHA-256-hashed token): members see where they are signed in, revoke a single device or all others, and add / remove passkeys at /:slug/settings; a noteworthy login (new device, suspicious location) mails a security alert

Where a login lands. Always VutuvWeb.Home.path/1: the feed, or the member's own profile while they follow nobody (a page that asked for the login wins over both via return_to). The PIN that confirms a brand-new registration lands there too — it just carries the one-time welcome questions (location and job search) with it, as a modal floating over that profile. SessionController.welcome_due?/2 gates them on both the PIN form's "registration" context and a nil users.welcome_completed_at, and stores the one-shot :welcome_pending session key VutuvWeb.Plug.WelcomeModal reads, so an ordinary login never sees them and a member who closed them is not asked again. See Profiles.

A newcomer gets no welcome toast: the modal owns that screen and a toast behind its dimmed backdrop cannot be read, "Welcome back" is the wrong word for somebody who has just arrived, and the profile underneath already shows the completion checklist. The returning member's "Welcome back, …" (plus the unread-conversations nudge) is unchanged.

Scripted sign-ups (Vutuv.SignupTrap)

Some registrations come from scripts that create accounts in bulk, and they read the PIN mail, so the PIN alone does not stop them. Vutuv.SignupTrap recognises them by what they type (today: the same lowercase word as first and last name, four letters or more) and answers with the ordinary PIN screen while creating nothing: Accounts.register_user/3 returns {:trapped, email} and the controller calls Accounts.pretend_registration/2, the same pin cookie and screen without any mail. The trap only looks at a form that would otherwise have created an account, so an invalid one still gets its errors.

What the form submitted, with the IP address, user agent and Accept-Language, goes to trapped_registrations. Vutuv.SignupTrap.Reporter ticks hourly and calls SignupTrap.run/2, which deletes entries after 14 days and mails the operator every unreported entry from before the last Monday, 07:00 German time. The due list is a query, not a timer, so a restart costs an hour at most; the entries stay row-locked while their mail goes out, so the two slots of a deploy never send it twice, and a failed delivery rolls the reported_at stamp back.

A new rule is an entry in @rules (its name and the label the report shows), a rule?/2 clause and a test in test/vutuv/signup_trap_test.exs. Run it over the dev database's accounts first and read every one it would have caught: a real person caught by a rule never gets a PIN and is never told why.