Authentication
S05 brings the first real domain surface to shithub: email/password signup, email verification, login, logout, password reset, and rate-limiting. This doc covers what's wired, where the code lives, and the security choices behind the design.
What this sprint ships
- Real users in Postgres:
users,user_emails,password_resets,email_verifications,auth_throttle,username_redirects. - argon2id password hashing with PHC string encoding.
- Strict username whitelist + reserved-name list.
- Common-password rejection from an embedded SecLists 10k corpus.
- Email Sender abstraction with stdout (dev), SMTP (MailHog/local), Postmark, and Resend backends.
- Counter-based rate-limiting backed by Postgres (no Redis dep).
- Auth handlers: signup, login, logout, password reset (request + confirm), email verification, verification resend.
- Constant-time login: missing usernames still trigger an argon2 hash against a pre-computed dummy.
- Generic password-reset response: no user-existence enumeration via that flow.
- 4 KiB request-body cap on auth POSTs (anti-DoS for the hashing path).
- Honeypot field on the signup form.
shithubd admin reset-password <username>operator escape hatch.
Out of scope (future sprints)
- 2FA / TOTP (S06)
- SSH keys (S07)
- Personal access tokens (S08)
- OAuth providers (post-MVP)
- Profile / settings UI (S09 / S10)
Tables (migrations 0002–0008)
| Table | Purpose | Notes |
|---|---|---|
users |
identity + password material | username citext UNIQUE. PHC argon2id hash. Soft-deletable (deleted_at) and suspendable. |
user_emails |
one or more emails per user | email citext UNIQUE across all users. Partial unique index enforces one primary per user. |
password_resets |
reset tokens | token_hash bytea UNIQUE. 1-hour TTL. Single-use via used_at. |
email_verifications |
verification tokens | token_hash bytea UNIQUE. 24-hour TTL. Single-use via used_at. |
auth_throttle |
rate-limit counters | (scope, identifier) UNIQUE. Fixed-window. |
username_redirects |
username-change history | Old name → new user. Acts as a 30-day reservation (S10 wires the cooldown). |
citext is enabled in migration 0002 for case-insensitive uniqueness without functional indexes. Display casing for users and emails lives in the same column — citext preserves it on read.
Password hashing
internal/auth/password implements argon2id using golang.org/x/crypto/argon2. Defaults:
- Memory: 64 MiB (
m=65536) - Time: 3 iterations (
t=3) - Threads: 2 (
p=2) - Salt: 16 bytes
- Key: 32 bytes
These are tuned for ~100–300 ms per Hash on dev hardware. Operators can override via auth.argon2.* config. The output is a canonical PHC string:
$argon2id$v=19$m=65536,t=3,p=2$<saltB64>$<hashB64>
Stored in users.password_hash; users.password_algo is argon2id-v1 so we can roll forward later.
A package-level dummyEncoded is computed on first use. Login handlers call password.VerifyAgainstDummy when the username doesn't exist so the response time matches a real failed-password verification (defense against username enumeration via timing).
The minimum password length is 10 characters. There is no maximum — argon2id can hash arbitrary input — but the auth-POST middleware caps r.Body at 4 KiB so a misbehaving client can't ship a 10 MB password to weaponize the hashing path.
Tokens
internal/auth/token mints 32-byte cryptographically-random tokens, base64url-encodes them for inclusion in URLs/emails, and stores their sha256 hash in the DB. The raw token never touches disk. Lookups parse the URL fragment, hash, and query by hash with a UNIQUE-index O(1) seek. Comparisons use crypto/subtle.ConstantTimeCompare.
Reserved-name list
internal/auth/reserved.go maintains a set of usernames that may not be claimed: every static top-level route shithub registers, GitHub-known reservations we mirror for parity, and a buffer of likely-future routes. Signup checks this list (case-insensitive — LOGIN is rejected the same as login).
When a future sprint adds a new top-level route, the leading path segment must be added here. The intended audit mechanism: internal/web/handlers/handlers_test.go should walk the registered chi routes and check every static segment against auth.ReservedNames(). (Stub-level today; full route-walking lands when the route surface stabilizes in S11+.)
Common-password list
internal/passwords embeds the SecLists 10k-most-common.txt corpus (~73 KB on disk). IsCommon is case-insensitive. Used at signup and password-reset.
To refresh: replace internal/passwords/common_passwords.txt with a newer SecLists snapshot and re-run the test suite.
Email service
internal/auth/email defines the Sender interface. Four implementations:
StdoutSender— writes a human-readable dump to a writer. Default in dev when no SMTP is configured. Convenient for tests.SMTPSender— plain SMTP for MailHog locally. Authenticated and TLS-upgrade variants supported.PostmarkSender— Postmark transactional API.ResendSender— Resend transactional API (https://resend.com). Comparable feature set to Postmark; preferred where onboarding speed matters (no human approval queue).
messages.go hosts the VerifyMessage and ResetMessage builders. Both produce HTML + plaintext bodies — every transactional email shithub sends works in plain-text-only clients. Templates are inlined (short, rarely change); when they grow, promote to templates/email/*.{html,txt}.
Wiring
auth.email_backend chooses the implementation: stdout | smtp | postmark | resend. The smtp backend additionally requires auth.smtp.addr; postmark requires auth.postmark.server_token; resend requires auth.resend.api_key. Validation enforces these.
# Dev: capture mail in MailHog
make dev-email
SHITHUB_AUTH__EMAIL_BACKEND=smtp ./bin/shithubd web
# Open http://127.0.0.1:8025 to read captured messages
Rate limiting
internal/auth/throttle implements fixed-window counters via the auth_throttle table. The BumpAuthThrottle query atomically increments the counter for (scope, identifier) or starts a new window if the existing one is older than (now - Window).
Limits enforced by the auth handlers:
| Scope | Identifier | Max | Window | Where |
|---|---|---|---|---|
signup |
ip:<client-ip> |
5 | 1h | signupSubmit |
login |
ip:<client-ip>|<username> |
6 | 15m | loginSubmit (reset on success) |
reset |
email:<addr> |
3 | 1h | resetRequestSubmit |
429 responses include a Retry-After header.
We deliberately use Postgres rather than introducing Redis. At launch scale this is well within Postgres's comfort zone, and avoiding a new dep is worth the marginal latency. Migrate if S36 proves it necessary.
Sessions
S02 ships an AEAD-encrypted cookie store; S05 extends it to carry user_id. Session.IsAnonymous() reports whether a user is bound. The cookie store re-encrypts on every Save, producing a fresh ciphertext — that's our defense against session fixation.
Login flow:
- Verify password.
- Mutate the loaded session to set
UserIDandIssuedAt. - Call
SessionStore.Save— re-encrypts the cookie under the new state.
Logout flow:
SessionStore.Clear— deletes the cookie.
Auth middleware
internal/web/middleware/auth.go adds:
OptionalUser(lookup)— populatesCurrentUser{ID, Username}into context when the session has auser_id. Anonymous requests still pass through.RequireUser— redirects to/login?next=<requested-path>for anonymous requests.MaxBodySize(n)— wrapsr.Bodyinhttp.MaxBytesReader(w, r.Body, n). Used on auth POSTs (4 KiB).
CurrentUserFromContext(ctx) returns the bound CurrentUser (zero value when anonymous).
CSRF
S02's nosurf wrapper guards every state-changing route. S05 fixes a wart: nosurf's default isTLS returns true unconditionally, which makes its same-origin Referer check require an https scheme even on plain-HTTP requests. We set isTLS to a function that consults r.TLS and X-Forwarded-Proto: https so dev (HTTP) and prod (TLS-terminated) both work correctly.
Signup → verify → login → logout
End-to-end flow exercised in internal/web/handlers/auth/auth_test.go::TestSignup_Verify_Login_Logout:
GET /signup— render form. CSRF cookie set by nosurf.POST /signup— validate username (whitelist + reserved-name list), email shape, password length, common-password check. Hash password. In one transaction: create user, create user_email (unverified), setusers.primary_email_id, create email_verifications row. Send verification email (best-effort — SMTP failure does not break signup). Redirect to/login?notice=signup-pending.GET /verify-email/{token}— hash the token, look up the row, validate (not used, not expired). In one transaction: mark the email verified, flipusers.email_verifiedif it's the primary, mark the verification rowused_at. Redirect to/login?notice=verified.POST /login— load user (constant-time fallback if missing), Verify password, check suspended/verified flags, reset throttle counter, touchlast_login_at, mutate session withuser_id, Save (re-encrypts cookie), redirect to/(or?next=target if it's a relative path).POST /logout— Clear session. Redirect to/login?notice=logged-out.
Password reset
POST /password/reset always responds with the same generic notice — "If an account is registered to that address, we've sent a password-reset link." — whether or not the email exists. No email is sent for unknown addresses. This is the canonical defense against enumeration via the reset flow.
POST /password/reset/{token} validates the token (lookup by sha256, not used, not expired), enforces the password policy (length + common-password), hashes via argon2id, updates users.password_hash/password_algo/password_updated_at and marks the reset row consumed — atomically in one transaction. Redirects to /login?notice=password-reset.
Honeypot
The signup form has a hidden company field positioned off-screen with tabindex="-1". Bots tend to fill every field they see; humans don't. Non-empty submissions are silently treated as success (303 redirect to the same /login?notice=signup-pending page) so bots can't detect the trap.
Admin escape hatch
shithubd admin reset-password <username>
Generates a fresh password-reset token (1-hour TTL), persists it, and emails the link to the user's primary email via the configured backend. Useful when a locked-out user can't drive the public reset flow themselves.
Configuration
All auth settings flow through internal/infra/config (see docs/internal/config.md):
| Key | Type | Default | Notes |
|---|---|---|---|
auth.require_email_verification |
bool | true |
When true, login is rejected until the primary email is verified. |
auth.base_url |
string | http://127.0.0.1:8080 |
Used for absolute links in emails. |
auth.site_name |
string | shithub |
Branding token for email subjects/bodies. |
auth.email_from |
string | shithub <noreply@shithub.local> |
Envelope From for outgoing email. |
auth.email_backend |
string | stdout |
`stdout |
auth.smtp.addr |
string | 127.0.0.1:1025 |
Required when email_backend=smtp. |
auth.smtp.username |
string | "" |
Optional. |
auth.smtp.password |
string | "" |
Optional. Redacted by config print. |
auth.postmark.server_token |
string | "" |
Required when email_backend=postmark. Redacted. |
auth.resend.api_key |
string | "" |
Required when email_backend=resend. Redacted. |
auth.argon2.memory_kib |
uint32 | 65536 |
argon2id memory cost (KiB). |
auth.argon2.time |
uint32 | 3 |
argon2id iterations. |
auth.argon2.threads |
uint8 | 2 |
argon2id lanes. |
Testing
- Unit tests (no DB):
internal/auth/password,internal/auth/token,internal/auth/email,internal/auth/reserved,internal/passwords. Run withgo test ./.... - DB-backed tests (skip when
SHITHUB_TEST_DATABASE_URLis unset):internal/auth/throttle,internal/web/handlers/auth. Use the dbtest harness (clone-from-template) for parallel safety.
The auth integration tests cover:
- Signup → verify → login → logout
- Password reset end-to-end
- Generic notice for unknown email at reset time (no enumeration)
- Login throttled after 6 failed attempts (with
Retry-After) - Login response time roughly equal between existing and missing usernames
- Reserved-name rejection
- Common-password rejection
- Honeypot silently accepted
Pitfalls / what to remember
- Don't leak existence via timing. Always call
password.VerifyAgainstDummyon missing-user login attempts. - Don't leak existence via reset. Always render the same notice regardless of whether the email maps to a real account.
- Username uniqueness is enforced by the
citext UNIQUEconstraint, not by a pre-check (TOCTOU race otherwise). - No password length cap at the application layer — but cap the request body so the argon2 hasher can't be weaponized.
html/templateHTML-escapes attribute values including+→+. Browsers decode this transparently; non-browser clients (e.g. test clients) need to callhtml.UnescapeString.- Reserved-name list is load-bearing — when adding a new top-level route, update
internal/auth/reserved.goin the same PR. - Email send failure must not break signup. The verify-resend flow handles delivery retries.
View source
| 1 | # Authentication |
| 2 | |
| 3 | S05 brings the first real domain surface to shithub: email/password signup, email verification, login, logout, password reset, and rate-limiting. This doc covers what's wired, where the code lives, and the security choices behind the design. |
| 4 | |
| 5 | ## What this sprint ships |
| 6 | |
| 7 | - Real users in Postgres: `users`, `user_emails`, `password_resets`, `email_verifications`, `auth_throttle`, `username_redirects`. |
| 8 | - argon2id password hashing with PHC string encoding. |
| 9 | - Strict username whitelist + reserved-name list. |
| 10 | - Common-password rejection from an embedded SecLists 10k corpus. |
| 11 | - Email Sender abstraction with stdout (dev), SMTP (MailHog/local), Postmark, and Resend backends. |
| 12 | - Counter-based rate-limiting backed by Postgres (no Redis dep). |
| 13 | - Auth handlers: signup, login, logout, password reset (request + confirm), email verification, verification resend. |
| 14 | - Constant-time login: missing usernames still trigger an argon2 hash against a pre-computed dummy. |
| 15 | - Generic password-reset response: no user-existence enumeration via that flow. |
| 16 | - 4 KiB request-body cap on auth POSTs (anti-DoS for the hashing path). |
| 17 | - Honeypot field on the signup form. |
| 18 | - `shithubd admin reset-password <username>` operator escape hatch. |
| 19 | |
| 20 | ## Out of scope (future sprints) |
| 21 | |
| 22 | - 2FA / TOTP (S06) |
| 23 | - SSH keys (S07) |
| 24 | - Personal access tokens (S08) |
| 25 | - OAuth providers (post-MVP) |
| 26 | - Profile / settings UI (S09 / S10) |
| 27 | |
| 28 | ## Tables (migrations 0002–0008) |
| 29 | |
| 30 | | Table | Purpose | Notes | |
| 31 | |---|---|---| |
| 32 | | `users` | identity + password material | `username citext UNIQUE`. PHC argon2id hash. Soft-deletable (`deleted_at`) and suspendable. | |
| 33 | | `user_emails` | one or more emails per user | `email citext UNIQUE` across all users. Partial unique index enforces one primary per user. | |
| 34 | | `password_resets` | reset tokens | `token_hash bytea UNIQUE`. 1-hour TTL. Single-use via `used_at`. | |
| 35 | | `email_verifications` | verification tokens | `token_hash bytea UNIQUE`. 24-hour TTL. Single-use via `used_at`. | |
| 36 | | `auth_throttle` | rate-limit counters | `(scope, identifier)` UNIQUE. Fixed-window. | |
| 37 | | `username_redirects` | username-change history | Old name → new user. Acts as a 30-day reservation (S10 wires the cooldown). | |
| 38 | |
| 39 | `citext` is enabled in migration 0002 for case-insensitive uniqueness without functional indexes. Display casing for users and emails lives in the same column — citext preserves it on read. |
| 40 | |
| 41 | ## Password hashing |
| 42 | |
| 43 | `internal/auth/password` implements argon2id using `golang.org/x/crypto/argon2`. Defaults: |
| 44 | |
| 45 | - Memory: 64 MiB (`m=65536`) |
| 46 | - Time: 3 iterations (`t=3`) |
| 47 | - Threads: 2 (`p=2`) |
| 48 | - Salt: 16 bytes |
| 49 | - Key: 32 bytes |
| 50 | |
| 51 | These are tuned for ~100–300 ms per Hash on dev hardware. Operators can override via `auth.argon2.*` config. The output is a canonical PHC string: |
| 52 | |
| 53 | ``` |
| 54 | $argon2id$v=19$m=65536,t=3,p=2$<saltB64>$<hashB64> |
| 55 | ``` |
| 56 | |
| 57 | Stored in `users.password_hash`; `users.password_algo` is `argon2id-v1` so we can roll forward later. |
| 58 | |
| 59 | A package-level `dummyEncoded` is computed on first use. Login handlers call `password.VerifyAgainstDummy` when the username doesn't exist so the response time matches a real failed-password verification (defense against username enumeration via timing). |
| 60 | |
| 61 | The minimum password length is 10 characters. There is **no** maximum — argon2id can hash arbitrary input — but the auth-POST middleware caps `r.Body` at 4 KiB so a misbehaving client can't ship a 10 MB password to weaponize the hashing path. |
| 62 | |
| 63 | ## Tokens |
| 64 | |
| 65 | `internal/auth/token` mints 32-byte cryptographically-random tokens, base64url-encodes them for inclusion in URLs/emails, and stores their sha256 hash in the DB. The raw token never touches disk. Lookups parse the URL fragment, hash, and query by hash with a UNIQUE-index O(1) seek. Comparisons use `crypto/subtle.ConstantTimeCompare`. |
| 66 | |
| 67 | ## Reserved-name list |
| 68 | |
| 69 | `internal/auth/reserved.go` maintains a set of usernames that may not be claimed: every static top-level route shithub registers, GitHub-known reservations we mirror for parity, and a buffer of likely-future routes. Signup checks this list (case-insensitive — `LOGIN` is rejected the same as `login`). |
| 70 | |
| 71 | When a future sprint adds a new top-level route, the leading path segment **must** be added here. The intended audit mechanism: `internal/web/handlers/handlers_test.go` should walk the registered chi routes and check every static segment against `auth.ReservedNames()`. (Stub-level today; full route-walking lands when the route surface stabilizes in S11+.) |
| 72 | |
| 73 | ## Common-password list |
| 74 | |
| 75 | `internal/passwords` embeds the SecLists `10k-most-common.txt` corpus (~73 KB on disk). `IsCommon` is case-insensitive. Used at signup and password-reset. |
| 76 | |
| 77 | To refresh: replace `internal/passwords/common_passwords.txt` with a newer SecLists snapshot and re-run the test suite. |
| 78 | |
| 79 | ## Email service |
| 80 | |
| 81 | `internal/auth/email` defines the `Sender` interface. Four implementations: |
| 82 | |
| 83 | - `StdoutSender` — writes a human-readable dump to a writer. Default in dev when no SMTP is configured. Convenient for tests. |
| 84 | - `SMTPSender` — plain SMTP for MailHog locally. Authenticated and TLS-upgrade variants supported. |
| 85 | - `PostmarkSender` — Postmark transactional API. |
| 86 | - `ResendSender` — Resend transactional API (https://resend.com). Comparable feature set to Postmark; preferred where onboarding speed matters (no human approval queue). |
| 87 | |
| 88 | `messages.go` hosts the `VerifyMessage` and `ResetMessage` builders. Both produce HTML + plaintext bodies — every transactional email shithub sends works in plain-text-only clients. Templates are inlined (short, rarely change); when they grow, promote to `templates/email/*.{html,txt}`. |
| 89 | |
| 90 | ### Wiring |
| 91 | |
| 92 | `auth.email_backend` chooses the implementation: `stdout | smtp | postmark | resend`. The `smtp` backend additionally requires `auth.smtp.addr`; `postmark` requires `auth.postmark.server_token`; `resend` requires `auth.resend.api_key`. Validation enforces these. |
| 93 | |
| 94 | ```sh |
| 95 | # Dev: capture mail in MailHog |
| 96 | make dev-email |
| 97 | SHITHUB_AUTH__EMAIL_BACKEND=smtp ./bin/shithubd web |
| 98 | # Open http://127.0.0.1:8025 to read captured messages |
| 99 | ``` |
| 100 | |
| 101 | ## Rate limiting |
| 102 | |
| 103 | `internal/auth/throttle` implements fixed-window counters via the `auth_throttle` table. The `BumpAuthThrottle` query atomically increments the counter for `(scope, identifier)` or starts a new window if the existing one is older than `(now - Window)`. |
| 104 | |
| 105 | Limits enforced by the auth handlers: |
| 106 | |
| 107 | | Scope | Identifier | Max | Window | Where | |
| 108 | |---|---|---|---|---| |
| 109 | | `signup` | `ip:<client-ip>` | 5 | 1h | `signupSubmit` | |
| 110 | | `login` | `ip:<client-ip>\|<username>` | 6 | 15m | `loginSubmit` (reset on success) | |
| 111 | | `reset` | `email:<addr>` | 3 | 1h | `resetRequestSubmit` | |
| 112 | |
| 113 | 429 responses include a `Retry-After` header. |
| 114 | |
| 115 | We deliberately use Postgres rather than introducing Redis. At launch scale this is well within Postgres's comfort zone, and avoiding a new dep is worth the marginal latency. Migrate if S36 proves it necessary. |
| 116 | |
| 117 | ## Sessions |
| 118 | |
| 119 | S02 ships an AEAD-encrypted cookie store; S05 extends it to carry `user_id`. `Session.IsAnonymous()` reports whether a user is bound. The cookie store re-encrypts on every Save, producing a fresh ciphertext — that's our defense against session fixation. |
| 120 | |
| 121 | Login flow: |
| 122 | |
| 123 | 1. Verify password. |
| 124 | 2. Mutate the loaded session to set `UserID` and `IssuedAt`. |
| 125 | 3. Call `SessionStore.Save` — re-encrypts the cookie under the new state. |
| 126 | |
| 127 | Logout flow: |
| 128 | |
| 129 | 1. `SessionStore.Clear` — deletes the cookie. |
| 130 | |
| 131 | ## Auth middleware |
| 132 | |
| 133 | `internal/web/middleware/auth.go` adds: |
| 134 | |
| 135 | - `OptionalUser(lookup)` — populates `CurrentUser{ID, Username}` into context when the session has a `user_id`. Anonymous requests still pass through. |
| 136 | - `RequireUser` — redirects to `/login?next=<requested-path>` for anonymous requests. |
| 137 | - `MaxBodySize(n)` — wraps `r.Body` in `http.MaxBytesReader(w, r.Body, n)`. Used on auth POSTs (4 KiB). |
| 138 | |
| 139 | `CurrentUserFromContext(ctx)` returns the bound `CurrentUser` (zero value when anonymous). |
| 140 | |
| 141 | ## CSRF |
| 142 | |
| 143 | S02's `nosurf` wrapper guards every state-changing route. S05 fixes a wart: nosurf's default `isTLS` returns true unconditionally, which makes its same-origin Referer check require an `https` scheme even on plain-HTTP requests. We set `isTLS` to a function that consults `r.TLS` and `X-Forwarded-Proto: https` so dev (HTTP) and prod (TLS-terminated) both work correctly. |
| 144 | |
| 145 | ## Signup → verify → login → logout |
| 146 | |
| 147 | End-to-end flow exercised in `internal/web/handlers/auth/auth_test.go::TestSignup_Verify_Login_Logout`: |
| 148 | |
| 149 | 1. `GET /signup` — render form. CSRF cookie set by nosurf. |
| 150 | 2. `POST /signup` — validate username (whitelist + reserved-name list), email shape, password length, common-password check. Hash password. In one transaction: create user, create user_email (unverified), set `users.primary_email_id`, create email_verifications row. Send verification email (best-effort — SMTP failure does not break signup). Redirect to `/login?notice=signup-pending`. |
| 151 | 3. `GET /verify-email/{token}` — hash the token, look up the row, validate (not used, not expired). In one transaction: mark the email verified, flip `users.email_verified` if it's the primary, mark the verification row `used_at`. Redirect to `/login?notice=verified`. |
| 152 | 4. `POST /login` — load user (constant-time fallback if missing), Verify password, check suspended/verified flags, reset throttle counter, touch `last_login_at`, mutate session with `user_id`, Save (re-encrypts cookie), redirect to `/` (or `?next=` target if it's a relative path). |
| 153 | 5. `POST /logout` — Clear session. Redirect to `/login?notice=logged-out`. |
| 154 | |
| 155 | ## Password reset |
| 156 | |
| 157 | `POST /password/reset` always responds with the same generic notice — "If an account is registered to that address, we've sent a password-reset link." — whether or not the email exists. No email is sent for unknown addresses. This is the canonical defense against enumeration via the reset flow. |
| 158 | |
| 159 | `POST /password/reset/{token}` validates the token (lookup by sha256, not used, not expired), enforces the password policy (length + common-password), hashes via argon2id, updates `users.password_hash`/`password_algo`/`password_updated_at` and marks the reset row consumed — atomically in one transaction. Redirects to `/login?notice=password-reset`. |
| 160 | |
| 161 | ## Honeypot |
| 162 | |
| 163 | The signup form has a hidden `company` field positioned off-screen with `tabindex="-1"`. Bots tend to fill every field they see; humans don't. Non-empty submissions are silently treated as success (303 redirect to the same `/login?notice=signup-pending` page) so bots can't detect the trap. |
| 164 | |
| 165 | ## Admin escape hatch |
| 166 | |
| 167 | ```sh |
| 168 | shithubd admin reset-password <username> |
| 169 | ``` |
| 170 | |
| 171 | Generates a fresh password-reset token (1-hour TTL), persists it, and emails the link to the user's primary email via the configured backend. Useful when a locked-out user can't drive the public reset flow themselves. |
| 172 | |
| 173 | ## Configuration |
| 174 | |
| 175 | All auth settings flow through `internal/infra/config` (see `docs/internal/config.md`): |
| 176 | |
| 177 | | Key | Type | Default | Notes | |
| 178 | |---|---|---|---| |
| 179 | | `auth.require_email_verification` | bool | `true` | When true, login is rejected until the primary email is verified. | |
| 180 | | `auth.base_url` | string | `http://127.0.0.1:8080` | Used for absolute links in emails. | |
| 181 | | `auth.site_name` | string | `shithub` | Branding token for email subjects/bodies. | |
| 182 | | `auth.email_from` | string | `shithub <noreply@shithub.local>` | Envelope From for outgoing email. | |
| 183 | | `auth.email_backend` | string | `stdout` | `stdout | smtp | postmark | resend`. | |
| 184 | | `auth.smtp.addr` | string | `127.0.0.1:1025` | Required when `email_backend=smtp`. | |
| 185 | | `auth.smtp.username` | string | `""` | Optional. | |
| 186 | | `auth.smtp.password` | string | `""` | Optional. Redacted by `config print`. | |
| 187 | | `auth.postmark.server_token` | string | `""` | Required when `email_backend=postmark`. Redacted. | |
| 188 | | `auth.resend.api_key` | string | `""` | Required when `email_backend=resend`. Redacted. | |
| 189 | | `auth.argon2.memory_kib` | uint32 | `65536` | argon2id memory cost (KiB). | |
| 190 | | `auth.argon2.time` | uint32 | `3` | argon2id iterations. | |
| 191 | | `auth.argon2.threads` | uint8 | `2` | argon2id lanes. | |
| 192 | |
| 193 | ## Testing |
| 194 | |
| 195 | - **Unit tests** (no DB): `internal/auth/password`, `internal/auth/token`, `internal/auth/email`, `internal/auth/reserved`, `internal/passwords`. Run with `go test ./...`. |
| 196 | - **DB-backed tests** (skip when `SHITHUB_TEST_DATABASE_URL` is unset): `internal/auth/throttle`, `internal/web/handlers/auth`. Use the dbtest harness (clone-from-template) for parallel safety. |
| 197 | |
| 198 | The auth integration tests cover: |
| 199 | - Signup → verify → login → logout |
| 200 | - Password reset end-to-end |
| 201 | - Generic notice for unknown email at reset time (no enumeration) |
| 202 | - Login throttled after 6 failed attempts (with `Retry-After`) |
| 203 | - Login response time roughly equal between existing and missing usernames |
| 204 | - Reserved-name rejection |
| 205 | - Common-password rejection |
| 206 | - Honeypot silently accepted |
| 207 | |
| 208 | ## Pitfalls / what to remember |
| 209 | |
| 210 | - **Don't leak existence via timing.** Always call `password.VerifyAgainstDummy` on missing-user login attempts. |
| 211 | - **Don't leak existence via reset.** Always render the same notice regardless of whether the email maps to a real account. |
| 212 | - **Username uniqueness** is enforced by the `citext UNIQUE` constraint, not by a pre-check (TOCTOU race otherwise). |
| 213 | - **No password length cap** at the application layer — but cap the request body so the argon2 hasher can't be weaponized. |
| 214 | - **`html/template` HTML-escapes attribute values** including `+` → `+`. Browsers decode this transparently; non-browser clients (e.g. test clients) need to call `html.UnescapeString`. |
| 215 | - **Reserved-name list is load-bearing** — when adding a new top-level route, update `internal/auth/reserved.go` in the same PR. |
| 216 | - **Email send failure must not break signup.** The verify-resend flow handles delivery retries. |