← repoman internals

internal/auth

import "git.griefed.de/griefed/repoman/internal/auth"
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.