adminauth

package
v0.1.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 27, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package adminauth manages the admin console's single-user authentication layer: bcrypt-hashed credentials persisted to disk, in-memory session tokens, and a per-(IP, username) login rate limiter. Gated on `deployment.mode == public` — loopback installs remain unauthenticated as their historical contract requires.

Sessions are in-memory only; restart logs everyone out. This is deliberate — the pairing store has the same "in-memory state is fine, paired clients re-establish" property, and persisting sessions across restart is extra disk surface for marginal UX gain. The credentials file is the only on-disk state.

Index

Constants

View Source
const (
	RateLimitMaxAttempts = 5
	RateLimitWindow      = 15 * time.Minute
	RateLimitDelay       = 5 * time.Second
)

Rate-limit policy. 5 failed attempts per (clientIP, username) per 15 minutes; on threshold, the handler should slow the response (5s wait) instead of returning 429 — don't tell the attacker they're being throttled, and don't fast-fail (which would otherwise let them parallelise attempts cheaply).

The 5s delay is the load-bearing part: it stretches a 100-attempt dictionary attack from microseconds to 500 seconds while bcrypt's per-attempt cost (~250ms at cost 12) does the heavy lifting on the verification side. Hostile networks see a hard ceiling on throughput.

View Source
const (
	SessionIdleTimeout = 24 * time.Hour
	SessionHardCap     = 7 * 24 * time.Hour
)

Session lifetimes. Idle bump on every Validate; hard cap is the absolute ceiling (so an attacker who steals a session can't keep it alive indefinitely by polling).

Variables

View Source
var (
	ErrInvalidCredentials = errors.New("adminauth: invalid credentials")
	ErrSessionNotFound    = errors.New("adminauth: session not found")
	ErrSessionExpired     = errors.New("adminauth: session expired")
	ErrAlreadyInitialised = errors.New("adminauth: store already has credentials; use reset-password to rotate")
	ErrNotInitialised     = errors.New("adminauth: store has no credentials (run `bridge init --public` or `bridge admin reset-password`)")
	ErrUsernameMismatch   = errors.New("adminauth: username does not match the stored admin account")
)

Errors returned by the Store API. Callers map these to HTTP status codes at the handler boundary; never leak them onto the wire verbatim (the JSON 401 body says "unauthenticated" — the specific reason stays in the server-side log).

Functions

func ExtractClientIP

func ExtractClientIP(r *http.Request, trustForwardedHeaders bool) string

ExtractClientIP returns the client IP for rate-limit and audit purposes. The trustForwardedHeaders flag MUST come from the operator's explicit `deployment.adminTLSTerminatedByProxy: true` — never default it to true, never sniff it from header presence: a direct attacker could otherwise forge X-Forwarded-For to arbitrary values and key the rate-limit bucket onto an uninvolved third party (defeating the limiter for everyone).

Returns "" only when r.RemoteAddr itself is unparseable, which shouldn't happen for an HTTP request that reached a handler. Callers should treat an empty return as "use a fallback key" or simply log + skip (don't crash the request).

When trustForwardedHeaders is true, header preference order:

  1. **X-Real-IP** — preferred. Caddy and nginx both set this to a single value: the immediate connecting client's IP, fresh on every request, overwriting any client-supplied value. If the proxy is correctly configured this is the most spoof-resistant signal.
  2. **X-Forwarded-For, right-most valid element** — fallback. XFF is a comma-separated chain where each proxy APPENDS the IP it saw on connect. The RIGHT-most entry is therefore the IP your trusted proxy saw (== the true client in a one-hop setup). The left-most is whatever the client claimed, including arbitrary attacker-forged values (Gemini High security review on PR #290). Taking the right-most defeats header injection regardless of whether the proxy strips client-supplied XFF on ingress.
  3. **r.RemoteAddr** — when neither header is set / valid.

Documented proxy-config expectation: operator's Caddy/nginx MUST set X-Real-IP to the connecting client's IP on ingress (Caddy default; nginx requires `proxy_set_header X-Real-IP $remote_addr`). XFF append-or-overwrite either way works under the right-most strategy.

Single-proxy assumption: this code is correct for the common `Caddy → bridge` and `nginx → bridge` shapes. Operators chaining multiple proxies (CDN → load balancer → bridge) get the load-balancer's IP, not the original client; a trusted-proxy- count hop wouldn't be hard to add later, but the PR 2 scope only documents single-proxy reverse-proxy deployments.

func IsSafeRelativePath

func IsSafeRelativePath(s string) bool

IsSafeRelativePath returns true if s is a same-origin relative path safe to use as a post-login redirect target. Rejects every shape that could be coerced into an off-origin URL by a browser:

  • "//attacker.com" — protocol-relative URL; browsers treat `Location: //evil.example` as a navigation to evil.example on the same scheme. Caught by the `!HasPrefix(s, "//")` check.
  • "/\\attacker.com" — Windows path coercion. Some legacy browsers normalised backslashes to slashes before parsing the URL; refuse defensively. Caught by `!HasPrefix(s, "/\\")` and the `!ContainsAny(s, "\\")` general guard.
  • "https://attacker.com" — explicit scheme. Caught by `HasPrefix(s, "/")` (an absolute URL starts with the scheme, not a slash) AND `!ContainsAny(s, ":")` (defense in depth against any single-leading-slash bypass that happens to include a colon).

Empty string returns false — the handler should fall through to the default landing page instead of redirecting to "" (which some routers interpret as "stay where you are" and some as "redirect to root"; ambiguous behaviour).

Types

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is a process-local, in-memory failed-login tracker. No persistence — restart wipes the state. That's fine; the bcrypt cost per attempt prevents online brute force from being meaningful in any time window the limiter would care about, and a determined attacker willing to wait through restarts can be blocked at the firewall layer.

func NewRateLimiter

func NewRateLimiter() *RateLimiter

NewRateLimiter creates a limiter and starts its janitor goroutine. The janitor exits when Stop is called.

func (*RateLimiter) Allow

func (rl *RateLimiter) Allow(clientIP, username string) bool

Allow reports whether the (clientIP, username) bucket is below the failure threshold. Does NOT increment — call RecordFailure after a verification attempt fails. Allow / RecordFailure are split so a successful Verify doesn't artificially bump the counter on the success path.

func (*RateLimiter) RecordFailure

func (rl *RateLimiter) RecordFailure(clientIP, username string)

RecordFailure increments the failed-attempt counter for the given key. Resets the window when the previous window expired. Caller hands the resulting bucket state back via Allow on the next attempt.

Map-size guard: when len(buckets) ≥ maxBuckets AND the key isn't already present, we evict a batch of the oldest-by-lastAttemptAt entries before adding the new one. This bounds the map's memory footprint under a high-cardinality attack — see maxBuckets docstring for the threat model.

func (*RateLimiter) RecordSuccess

func (rl *RateLimiter) RecordSuccess(clientIP, username string)

RecordSuccess clears the failure history for a key — a successful login should give the operator a clean slate so a past typo storm doesn't carry forward.

func (*RateLimiter) Stop

func (rl *RateLimiter) Stop()

Stop terminates the janitor goroutine. Safe to call multiple times from any number of goroutines — subsequent calls block on the same `<-rl.done` receive until the janitor exits, then return.

Pre-fix used a select/default + bare close pattern that races: two concurrent callers could both pass `default` then race on `close(stopCh)` → panic. The doc-comment claimed concurrency safety but the implementation didn't deliver. `sync.Once` closes that hole — the first caller closes stopCh + waits for done; subsequent callers wait on done directly (which is already closed by the janitor's `defer close(rl.done)`). CodeRabbit Major review post-PR-#292.

