package auth // import "git.griefed.de/griefed/repoman/internal/auth" Package auth implements repoman's password hashing, session management, two-factor authentication, and role-based access control. The package is deliberately self-contained: nothing in here imports from internal/server or internal/db, so it can be unit-tested in isolation. The DB is reached only through the small set of interfaces declared at the top of session.go. CONSTANTS const ( ReasonUnknownUser = "unknown_user" ReasonInactiveUser = "inactive_user" // includes hard-locked ReasonSoftLocked = "soft_locked" ReasonBadPassword = "bad_password" ReasonInternalError = "internal_error" ) Reason codes for LoginError. Stable identifiers for audit logs and metrics; the user-facing error string is "Invalid credentials" regardless of which reason fired. const MinPasswordLength = 12 MinPasswordLength is enforced by ValidatePassword. NIST 2024 favours length over complexity rules; we set the floor at 12 characters and rely on the leaked-password list to catch obviously-weak choices. const TOTPIssuer = "repoman" TOTPIssuer is the label that shows up in the user's authenticator app. Hardcoded to "repoman" so all installs share a recognisable name; the per-install distinction comes from the username segment of the otpauth URL. VARIABLES var ( ErrPasswordTooShort = errors.New("password is too short") ErrPasswordTooCommon = errors.New("password is too common; choose a less guessable one") ErrInvalidHashFormat = errors.New("invalid password hash format") ) ErrPasswordTooShort and ErrPasswordTooCommon are returned by ValidatePassword. They are exported so handlers can format localised user-facing messages without string-matching. var AllRoles = []Role{RoleAdmin, RoleOperator, RoleReadOnly} AllRoles is used by the user-management UI to render dropdowns and by validation helpers. Order is meaningful: most privileged first. var CommonTimezones = []string{ "UTC", "Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver", "America/Chicago", "America/New_York", "America/Sao_Paulo", "Atlantic/Reykjavik", "Europe/London", "Europe/Lisbon", "Europe/Madrid", "Europe/Paris", "Europe/Berlin", "Europe/Amsterdam", "Europe/Rome", "Europe/Zurich", "Europe/Stockholm", "Europe/Warsaw", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Moscow", "Africa/Cairo", "Africa/Johannesburg", "Asia/Jerusalem", "Asia/Istanbul", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", "Asia/Shanghai", "Asia/Singapore", "Asia/Hong_Kong", "Asia/Tokyo", "Asia/Seoul", "Australia/Perth", "Australia/Sydney", "Pacific/Auckland", } CommonTimezones is a curated set of IANA zones offered in the profile dropdown — enough to cover most users without listing all ~600 zones. The client augments this with the browser-detected zone (and a user's saved zone is always added if missing), so the list need not be exhaustive; any valid IANA name still validates server-side via ValidTimezone. var ErrInvalidRecoveryCode = errors.New("invalid recovery code") ErrInvalidRecoveryCode is returned when no recovery code matches. var ErrInvalidTOTPCode = errors.New("invalid TOTP code") ErrInvalidTOTPCode is returned when the supplied code does not match the user's TOTP secret for any of the accepted time windows. var ErrNotFound = errors.New("not found") ErrNotFound is returned by repository implementations when the requested record does not exist. Auth-layer code uses this to distinguish "user exists, password wrong" (privacy-leak) from "user does not exist" (also privacy-leak — both cases return the same generic error to the caller). var ErrSessionExpired = errors.New("session expired") ErrSessionExpired is returned by SessionManager.Get when the session is found in storage but past its expiry. The caller should treat this identically to ErrNotFound; it is split out so audit logging can distinguish "session was deleted by logout" (not found) from "session timed out" (expired) when those signals are available. var ErrTOTPNotEnrolled = errors.New("user has not enrolled 2FA") ErrTOTPNotEnrolled is returned by the verify helpers when the user has no TOTP secret on file. Handlers should redirect such users directly to the dashboard rather than rendering a 2FA form. FUNCTIONS func BuildResetURL(baseURL string, token *IssuedToken) string BuildResetURL constructs the URL operators put in the reset email. Uses the query-parameter variant per the project decision: {baseURL}/auth/reset?token=<rawToken>&id=<tokenID> baseURL is the externally-visible URL of the repoman instance (e.g. "https://repoman.example.com"); trailing slashes are trimmed so the joined URL is always well-formed. func CompleteEnrollment( ctx context.Context, users TOTPUserRepository, userID int64, candidateSecret string, verificationCode string, ) ([]string, error) CompleteEnrollment verifies the user-supplied code against the candidate secret and, on success, persists the secret AND a fresh set of recovery codes. Returns the plaintext recovery codes so the handler can show them to the user — they are NOT recoverable later. The persisted recovery codes are bcrypt-hashed; a database leak does not yield usable codes. func GenerateRandomPassword(byteLen int) (string, error) GenerateRandomPassword returns a cryptographically random human-typeable password. byteLen is the entropy budget; the returned string has ceil(byteLen * 4 / 3) base64-url characters. For interactive use (admin reset, emergency reset) 18 bytes is a good default — 24 characters at 6 bits each = 144 bits of entropy, plenty against any guessing budget. The output uses URL-safe base64 (no `+/=`), which is also paste- friendly: no quoting needed in shells, no line-wrapping in emails. func GenerateTemporaryPassword() (string, error) GenerateTemporaryPassword produces a cryptographically-random 16-character URL-safe password for admin-initiated resets. The user will be required to change it on next login. func HashPassword(password string, pepper Pepper) (string, error) HashPassword produces an argon2id-encoded hash of `password` with the application-wide pepper mixed in. The returned string follows the standard argon2 reference format: $argon2id$v=19$m=65536,t=3,p=4$<base64 salt>$<base64 hash> All parameters are embedded so VerifyPassword can read them back. This means we can change the parameters in code without breaking existing logins. func ValidTimezone(name string) bool ValidTimezone reports whether name is an acceptable user timezone: either the empty string (meaning UTC, the default) or an IANA name that time.LoadLocation can resolve. Validating through LoadLocation keeps the stored value to real zones without hardcoding the full IANA database. func ValidatePassword(password string) error ValidatePassword checks whether a candidate password meets the project's policy. Returns nil if acceptable. Rules (matching the README): 1. Minimum length MinPasswordLength 2. Not present in the embedded leaked-password list We deliberately do NOT enforce character-class rules ("must contain digit / uppercase / symbol"). NIST SP 800-63B (2024) recommends against them — they push users toward predictable patterns (Password1!) while making strong passphrases harder to type. func VerifyPassword(password, encoded string, pepper Pepper) error VerifyPassword compares a candidate password against a stored hash. Returns nil on match, an error otherwise. The pepper from the running process is mixed in before hashing, matching what HashPassword did at storage time. Uses subtle.ConstantTimeCompare to avoid timing side-channels that could otherwise leak information about how many bytes of the hash matched. func VerifyRecoveryAndPromote( ctx context.Context, users TOTPUserRepository, sessions *SessionManager, user *User, session *Session, code string, ) error VerifyRecoveryAndPromote is the recovery-code variant of the above. func VerifyRecoveryCode( ctx context.Context, users TOTPUserRepository, user *User, candidate string, ) error VerifyRecoveryCode checks a recovery code against the user's stored hashed-code list. On success, the matched code is removed from the list and the updated list is persisted. The same code cannot be used twice. We iterate over every remaining hash because bcrypt does not allow pre-indexing; this is acceptable because the list is tiny (max 10). func VerifyTOTP(user *User, code string) error VerifyTOTP checks a code against the user's stored TOTP secret. Returns ErrTOTPNotEnrolled if the user has no secret, ErrInvalidTOTPCode on mismatch, or nil on success. The TOTP library by default tolerates a one-step skew (the previous and next 30-second windows). This is standard and protects against minor clock drift. func VerifyTOTPAndPromote( ctx context.Context, sessions *SessionManager, user *User, session *Session, code string, ) error ── 2FA-aware Authenticate add-on ──────────────────────────────── VerifyTOTPAndPromote is called by the /auth/2fa handler after the user successfully provided a TOTP code following the password step. On success it promotes the session out of pending_2fa state. TYPES type Authenticator struct { Users UserRepository Sessions *SessionManager Pepper Pepper SecLog *security.Logger // may be nil; callers wrap a no-op via NewLogger("") Settings func() Settings // returns current settings; called per request Now func() time.Time } Authenticator orchestrates username+password login, including the multi-stage lockout state machine. Settings is read on every Authenticate call rather than cached at construction time so configuration changes take effect immediately without requiring a daemon restart. The cost is a single config lookup per login attempt, which is negligible compared to the argon2id hash. func NewAuthenticator( users UserRepository, sessions *SessionManager, pepper Pepper, secLog *security.Logger, settings func() Settings, ) *Authenticator NewAuthenticator wires up an Authenticator. settings is a function (not a value) so the auth subsystem always sees the latest config without a mutex of its own. func (a *Authenticator) Authenticate( ctx context.Context, username, password string, ip, userAgent string, ) (*LoginResult, error) Authenticate validates a username + password pair and, on success, creates a session. State-machine summary: 1. User unknown → ReasonUnknownUser Constant-time padding hash performed against fakeHashForTimingPadding. 2. User inactive (deactivated or hard-locked) → ReasonInactiveUser 3. User in soft-lockout window → ReasonSoftLocked 4. Bad password a. failed_login_count incremented b. If new count == FailedAttemptsThreshold: - If consecutive_lockouts already at hard threshold-1: HARD lockout (is_active=0); no soft-lock window. - Else: soft-lock for SoftLockoutDuration; consecutive_lockouts++. If it now hits hard threshold, upgrade to hard immediately. c. → ReasonBadPassword 5. Good password a. failed_login_count → 0 b. consecutive_lockouts → 0 c. last_login_at updated d. session created (Pending2FA if user has TOTP enrolled) All security-relevant transitions are logged to the security log (for fail2ban) and to the audit log (caller's responsibility — this function returns success/failure information so the caller can emit the audit entry with the correct actor context). type EnrollmentSecret struct { Secret string // base32 — for manual entry in apps that don't scan URL string // otpauth:// URL for QR-code rendering AccountName string // typically the username } EnrollmentSecret is what GenerateEnrollment returns to a handler that's about to display a QR code for setup. The Secret should NOT be persisted yet — it becomes the user's TOTPSecret only after they prove they can read codes from it (CompleteEnrollment). func GenerateEnrollment(username string) (*EnrollmentSecret, error) GenerateEnrollment creates a fresh TOTP secret and the otpauth URL for the user to add to their authenticator app. The caller renders the URL as a QR code and shows it next to a verification form. The secret is NOT stored in the user record by this function. Persistence happens in CompleteEnrollment, after the user proves they have correctly added the secret to their app. type IssuedToken struct { ID int64 Plain string } IssuedToken is what RequestReset returns to the caller. The Plain value is the raw token to embed in the reset email URL; the ID is the row identifier the user will send back when consuming. IssuedToken is only returned for legitimate reset requests against existing accounts. For non-existent emails, RequestReset returns (nil, nil) — the caller treats both cases identically (silently proceed, "if your address exists you'll get an email") to avoid leaking which addresses are registered. type LockoutSettings struct { FailedAttemptsThreshold int `json:"failed_attempts_threshold"` SoftLockoutDurationMinutes int `json:"soft_lockout_duration_minutes"` ConsecutiveSoftLockoutsBeforeHard int `json:"consecutive_soft_lockouts_before_hard"` FailedAttemptsWindowHours int `json:"failed_attempts_window_hours"` MaxFailedAttempts int `json:"max_failed_attempts"` SoftLockDuration time.Duration `json:"soft_lock_duration"` HardLockAfterSoftLocks int `json:"hard_lock_after_soft_locks"` } LockoutSettings drives the multi-stage lockout state machine. 1. Each failed login increments failed_login_count. 2. When failed_login_count >= FailedAttemptsThreshold, the user is soft-locked for SoftLockoutDuration. consecutive_lockouts is incremented. 3. When consecutive_lockouts >= ConsecutiveSoftLockoutsBeforeHard, the soft lockout is upgraded to a hard lockout (is_active=0) and only an admin can re-enable the account. All durations are stored in human-friendly units (minutes/hours) so the JSON config is editable without unit conversion. The Go-side accessors return time.Duration. func (l LockoutSettings) FailedAttemptsWindow() time.Duration FailedAttemptsWindow returns the failed-attempts counting window. Failed logins older than this window are considered stale and the counter is implicitly reset on the next attempt. func (l LockoutSettings) SoftLockoutDuration() time.Duration SoftLockoutDuration returns the soft-lockout duration as a time.Duration. type LoginError struct { Reason string // machine-readable code: see Reason* constants below } LoginError captures everything a handler needs to know about a rejected login without revealing it to the user. The user always sees the same generic error; LoginError is for audit logging. func (e *LoginError) Error() string Error renders the login failure with its machine-readable reason code (for logs). type LoginResult struct { Session *Session User *User Pending2FA bool MustChangePassword bool } LoginResult is the success-case return value of Authenticate. Pending2FA tells the handler whether to set the session cookie and then redirect to /auth/2fa, or send the user straight to the dashboard. MustChangePassword propagates from the user record so the handler can redirect to the forced-change page after the (optional) 2FA step. type PasswordResetService struct { Users UserRepository Tokens PasswordResetTokenRepository Sessions SessionRepository Pepper Pepper SecLog *security.Logger // may be nil Settings func() Settings Now func() time.Time } PasswordResetService coordinates the email-based password-reset flow. It is split out from Authenticator because it has a different failure model (security-log + audit, no mutating user state until the user actually completes the reset). func NewPasswordResetService( users UserRepository, tokens PasswordResetTokenRepository, sessions SessionRepository, pepper Pepper, secLog *security.Logger, settings func() Settings, ) *PasswordResetService NewPasswordResetService wires up the service with default time func. func (s *PasswordResetService) ConsumeReset( ctx context.Context, tokenID int64, rawToken, newPassword, requesterIP string, ) error ConsumeReset validates a reset request and, on success, updates the user's password and invalidates ALL their sessions. The function is intentionally strict about every failure path emitting a security-log entry — the operator wants to feed those to fail2ban. Reasons logged: - "id_not_found" : tokenID does not exist in DB - "expired" : token is past its expires_at - "already_used" : token has a non-NULL used_at - "hash_mismatch" : tokenID exists but rawToken doesn't match - "user_not_found" : token's user was deleted between issue and use - "inactive_account" : user was deactivated between issue and use - "weak_password" : new password failed ValidatePassword On success, two log entries fire: reset_token_consumed (security) and the caller is expected to add an audit_log entry with full actor context. func (s *PasswordResetService) RequestReset( ctx context.Context, email, requesterIP string, ) (*IssuedToken, error) RequestReset attempts to issue a reset token for the given email. Returns (token, nil) when a token was issued, (nil, nil) when the email is not registered (the caller MUST treat this identically to the issued case to prevent enumeration), or an error for actual system failures. Rate-limit policy is enforced here: if the email has already requested MaxRequestsPerHourPerEmail tokens in the last hour, no new token is issued. This too is silent to the user — the caller gets the same (nil, nil) as for unknown email — so the rate limit can't be probed. type PasswordResetSettings struct { TokenValidityMinutes int `json:"token_validity_minutes"` MaxRequestsPerHourPerEmail int `json:"max_requests_per_hour_per_email"` } PasswordResetSettings tunes the email-based reset flow. MaxRequestsPerHourPerEmail is the inbox-flood guard: a single email address can request at most this many tokens per hour. The repository-level CountRecentForEmail enforces this. func (p PasswordResetSettings) TokenValidity() time.Duration TokenValidity returns the reset-token lifetime as a Duration. type PasswordResetToken struct { ID int64 UserID int64 TokenHash string CreatedAt time.Time ExpiresAt time.Time UsedAt *time.Time RequesterIP string } PasswordResetToken mirrors password_reset_tokens. The raw token value is never stored or returned by repository methods — only the hash. Issuance returns the raw token to the caller exactly once so it can be embedded in an outbound email. func (t *PasswordResetToken) IsValid(now time.Time) bool IsValid reports whether the token can still be consumed: not expired, not yet used. type PasswordResetTokenRepository interface { Create(ctx context.Context, t *PasswordResetToken) error GetValidByID(ctx context.Context, id int64, now time.Time) (*PasswordResetToken, error) MarkUsed(ctx context.Context, id int64, at time.Time) error // CountRecentForEmail returns how many tokens have been issued for // the given email within the given window. Used to rate-limit reset // requests so a malicious party cannot flood a user's inbox. CountRecentForEmail(ctx context.Context, email string, since time.Time) (int, error) } PasswordResetTokenRepository persists reset tokens. type Pepper []byte Pepper is a per-installation secret mixed into every password hash before the argon2id call. It is *not* stored in the database; an attacker with only DB access cannot use the hashes for offline cracking unless they also obtain the pepper. The pepper is loaded once at startup. Rotation requires re-hashing every user's password, which is not currently supported — a future migration helper will add that capability. func LoadPepper(pepperPath string) (Pepper, error) LoadPepper resolves the per-installation password pepper according to the precedence rules documented in the README: 1. Environment variable REPOMAN_PEPPER (base64-encoded), if set and non-empty. This is the recommended path for hardened deployments because the pepper never lives on the same disk as the SQLite database. 2. Pepper file at `pepperPath`, if it exists. Auto-generated on first start. On first start in a fresh deployment, neither source exists. We generate 32 random bytes and write them to pepperPath with mode 0600. The directory must exist (typically the same dir as the SQLite file). Returning an error rather than panicking on permission issues is deliberate: the caller (cmd/repoman) prints a clear message pointing at the file path and the umask before exiting. type Permission string Permission is a coarse-grained capability. Adding a permission means adding a constant here AND extending the Role.Can() switch below; keeping the two close together ensures we cannot ship a role that silently grants nothing. We deliberately do NOT model fine-grained per-source permissions (e.g. "may trigger GitHub but not GitLab"). Practice has shown that such matrices balloon fast and become a long-tail bug source. If a finer split is ever needed, it should be done as an explicit follow-up design rather than slipped in here. const ( // PermViewConfig — see the current configuration. All authenticated // users have this; it is split out from PermEditConfig because // read-only auditors must be able to see what is configured without // being able to change it. PermViewConfig Permission = "view_config" // PermEditConfig — create and manage migration configs. Admin and // operator (multi-tenant: users manage their OWN configs). This is the // coarse role gate; per-config ownership is enforced in the handlers // (admins manage any config, operators only the ones they own). Read-only // cannot edit. (Before multi-tenancy this was admin-only; ownership now // provides the separation that the admin-only rule used to.) PermEditConfig Permission = "edit_config" // PermTriggerRun — start a migration run, including dry-runs. // Admin and operator. Read-only cannot trigger anything. PermTriggerRun Permission = "trigger_run" // PermStopRun — cancel an in-progress run. Same audience as // PermTriggerRun for symmetry: whoever can start can stop. PermStopRun Permission = "stop_run" // PermViewLogs — see run logs and the inventory page. Granted to // every authenticated user including read-only auditors. PermViewLogs Permission = "view_logs" // PermManageUsers — create/edit/delete user accounts, reset // passwords and 2FA. Admin only. PermManageUsers Permission = "manage_users" // PermViewAuditLog — read the audit log. Admin only. Operators // and read-only users cannot see who logged in when, by design: // the audit log is for accountability, not visibility. PermViewAuditLog Permission = "view_audit_log" // PermManageSettings — save global settings (Forgejo URL/token, SMTP, // auth policy). Admin only. PermManageSettings Permission = "manage_settings" // PermBackupRestore — trigger a backup or restore via the web UI. // Restore is also gated behind a CLI confirmation, but the UI // path is admin-only. PermBackupRestore Permission = "backup_restore" ) type Role string Role represents a user's authorization level. The string form is used as the on-the-wire and on-disk representation; the typed alias gives us compile-time protection against typos in handler code. const ( RoleAdmin Role = "admin" RoleOperator Role = "operator" RoleReadOnly Role = "readonly" ) func (r Role) Can(permission Permission) bool Can reports whether the role grants the given permission. The full matrix is laid out explicitly rather than computed from a table so it is grep-friendly and easy to audit during code review. Mapping (all combinations explicit, no implicit inheritance): admin operator readonly view_config yes yes yes edit_config yes yes* no (* own configs only — see canManageConfig) trigger_run yes yes* no (* own configs only) stop_run yes yes* no (* own configs only) view_logs yes yes yes manage_users yes no no view_audit_log yes no no backup_restore yes no no func (r Role) String() string String makes Role satisfy fmt.Stringer for log/slog formatting. func (r Role) Valid() bool Valid returns true when r is one of the three known roles. Used at the DB boundary and by the user-creation handler to reject typos. type Session struct { ID string UserID int64 CSRFToken string Pending2FA bool IP string UserAgent string CreatedAt time.Time LastSeenAt time.Time ExpiresAt time.Time // LastActivityAt is the last time this session saw genuine USER activity (a // top-level navigation or an explicit keepalive on interaction), as opposed // to LastSeenAt which every request bumps. It drives the server-side idle // timeout so a backgrounded tab whose JS countdown is frozen still expires. LastActivityAt time.Time } Session mirrors the sessions table. Cookie sent to the browser contains only ID; everything else stays server-side. func (s *Session) IsExpired(now time.Time) bool IsExpired reports whether the session has passed its absolute expiry. Sliding expiry (LastSeenAt-based) is enforced by the session manager when each request is served. type SessionManager struct { Repo SessionRepository Now func() time.Time Settings func() Settings } SessionManager owns the session lifecycle. It is a thin layer over the SessionRepository that handles ID generation, cookie semantics, and time-based expiry. Time is injected via Now so the rest of the package — and especially the tests — can drive a deterministic clock. Production wiring sets Now = time.Now. Settings is a function (not a value) so an operator-driven config change takes effect on the next request without a daemon restart, matching the same pattern used by Authenticator. func NewSessionManager(repo SessionRepository, settings func() Settings) *SessionManager NewSessionManager constructs a manager. settings is called on every Create and Get to pick up the current operator-configured session lifetimes. Passing nil falls back to DefaultSettings, which has the same values as the old hardcoded constants. func (sm *SessionManager) CleanupExpired(ctx context.Context) (int, error) CleanupExpired deletes sessions that have already expired. Intended to be called from a periodic background job; the front door also lazily evicts on Get, so this is purely housekeeping. func (sm *SessionManager) Create( ctx context.Context, userID int64, pending2FA bool, ip, userAgent string, ) (*Session, error) Create issues a new session for the given user. The returned session holds the random ID that the caller MUST send to the browser as the session cookie. The CSRF token is generated alongside and included. If pending2FA is true, the session is restricted: middleware will only allow it to reach the /auth/2fa endpoints. This is used between the password step and the TOTP step of a 2FA login. IP and UA are stored for audit purposes; they are not used for any security decisions because mobile users routinely change networks. func (sm *SessionManager) Delete(ctx context.Context, id string) error Delete removes a single session (logout from one device). func (sm *SessionManager) DeleteAllForUser(ctx context.Context, userID int64) error DeleteAllForUser invalidates every session belonging to a user. Called after a password change, an admin-driven 2FA reset, or an explicit "log out everywhere" action. func (sm *SessionManager) DeleteAllForUserExcept(ctx context.Context, userID int64, exceptID string) error DeleteAllForUserExcept invalidates every session belonging to a user except exceptID. Called after a self-service password change to keep the current session alive while revoking all others ("log out other devices"). func (sm *SessionManager) Get(ctx context.Context, id string) (*Session, error) Get fetches a session by ID and refreshes its expiry if appropriate. Behaviour: - Session not found → ErrNotFound - Session found but past ExpiresAt → ErrSessionExpired and the session is deleted from storage as a side effect - Session found but past CreatedAt + AbsoluteMax → also expired - Session valid → LastSeenAt is bumped to now and ExpiresAt is extended by RollingWindow, capped at CreatedAt + AbsoluteMax. The updated values are persisted. The caller does not need to know about absolute caps; it just gets either a valid session or an error. func (sm *SessionManager) Promote2FA(ctx context.Context, id string) error Promote2FA marks a session as no longer pending 2FA. Called after the user has successfully provided a TOTP code following password authentication. func (sm *SessionManager) Touch(ctx context.Context, id string) error Touch records genuine user activity on a session, resetting the server-side idle clock. The middleware calls it on top-level navigations and the keepalive endpoint calls it on throttled in-page interaction — never for background traffic, so an idle tab still times out. Best-effort: callers log and ignore the error (a missed bump just means the idle window is measured from a slightly earlier activity). type SessionRepository interface { Create(ctx context.Context, s *Session) error GetByID(ctx context.Context, id string) (*Session, error) // UpdateLastSeen records the observation time and the new, already-capped // expiry that SessionManager computed. The expiry is passed in (not derived // in SQL) so the rolling-window setting and absolute cap are honoured and // both timestamps are stored in the canonical RFC3339 format. UpdateLastSeen(ctx context.Context, id string, lastSeen, expiresAt time.Time) error // UpdateActivity records the last genuine user-activity time, bumped only on // navigations + interaction keepalives (not background traffic). Backs the // server-side idle timeout. UpdateActivity(ctx context.Context, id string, at time.Time) error UpdatePending2FA(ctx context.Context, id string, pending bool) error Delete(ctx context.Context, id string) error DeleteAllForUser(ctx context.Context, userID int64) error // DeleteAllForUserExcept invalidates every session belonging to a // user except the one with exceptID. Used after a self-service // password change so the user stays logged in on the current device // while every other session is revoked. DeleteAllForUserExcept(ctx context.Context, userID int64, exceptID string) error DeleteExpired(ctx context.Context, now time.Time) (int, error) } SessionRepository persists web sessions. type SessionSettings struct { RollingWindowHours int `json:"rolling_window_hours"` AbsoluteMaxHours int `json:"absolute_max_hours"` // AutoLogoutMinutes is the client-side idle-timeout before the // browser is automatically signed out. The countdown resets on // every page load (i.e. any navigation or refresh). Set to 0 to // use the default (30 minutes). // // This is intentionally shorter than RollingWindowHours so that // a shared/public machine gets signed out promptly when the user // walks away, without requiring the server session to expire first. // The server session is the authoritative source of truth; // AutoLogout is an additional UX safety net. AutoLogoutMinutes int `json:"auto_logout_minutes"` } SessionSettings tunes session lifetimes. Both values are caps applied by the SessionManager; runtime sliding behaviour is unchanged. func (s SessionSettings) AbsoluteMax() time.Duration AbsoluteMax returns the absolute session lifetime ceiling. func (s SessionSettings) AutoLogout() time.Duration AutoLogout returns the client-side idle timeout as a Duration. Falls back to 30 minutes when not configured. func (s SessionSettings) IdleTimeout() time.Duration IdleTimeout is the SERVER-enforced inactivity window: a session with no genuine user activity for this long is expired regardless of the rolling window. It mirrors the client AutoLogout plus a small grace, so the two agree and the client logs out first when it can. AutoLogout never returns < 30m, so idle enforcement is effectively always on. func (s SessionSettings) RollingWindow() time.Duration RollingWindow returns the per-request session-extension window. type Settings struct { Lockout LockoutSettings `json:"lockout"` Session SessionSettings `json:"session"` PasswordReset PasswordResetSettings `json:"password_reset"` // SecurityLogPath is the filesystem path to the fail2ban-friendly // security log. Empty string disables file-based logging (events // still go to the audit_log table). Default is set by // DefaultSettings to a path next to the SQLite file. SecurityLogPath string `json:"security_log_path"` } Settings holds every operator-tunable parameter for the auth subsystem. It is loaded from the active configuration's content_json under the "auth" key, falling back to DefaultSettings for any unspecified field. Settings is a value type — pass it by value, never store a pointer somewhere it might race with reconfiguration. The Authenticator reads a snapshot at start of each request rather than holding a long-lived reference. func DefaultSettings() Settings DefaultSettings returns the built-in defaults applied when the configuration JSON omits any of these fields. The values match the constants documented in the README. SecurityLogPath defaults to the empty string, meaning "disabled unless the operator opts in". The CLI bootstrap may set a path based on the chosen DB location. func (s Settings) Validate() error Validate rejects nonsensical values that would brick the auth system. Called by globalconfig.AuthCfg.AuthSettings (which merges operator overrides onto DefaultSettings); callers that build Settings programmatically should also invoke it before committing. type TOTPUserRepository interface { SetTOTP(ctx context.Context, userID int64, secret, recoveryCodesJSON string) error UpdateTOTPRecoveryCodes(ctx context.Context, userID int64, recoveryCodesJSON string) error ClearTOTP(ctx context.Context, userID int64) error } TOTPUserRepository is the persistence subset that 2FA needs. It is separate from UserRepository so the auth package can keep core authentication and 2FA enrolment as orthogonal concerns; an implementation can satisfy both interfaces with the same struct. type User struct { ID int64 Username string Email string // empty string when no email on file DisplayName string // user-editable; empty = fall back to Username PasswordHash string Role Role IsActive bool MustChangePassword bool TOTPSecret string // empty when 2FA not enrolled TOTPRecoveryCodesJSON string // Multi-stage lockout state. See LockoutSettings doc comment for // the full state-machine description. FailedLoginCount int ConsecutiveLockouts int LockedUntil *time.Time // nil = not soft-locked LastLockoutAt *time.Time // nil = never soft-locked CreatedAt time.Time LastLoginAt *time.Time PasswordChangedAt time.Time // Timezone is the user's preferred IANA timezone (e.g. "Europe/Berlin") // for displaying timestamps in the UI as "local (UTC)". Empty = UTC. Timezone string // ForgejoOwner is the Forgejo namespace this user's imports must target // (multi-tenant Phase B). Empty means unpinned; admins are unrestricted. ForgejoOwner string } User mirrors the users table. All time fields are time.Time; repository implementations convert to/from the SQLite TEXT column representation. Sensitive fields (password_hash, totp_secret, totp_recovery_codes_json) are loaded only when the caller needs them; bulk listing endpoints use UserSummary instead. func (u *User) HasTOTP() bool HasTOTP reports whether the user has 2FA enabled. Centralised here so the rest of the code never has to compare TOTPSecret to "". func (u *User) IsLocked(now time.Time) bool IsLocked reports whether the user is currently within an active soft-lockout window. now is taken as a parameter rather than calling time.Now() so tests can drive the clock. Hard-lockout is represented by IsActive=false, NOT by LockedUntil, so this method does NOT report hard-locked accounts. Authentication code checks IsActive separately. type UserAdminRepository interface { // GetByID returns a user or ErrNotFound. GetByID(ctx context.Context, id int64) (*User, error) // ListUsers returns all users ordered by username. ListUsers(ctx context.Context) ([]*User, error) // CreateUser inserts a new user row. The caller must have already // hashed the password and validated the fields. Returns the new ID. CreateUser(ctx context.Context, user *User) (int64, error) // CountAdmins returns the number of active admin users. Used to // prevent locking out the last admin. CountAdmins(ctx context.Context) (int, error) // HardLock sets is_active=0. Admin can undo with Unlock. HardLock(ctx context.Context, userID int64) error // Unlock clears all lockout state and re-activates the account. Unlock(ctx context.Context, userID int64) error // ClearTOTP removes the TOTP enrolment for a user. ClearTOTP(ctx context.Context, userID int64) error // UpdatePasswordHash replaces the stored hash. mustChangeNext // forces a password-change redirect on next login. UpdatePasswordHash(ctx context.Context, userID int64, hash string, mustChangeNext bool) error // UpdateRole changes the user's role. The caller is responsible // for ensuring at least one active admin remains afterward. UpdateRole(ctx context.Context, userID int64, role Role) error // UpdateTimezone sets the user's preferred IANA timezone (empty = UTC). // The caller validates the value (see ValidTimezone). UpdateTimezone(ctx context.Context, userID int64, tz string) error // UpdateProfile sets the user's editable profile fields: display name and // email. The caller validates the email format and gates an email change on a // password re-check; a colliding email surfaces as db.ErrEmailTaken. UpdateProfile(ctx context.Context, userID int64, displayName, email string) error // SetForgejoOwner pins (or clears, with "") the Forgejo namespace the user's // imports must target (multi-tenant Phase B). Admins are unrestricted. SetForgejoOwner(ctx context.Context, userID int64, owner string) error } UserAdminRepository is the persistence interface used by the user management web UI and CLI admin commands. It is intentionally separate from UserRepository (which the authentication subsystem uses) so that the auth package never accidentally gains access to admin-only mutations, and so tests can stub only the methods they actually exercise. The SQL implementations of both interfaces live on the same *db.UserRepo struct; the interface split is purely about type-safety and ISP compliance. type UserRepository interface { GetByID(ctx context.Context, id int64) (*User, error) GetByUsername(ctx context.Context, username string) (*User, error) GetByEmail(ctx context.Context, email string) (*User, error) UpdatePasswordHash(ctx context.Context, userID int64, hash string, mustChangeNext bool) error UpdateLastLogin(ctx context.Context, userID int64, at time.Time) error // IncrementFailedLogin bumps failed_login_count by 1 atomically and // returns the NEW value, so the caller can decide whether to trigger // a lockout without a separate read. IncrementFailedLogin(ctx context.Context, userID int64) (newCount int, err error) // ResetFailedLogin sets failed_login_count to 0. Called on // successful login AND when a soft-lockout expires (lazy reset on // next access). Does NOT touch consecutive_lockouts. ResetFailedLogin(ctx context.Context, userID int64) error // SoftLock applies a temporary lockout: sets locked_until and // last_lockout_at, and atomically increments consecutive_lockouts. // Returns the new consecutive_lockouts value so the caller can // detect when the hard-lockout threshold is reached. SoftLock(ctx context.Context, userID int64, until, at time.Time) (newConsecutive int, err error) // HardLock sets is_active=0 with an audit-log distinguishable // reason (caller emits the audit entry; this method only mutates // the row). Also clears locked_until since hard-lockout supersedes // any pending soft-lockout window. HardLock(ctx context.Context, userID int64) error // Unlock clears all lockout state: locked_until=NULL, // failed_login_count=0, consecutive_lockouts=0, is_active=1. // Called by admin-driven unlock and by emergency-reset. Unlock(ctx context.Context, userID int64) error // ResetConsecutiveLockouts zeroes the consecutive_lockouts counter // without touching anything else. Called on successful login as // part of the "you've come back, the streak ends" semantics. ResetConsecutiveLockouts(ctx context.Context, userID int64) error } UserRepository is the persistence interface the auth package depends on. Implementations live in internal/db; tests substitute an in-memory fake. Keeping this interface narrow (only what auth actually uses) avoids accidental coupling to query helpers that other packages need.