auth

package
v1.6.0 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Index

Constants

View Source
const MinPasswordLength = 10

MinPasswordLength is the floor for every path that sets one — setup, account creation, a change from the UI, and the offline reset. Named so the offline path cannot quietly disagree with the online one.

View Source
const SessionCookie = "dc_session"

SessionCookie is the name of the httpOnly cookie carrying the session JWT.

View Source
const TOTPIssuer = "Docker Commander"

TOTPIssuer is the label shown in authenticator apps (Google Authenticator, Authy, 1Password, …) next to the account.

Variables

View Source
var (
	ErrSetupDone       = errors.New("auth: setup already completed")
	ErrInvalidCreds    = errors.New("auth: invalid credentials")
	ErrRateLimited     = errors.New("auth: too many attempts, try again later")
	ErrMFARequired     = errors.New("auth: 2fa code required")
	ErrInvalidMFACode  = errors.New("auth: invalid 2fa code")
	ErrWeakPassword    = fmt.Errorf("auth: password must be at least %d characters", MinPasswordLength)
	ErrInvalidUsername = errors.New("auth: username must be 3-32 characters")
)

Common authentication errors surfaced to the API layer.

View Source
var ErrClonedAuthenticator = errors.New("auth: this passkey's counter went backwards")

ErrClonedAuthenticator means the signature counter went backwards — the sign of a credential that exists in two places.

View Source
var ErrEnrollmentStale = errors.New("auth: this account is protected now — start again and confirm with your password")

ErrEnrollmentStale is returned when a pending enrolment was started before the account had any second factor and so was never authorised with the password, but the account has gained one since. Starting over asks for the password.

View Source
var ErrInvalidHash = errors.New("auth: invalid password hash format")

ErrInvalidHash is returned when an encoded hash cannot be parsed.

View Source
var ErrLastFactor = store.ErrLastFactor

ErrLastFactor is returned when removing a factor would leave the account with no second factor at all.

View Source
var ErrNoPasskeys = errors.New("auth: this account has no passkeys")

ErrNoPasskeys means the account has none paired.

View Source
var ErrPasskeyUnavailable = errors.New("auth: passkeys need HTTPS (or localhost)")

ErrPasskeyUnavailable means this request could not be a passkey ceremony: the browser will not touch WebAuthn outside a secure context, so neither will we.

View Source
var ErrPasswordlessNotAllowed = errors.New("auth: this account must sign in with its password")

ErrPasswordlessNotAllowed means the account has not turned this on, or is one that may never use it.

View Source
var ErrTooBusy = errors.New("auth: too many sign-ins in flight, try again")

ErrTooBusy means the server is holding as many half-finished ceremonies as it is willing to. Reachable without a session, so it must be bounded.

View Source
var ErrTooManyFactors = errors.New("auth: this account already has the maximum number of authenticators")

ErrTooManyFactors is returned when an account already holds the maximum.

View Source
var ErrUserVerificationRequired = errors.New("auth: this passkey did not verify who you are; use your password")

ErrUserVerificationRequired means the authenticator answered without verifying the user — no PIN, no biometric. Enough for a second factor, not enough to BE the login.

Functions

func HashPassword

func HashPassword(password string) (string, error)

HashPassword derives an Argon2id hash and returns it in the standard PHC encoded string form, e.g. $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>.

func LDAPTest

func LDAPTest(cfg store.LDAPConfig) (int, error)

LDAPTest verifies the LDAP settings: dial, optional StartTLS, service bind, and a base search. Returns the number of entries under the user base.

func MapsRoles added in v1.6.0

func MapsRoles(cfg store.LDAPConfig) bool

MapsRoles reports whether any mapping hands out roles. It gates whether LDAP becomes authoritative for role membership: a config written before roles existed grants only sections, and must not silently wipe roles an admin assigned by hand.

func MatchTOTP added in v1.6.0

func MatchTOTP(code, secret string) (counter int64, ok bool)

MatchTOTP reports whether code is valid and, if so, which time step produced it — so the caller can refuse to accept that step a second time.

The library's own validation only answers yes/no, and "yes" holds for the whole ~90-second window. That makes a single observed code (shoulder-surfed, phished through a proxy, screenshotted by malware) spendable more than once, which is precisely what a one-time password is supposed to prevent.

It re-derives the code for each step in the skew window and compares in constant time, so a wrong code leaks nothing about how wrong it was.

func RolesForGroups added in v1.6.0

func RolesForGroups(cfg store.LDAPConfig, groups []string) []int64

RolesForGroups returns the union of role ids granted to a user who belongs to the given group DNs. Matching is identical to SectionsForGroups (exact full DN, case-insensitive). Ids are not validated against the roles table here; a role deleted after the mapping was written is skipped when the roles are applied, so a stale id grants nothing rather than failing the login.

func SectionsForGroups added in v1.5.0

func SectionsForGroups(cfg store.LDAPConfig, groups []string) []string

SectionsForGroups returns the union of RBAC sections granted to a user who belongs to the given group DNs, per the config's group→section mappings. Group DNs are matched case-insensitively on the full DN (exact, not substring) to mirror the admin-group check, and unknown section names are ignored, so a mapping can never grant access beyond a real, named section.

func StepUpKey added in v1.6.0

func StepUpKey(userID int64, sessionID string) string

StepUpKey buckets password re-checks per account AND per session.

Not the client address, which is what login uses: keying it there let anyone holding a session spend the address's login budget — five wrong passwords on "remove this authenticator" and nobody at that address could sign in for fifteen minutes.

But not the account alone either, which merely moves the damage onto the victim. Step-up is reachable with nothing but a session, so a stolen one could burn the account's whole budget every fifteen minutes: the owner's CORRECT password is then refused for exactly the two things they need to recover — removing the attacker's authenticator and pairing a replacement — while logins keep working, so nothing looks broken.

Per session, the attacker's stolen session spends its own budget and the owner's is untouched. Minting more sessions needs the password, which is what the attacker is trying to guess.

func ValidateTOTP

func ValidateTOTP(code, secret string) bool

ValidateTOTP reports whether code is currently valid for secret. A small skew window is allowed to tolerate clock drift between server and device.

Prefer MatchTOTP where a replay matters: this answers "is it valid", which stays true for the whole window, so the same code keeps working until it expires.

func VerifyPassword

func VerifyPassword(password, encoded string) (bool, error)

VerifyPassword reports whether password matches the encoded hash. The comparison is constant-time to avoid leaking timing information.

func WithClaims added in v1.5.0

func WithClaims(ctx context.Context, c *Claims) context.Context

WithClaims returns a context carrying the given claims, the counterpart to ClaimsFrom. RequireSession uses the same key after verifying a token; this is exposed for composing authenticated contexts (and tests).

Types

type Claims

type Claims struct {
	UserID   int64     `json:"uid"`
	Username string    `json:"usr"`
	Role     string    `json:"role"`
	Kind     TokenKind `json:"knd"`
	// Epoch is the account's session generation when this token was minted. The
	// middleware refuses a token whose epoch is behind the account's current one,
	// which is how a password change takes effect immediately instead of waiting
	// out the TTL.
	Epoch int64 `json:"ep,omitempty"`
	jwt.RegisteredClaims
}

Claims is the JWT payload used for both session and MFA-challenge tokens.

func ClaimsFrom

func ClaimsFrom(ctx context.Context) (*Claims, bool)

ClaimsFrom returns the authenticated claims stored in the request context.

type Enrollment

type Enrollment struct {
	Secret     string `json:"secret"`     // base32 secret, also shown for manual entry
	OtpauthURL string `json:"otpauthUrl"` // otpauth:// provisioning URI
	QRDataURI  string `json:"qrDataUri"`  // data:image/png;base64,... for <img src>
}

Enrollment holds the data needed to show a user how to add their 2FA token.

func GenerateTOTP

func GenerateTOTP(accountName string) (*Enrollment, error)

GenerateTOTP creates a new TOTP secret for accountName and renders a QR code as a data URI so the frontend can display it without extra endpoints.

type Issued added in v1.6.0

type Issued struct {
	Token     string
	ID        string
	ExpiresAt time.Time
}

Issued is a freshly minted token and the facts about it a caller needs: its id (for the session row, or for spending a challenge) and when it expires.

type LDAPResult

type LDAPResult struct {
	Username string
	IsAdmin  bool     // member of the configured admin group
	Groups   []string // the user's group DNs (memberOf), for section mapping
	Email    string   // the directory's mail attribute, if it publishes one
}

LDAPResult is the outcome of a successful LDAP authentication.

func LDAPAuthenticate

func LDAPAuthenticate(cfg store.LDAPConfig, username, password string) (*LDAPResult, error)

LDAPAuthenticate verifies a username/password against an LDAP/AD directory: bind with the service account, search for the user, then bind as that user to validate the password. If an admin group is configured, group membership is reported so the account can be provisioned as an admin.

type LoginLimiter

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

LoginLimiter is a small in-memory fixed-window rate limiter keyed by client identity (IP or username). It throttles brute-force login attempts without any external dependency. Suitable for a single-instance local tool.

func NewLoginLimiter

func NewLoginLimiter(max int, window time.Duration) *LoginLimiter

NewLoginLimiter allows max failed attempts within the given window.

func (*LoginLimiter) Allow

func (l *LoginLimiter) Allow(key string) bool

Allow reports whether another attempt is permitted for key right now. It does not consume an attempt; call Fail to record a failed attempt.

func (*LoginLimiter) Fail

func (l *LoginLimiter) Fail(key string)

Fail records a failed attempt for key, starting a window if needed.

func (*LoginLimiter) Reset

func (l *LoginLimiter) Reset(key string)

Reset clears the counter for key after a successful login.

type LoginResult

type LoginResult struct {
	MFARequired bool
	Token       string // session token, or MFA-challenge token if MFARequired
	ExpiresAt   time.Time
	User        *store.User
}

LoginResult is returned from Login: either a finished session, or an MFA challenge the caller must satisfy via VerifyMFA.

type Middleware

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

Middleware enforces a valid, fully-authenticated session token. It reads the token from the session cookie first, then falls back to an Authorization Bearer header (useful for API clients and tooling).

func NewMiddleware

func NewMiddleware(tokens *TokenManager, epochs SessionEpochSource) *Middleware

NewMiddleware builds auth middleware backed by the given token manager and the source of session generations.

func (*Middleware) ParseSessionToken

func (m *Middleware) ParseSessionToken(raw string) (*Claims, error)

ParseSessionToken validates a raw token and ensures it is a session token. Used by the WebSocket handler which authenticates before upgrading.

func (*Middleware) RequireSession

func (m *Middleware) RequireSession(next http.Handler) http.Handler

RequireSession wraps next, rejecting requests without a valid session token.

type PasswordlessError added in v1.6.0

type PasswordlessError struct {
	Username string
	Err      error
}

PasswordlessError carries the account a failed attempt was for.

Once the assertion has verified, the account is known — and the failures after that point are the ones worth writing down: a cloned key, a missing PIN, an account that has not opted in. Without the username the audit log cannot say who any of it happened to, and a cloned-authenticator detection is the last thing that should vanish silently.

func (*PasswordlessError) Error added in v1.6.0

func (e *PasswordlessError) Error() string

func (*PasswordlessError) Unwrap added in v1.6.0

func (e *PasswordlessError) Unwrap() error

type RelyingParty added in v1.6.0

type RelyingParty struct {
	ID          string // e.g. "docker.example.com"
	Origin      string // e.g. "https://docker.example.com"
	DisplayName string
}

RelyingParty describes who is asking, in WebAuthn's terms: the id a credential is bound to, and the origin it may be used from.

Both are derived per request rather than configured, because getting them wrong is not a security failure but a usability one — a credential registered against the wrong id simply never works again. The browser is what enforces that the id matches the page's origin, so an attacker cannot use a forged Host header to mint a credential usable elsewhere: they would only produce one that is useless.

type Service

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

Service orchestrates the authentication flows on top of the store and the crypto/token primitives in this package.

func NewService

func NewService(s *store.Store, tm *TokenManager) *Service

NewService wires the auth service together.

func (*Service) BeginPasskeyLogin added in v1.6.0

func (s *Service) BeginPasskeyLogin(ctx context.Context, rp RelyingParty, challengeToken string) (*protocol.CredentialAssertion, error)

BeginPasskeyLogin issues an assertion challenge for the account named by an MFA challenge token. The token is proof the password was right; this is the second factor.

func (*Service) BeginPasskeyRegistration added in v1.6.0

func (s *Service) BeginPasskeyRegistration(ctx context.Context, rp RelyingParty, userID int64, stepUp bool) (*protocol.CredentialCreation, error)

BeginPasskeyRegistration starts pairing a passkey for an account.

stepUp says whether the caller proved the password. It is remembered with the ceremony rather than re-derived at the end; see FinishPasskeyRegistration.

func (*Service) BeginPasswordlessLogin added in v1.6.0

func (s *Service) BeginPasswordlessLogin(rlKey string, rp RelyingParty) (*protocol.CredentialAssertion, string, error)

BeginPasswordlessLogin issues a discoverable-credential challenge.

Nobody has said who they are, so this cannot be scoped to an account: the authenticator is asked what it holds for this relying party. The returned id is what ties the answer back to this challenge — an opaque, single-use handle, since there is no session and no half-finished login to key it on.

func (*Service) BeginTOTPEnrollment

func (s *Service) BeginTOTPEnrollment(ctx context.Context, userID int64, stepUp bool) (*Enrollment, error)

BeginTOTPEnrollment generates a new secret + QR for the user. The secret is stored but not yet enabled until confirmed via ConfirmTOTPEnrollment.

stepUp says whether the caller proved the password to get here. It is stored with the candidate so that confirming it can be judged against the account's protection at the time the enrolment began — see ConfirmTOTPEnrollment.

func (*Service) ChallengeUsername added in v1.6.0

func (s *Service) ChallengeUsername(challengeToken string) string

ChallengeUsername reports which account an MFA challenge token names, so a failed verification can be audited against it. It validates the token's signature and kind but says nothing about the code — callers must not treat a non-empty result as authentication.

func (*Service) ConfirmTOTPEnrollment

func (s *Service) ConfirmTOTPEnrollment(ctx context.Context, userID int64, code, name string) error

ConfirmTOTPEnrollment validates the first code from the candidate authenticator and pairs it. Anything already paired keeps working: this adds a factor, it does not replace one.

func (*Service) CreateAccount

func (s *Service) CreateAccount(ctx context.Context, username, password, role string, readOnly bool, sections []string) (*store.User, error)

CreateAccount creates a non-setup user account (used by admins). role is "admin" or "user"; for "user", sections and readOnly scope their access.

func (*Service) FinishPasskeyLogin added in v1.6.0

func (s *Service) FinishPasskeyLogin(ctx context.Context, rp RelyingParty, rlKey, challengeToken string, r *http.Request, info SessionInfo) (*LoginResult, error)

FinishPasskeyLogin verifies an assertion and issues a session.

rlKey is the rate-limit bucket, as for VerifyMFA: a passkey is not guessable, but the endpoint must not become a free oracle for probing which accounts exist.

func (*Service) FinishPasskeyRegistration added in v1.6.0

func (s *Service) FinishPasskeyRegistration(ctx context.Context, rp RelyingParty, userID int64, name string, r *http.Request) error

FinishPasskeyRegistration completes pairing and stores the credential.

The password check lives in "begin", but the factor is created here, and the two are minutes apart. An account with no second factor may open a ceremony without a password — there is nothing to protect yet — so if it gained one in the meantime, finishing would add an authenticator to a now-protected account on the strength of a session alone. What authorised the ceremony has to still be enough when it lands.

func (*Service) FinishPasswordlessLogin added in v1.6.0

func (s *Service) FinishPasswordlessLogin(ctx context.Context, rp RelyingParty, rlKey, ceremonyID string, r *http.Request, info SessionInfo) (*LoginResult, error)

FinishPasswordlessLogin verifies the assertion and issues a session.

rlKey is the rate-limit bucket — the client address, because until the assertion verifies there is no account to bucket on. That is also why this must not become a free oracle: every failure costs the caller budget.

func (*Service) HasPasskeys added in v1.6.0

func (s *Service) HasPasskeys(ctx context.Context, userID int64) (bool, error)

HasPasskeys reports whether the account has any paired.

func (*Service) ListFactors added in v1.6.0

func (s *Service) ListFactors(ctx context.Context, userID int64) ([]store.AuthFactor, error)

ListFactors returns the account's paired factors.

func (*Service) Login

func (s *Service) Login(ctx context.Context, rlKey, username, password string, exemptMFA bool, info SessionInfo) (*LoginResult, error)

Login verifies username+password. If the account has TOTP enabled it returns an MFA challenge token; otherwise a full session token. rlKey is the rate limit bucket (typically the client IP). exemptMFA skips the 2FA step (used for localhost when the admin has allowed it).

func (*Service) NeedsSetup

func (s *Service) NeedsSetup(ctx context.Context) (bool, error)

NeedsSetup reports whether no account exists yet (first-run wizard).

func (*Service) RemoveFactor added in v1.6.0

func (s *Service) RemoveFactor(ctx context.Context, userID, factorID int64) error

RemoveFactor unpairs one of the account's factors.

The last one cannot go. 2FA is mandatory here (bar the localhost exemption, which is a property of where you connect from, not of the account), so an account with zero factors is one that cannot sign in from anywhere else — a self-lockout with no admin reset behind it. Pair the replacement first, then remove the old one.

func (*Service) SetPassword

func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error

SetPassword replaces a user's password (admin reset or self-change) and invalidates every session already issued for that account.

Without the second half the first is half a control: a JWT is self-contained, so nothing about changing the password reaches the copy an attacker already holds. They would keep full access for the rest of the token's twelve hours — granted by the very act meant to take it away.

func (*Service) Setup

func (s *Service) Setup(ctx context.Context, username, password string) (*store.User, error)

Setup creates the first admin account. It fails once any user exists.

func (*Service) VerifyMFA

func (s *Service) VerifyMFA(ctx context.Context, rlKey, challengeToken, code string, info SessionInfo) (*LoginResult, error)

VerifyMFA completes login by validating a TOTP code against the MFA-challenge token issued by Login. rlKey is the same bucket Login uses (the client IP).

This step is rate limited for the same reason the password step is, and the reason is easy to miss: by the time it runs the attacker already has the password, so a six-digit code is the only thing left. Unthrottled, that is ~10^6 guesses — minutes of scripted requests — and the code path is cheap (no argon2), which is what makes it practical rather than theoretical.

It is keyed on the client IP *and* on the account, so rotating source addresses doesn't buy a fresh budget: the account bucket keeps counting.

func (*Service) VerifyUserPassword added in v1.6.0

func (s *Service) VerifyUserPassword(ctx context.Context, rlKey string, u *store.User, password string) error

VerifyUserPassword checks a password against the account it belongs to, local hash or directory bind, without issuing anything. It returns nil when the password is right, ErrRateLimited when the budget for this key is spent, and ErrInvalidCreds otherwise.

Used for step-up on operations a session alone must not authorise. It burns a rate-limit budget: otherwise it is a password oracle that answers as fast as you can ask, reachable by anyone holding a session.

Telling the two failures apart matters. Reporting a spent budget as "wrong password" tells the owner their own password is wrong, which is both false and the exact moment they are trying to recover an account.

type SessionEpochSource added in v1.6.0

type SessionEpochSource interface {
	SessionEpoch(ctx context.Context, userID int64) (int64, error)
	SessionExists(ctx context.Context, id string, userID int64) (bool, error)
	TouchSession(ctx context.Context, id string) error
}

SessionEpochSource reports an account's current session generation, and ErrNotFound (or any error) if the account is gone. It also answers whether a particular session is still recorded, which is what makes revoking one from the profile take effect against a self-contained token.

type SessionInfo added in v1.6.0

type SessionInfo struct {
	IP        string
	UserAgent string
}

SessionInfo is what a login knows about the client asking for one. Recorded on the session so its owner can recognise it later.

type TokenKind

type TokenKind string

TokenKind distinguishes a fully-authenticated session token from the short-lived intermediate token issued between the password and 2FA steps.

const (
	// KindSession is a fully authenticated token (password + 2FA satisfied).
	KindSession TokenKind = "session"
	// KindMFAChallenge is issued after a correct password when TOTP is still
	// required. It only authorises calling the 2FA verification endpoint.
	KindMFAChallenge TokenKind = "mfa"
)

type TokenManager

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

TokenManager mints and verifies HMAC-signed JWTs.

func NewTokenManager

func NewTokenManager(secret []byte, sessionTTL time.Duration) *TokenManager

NewTokenManager returns a manager signing with secret. sessionTTL controls how long a logged-in session stays valid before re-authentication.

func (*TokenManager) Issue

func (m *TokenManager) Issue(userID int64, username, role string, kind TokenKind, epoch int64) (Issued, error)

Issue creates a signed token for the given user and kind.

func (*TokenManager) Parse

func (m *TokenManager) Parse(tokenString string) (*Claims, error)

Parse validates the signature and expiry and returns the claims.

Jump to

Keyboard shortcuts

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