type Session

type Session struct {
	Username   string
	IssuedAt   time.Time
	LastUsedAt time.Time
}

Session is an in-memory record. The cookie value is the raw session ID; the map key is the SHA-256 hex digest of the same bytes (constant-time compare via the auth.Store / pairing.Store pattern). IssuedAt drives the hard cap; LastUsedAt drives the idle timeout.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the concurrent-safe credentials + session manager. One mutex guards both — the contention is low (admin login flow only) and a single lock keeps the invariants obvious.

func OpenStore

func OpenStore(path string) (*Store, error)

OpenStore loads (or initialises as empty) the store at path. A missing file is not an error — IsInitialised() reports the empty state and the caller's startup flow decides whether to refuse service or proceed.

func (*Store) CreateSession

func (s *Store) CreateSession(username string) (string, error)

CreateSession mints a new session token for the given username. Returns the RAW token (43-char base64url) for the caller to set as a cookie value; the Store keeps only the hash. Caller MUST have already verified credentials via Verify.

func (*Store) DeleteSession

func (s *Store) DeleteSession(raw string)

DeleteSession invalidates a session by raw token. Idempotent — unknown tokens return nil so logout against an already-expired session doesn't surface an error.

func (*Store) IsInitialised

func (s *Store) IsInitialised() bool

IsInitialised reports whether the on-disk credentials file exists. Used by the bridge serve startup path to refuse-to-start in public mode when no admin has been minted yet.

func (*Store) MintInitial

func (s *Store) MintInitial(username string) (string, error)

MintInitial generates a random 16-character alphabetic password, bcrypts it, persists the record, and returns the plaintext for one-time display to the operator. Subsequent calls return ErrAlreadyInitialised — credentials are only ever rotated via ResetPassword.

The plaintext lives only in the returned string and the caller's printed banner. Discarding the returned string is the caller's responsibility; no log line in this package ever sees it.

func (*Store) ResetPassword

func (s *Store) ResetPassword(username, newPassword string) error

ResetPassword overwrites the existing credentials with a new bcrypt hash. Called by `bridge admin reset-password`. Active sessions are NOT invalidated by this — operator who wants sessions revoked too must restart the bridge (cheap, and the hard-cap is 7d so a stale session can't outlive a normal release cycle).

func (*Store) SessionCount

func (s *Store) SessionCount() int

SessionCount returns the live session count. Test affordance; production has no consumer.

func (*Store) Username

func (s *Store) Username() string

Username returns the configured admin username, or "" if not initialised. Used by the login form to pre-fill the field (single-user system, no risk in exposing the username).

func (*Store) ValidateSession

func (s *Store) ValidateSession(raw string) (*Session, error)

ValidateSession looks up the session by raw token, checks both the idle timeout and the hard cap, and bumps LastUsedAt. Returns a copy of the session record on success.

Expired sessions are eagerly removed from the map so they don't accumulate. ErrSessionExpired vs ErrSessionNotFound are distinguished only for tests; the handler maps both to JSON 401.

func (*Store) Verify

func (s *Store) Verify(username, password string) error

Verify checks the credentials and returns nil on match. ErrInvalidCredentials covers both wrong-user and wrong-password cases — never disclose which one failed to the caller (handler reports a single generic "invalid credentials" to the wire). Constant-time username compare is unnecessary (username is not secret); bcrypt.CompareHashAndPassword is constant-time on the hash side.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL