Documentation
¶
Overview ¶
Package auth handles passwords, sessions, API keys and permission checks.
Index ¶
- Constants
- Variables
- func APIKeyHash(pepper []byte, prefix, secret string) []byte
- func AnonymizeIP(addr netip.Addr) string
- func CookieName(secure bool) string
- func HashSessionToken(token string) []byte
- func IsSessionInvalid(err error) bool
- func NewSessionToken() (token string, hash []byte, err error)
- func NormalizeEmail(email string) string
- func ParseAPIKey(token string) (prefix, secret string, err error)
- func ValidateEmail(email string) error
- type APIKeyConfig
- type APIKeyInfo
- type APIKeyService
- func (s *APIKeyService) Authenticate(ctx context.Context, token string) (*Identity, error)
- func (s *APIKeyService) Close(ctx context.Context) error
- func (s *APIKeyService) Create(ctx context.Context, actor *Identity, in CreateAPIKeyInput) (*CreatedAPIKey, error)
- func (s *APIKeyService) FlushUsage(ctx context.Context) error
- func (s *APIKeyService) List(ctx context.Context, actor *Identity) ([]APIKeyInfo, error)
- func (s *APIKeyService) Revoke(ctx context.Context, actor *Identity, id uuid.UUID) error
- func (s *APIKeyService) Start()
- type CreateAPIKeyInput
- type CreatedAPIKey
- type Hasher
- type Identity
- type LockoutPolicy
- type LoginInput
- type LoginResult
- type Params
- type RegisterInput
- type Service
- func (s *Service) Authenticate(ctx context.Context, token string) (*Identity, error)
- func (s *Service) ChangePassword(ctx context.Context, userID, keepSession uuid.UUID, current, next string) error
- func (s *Service) Hasher() *Hasher
- func (s *Service) IdentityForEmail(ctx context.Context, email string) (*Identity, error)
- func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)
- func (s *Service) Logout(ctx context.Context, sessionID uuid.UUID) error
- func (s *Service) NeedsSetup(ctx context.Context) (bool, error)
- func (s *Service) Register(ctx context.Context, in RegisterInput) (*Identity, error)
- type ServiceConfig
- type Session
- type SessionTTL
Constants ¶
const ( PermAPIKeysRead = "apikeys.read" PermAPIKeysWrite = "apikeys.write" )
Permissions API key management itself requires.
const ( SessionCookieName = "__Host-linkctrl_session" SessionCookieNameInsecure = "linkctrl_session" )
SessionCookieName uses the __Host- prefix, which browsers only accept when the cookie is Secure, has Path=/, and carries no Domain attribute. That makes it impossible for a subdomain — including one an attacker controls via a stale DNS record or a shared hosting neighbour — to set or overwrite the session cookie.
The prefix requires HTTPS, so local HTTP development uses the unprefixed name. Config refuses SECURE_COOKIES=false in production, so the weaker form cannot reach a real deployment.
const ( // APIKeyPrefixLength is the length of the public, storable part. APIKeyPrefixLength = len(apiKeyTag) + apiKeyIDChars )
Token layout: "lk_live_" + 8-character public id + "_" + 43-character secret.
The public id is stored and indexed, so verification is a single-row lookup rather than a scan comparing every hash. The tag is fixed-length and the id is fixed-length, which means the parts are taken by offset — splitting on "_" would break the moment a base64url secret contained one.
"live" is there so a future test-mode key is distinguishable by eye rather than by asking the database. The whole token is one word with no spaces or punctuation beyond underscores, so it survives being pasted into a shell, a YAML file and a CI secret box unquoted.
const MaxPasswordLength = 4096
MaxPasswordLength caps input before hashing.
Argon2 has no practical input limit, so this is not about the algorithm: it is a denial-of-service guard. Hashing is deliberately expensive, and an unbounded body means an attacker can make the server do unbounded work.
const MinPasswordLength = 12
MinPasswordLength is the floor for new passwords. Length is the only requirement — no composition rules, which push people toward predictable substitutions without adding real entropy (NIST SP 800-63B).
const MinPepperLength = 32
MinPepperLength mirrors the config validation floor, so a service built directly in a test cannot be weaker than a deployed one.
Variables ¶
var ( ErrMismatch = errors.New("auth: password does not match") ErrInvalidHash = errors.New("auth: hash is not in a recognised format") ErrUnsupportedID = errors.New("auth: unsupported password hash algorithm") )
var ( ErrEmailTaken = errors.New("auth: email already registered") ErrInvalidEmail = errors.New("auth: invalid email address") ErrInvalidCredentials = errors.New("auth: invalid email or password") ErrAccountLocked = errors.New("auth: account temporarily locked") ErrAccountInactive = errors.New("auth: account is not active") ErrSignupClosed = errors.New("auth: registration is closed") )
var ( ErrSessionNotFound = errors.New("auth: session not found") ErrSessionExpired = errors.New("auth: session expired") ErrSessionRevoked = errors.New("auth: session revoked") )
var DefaultLockout = LockoutPolicy{Threshold: 5, Window: 15 * time.Minute}
var DefaultParams = Params{
MemoryKiB: 64 * 1024,
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
DefaultParams follows the RFC 9106 second recommendation: 64 MiB, t=3, p=2. config.Validate refuses anything below the 19 MiB floor.
var ErrAPIKeyInvalid = errors.New("auth: api key is not valid")
ErrAPIKeyInvalid covers every reason a presented key does not authenticate: malformed, unknown, wrong secret, revoked, expired, or belonging to an account that is no longer active.
One error rather than several. The distinction is of no use to a legitimate caller — the key list shows revocation and expiry, so the owner can already see which of theirs is which — and separate responses would tell whoever found a leaked key whether it is still worth trying elsewhere.
var NonDelegableScopes = map[string]struct{}{ PermAPIKeysRead: {}, PermAPIKeysWrite: {}, "org.delete": {}, }
NonDelegableScopes are permissions an API key may never hold, whatever its creator's role.
Key management is the important one: a key that can mint keys makes revocation meaningless, because whoever holds a leaked key simply issues another before the original is cut off. So minting stays behind an interactive session, and org.delete follows the same rule — an irreversible action should require a human sign-in rather than a token in a CI variable.
Functions ¶
func APIKeyHash ¶
APIKeyHash is the value stored in api_keys.key_hash.
HMAC-SHA256 with a pepper from configuration, so a database dump on its own does not permit offline verification. Deliberately not argon2: the secret is full-entropy random, so stretching buys nothing, and 64 MiB of work per request would not fit a 150ms API budget.
The prefix is part of the message, which binds a hash to the row that holds it: a hash copied to another key's row no longer verifies.
func AnonymizeIP ¶
AnonymizeIP reduces an address to the prefix kept for session and audit records: /24 for IPv4, /48 for IPv6.
The same reasoning as analytics — enough to recognise "this session moved to a different network", not enough to identify a person. Analytics keeps no address at all; sessions keep a prefix because "where was this session used" is a question a user legitimately asks of their own account.
func CookieName ¶
CookieName returns the correct cookie name for the deployment.
func HashSessionToken ¶
HashSessionToken returns the storage hash for a token.
func IsSessionInvalid ¶
IsSessionInvalid reports whether an Authenticate failure means the credential itself is finished, as opposed to the lookup having failed.
The distinction decides whether a caller may destroy the cookie. Authenticate returns wrapped pgx errors for a dead pool, a cancelled context or a missing workspace row, and treating those as "this session is over" turns a ten-second database blip into a forced sign-out for every signed-in user at once — sessions that were, and remain, perfectly valid.
func NewSessionToken ¶
NewSessionToken returns a random token and its storage hash.
Only the hash is persisted. A database leak therefore does not hand over live sessions, which is the same reasoning as never storing a raw password. SHA-256 rather than argon2 is correct here: the token is full-entropy random, so key-stretching adds nothing, and session validation happens on every request where 64 MiB of work would be untenable.
func NormalizeEmail ¶
NormalizeEmail trims and lowercases. The database also stores a generated lowercase column, so comparison never depends on the caller remembering.
func ParseAPIKey ¶
ParseAPIKey splits a token into its public prefix and its secret.
Everything about the shape is checked here so that a malformed token costs no database round trip, which is what stops a flood of junk Authorization headers turning into a flood of queries.
func ValidateEmail ¶
Types ¶
type APIKeyConfig ¶
type APIKeyConfig struct {
// Pepper keys the HMAC. Required; a short one is refused rather than
// silently accepted, because a weak pepper is invisible in behaviour.
Pepper []byte
// UsageFlushInterval is how often buffered last_used_at values are
// written. Coarse on purpose: the value answers "is this key still in
// use", which does not need second resolution.
UsageFlushInterval time.Duration
Logger *slog.Logger
}
APIKeyConfig configures the key service.
type APIKeyInfo ¶
type APIKeyInfo struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scopes []string `json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `json:"created_at"`
}
APIKeyInfo is a key as its owner sees it. The secret is absent by construction: it is never stored, so it cannot be listed.
type APIKeyService ¶
type APIKeyService struct {
// contains filtered or unexported fields
}
APIKeyService issues, lists, revokes and authenticates API keys.
It sits alongside Service rather than inside it because the two answer different questions with different inputs — a password and a cookie versus a bearer token and a pepper — and only this one needs a secret from configuration. Both resolve to the same Identity, so nothing downstream can tell which credential a request arrived with unless it asks.
func NewAPIKeyService ¶
func NewAPIKeyService(pool *pgxpool.Pool, authSvc *Service, cfg APIKeyConfig) (*APIKeyService, error)
func (*APIKeyService) Authenticate ¶
Authenticate resolves a bearer token to an identity.
The identity's permissions are the intersection of the owner's current role and the key's scopes, recomputed on every request. So demoting a user weakens their keys at once, and a scope the role no longer grants stops working without the key having to be reissued.
func (*APIKeyService) Close ¶
func (s *APIKeyService) Close(ctx context.Context) error
Close flushes buffered usage timestamps and stops the writer.
func (*APIKeyService) Create ¶
func (s *APIKeyService) Create(ctx context.Context, actor *Identity, in CreateAPIKeyInput) (*CreatedAPIKey, error)
Create issues a key and returns the only copy of its token.
The token is not recoverable afterwards by design: only the HMAC is stored, which is the same reasoning as never storing a password. A caller who loses it revokes the key and issues another.
func (*APIKeyService) FlushUsage ¶
func (s *APIKeyService) FlushUsage(ctx context.Context) error
FlushUsage writes buffered last_used_at values immediately. Called by Close, and by tests that would otherwise have to sleep.
func (*APIKeyService) List ¶
func (s *APIKeyService) List(ctx context.Context, actor *Identity) ([]APIKeyInfo, error)
List returns the actor's own keys.
Own, not the workspace's: a key is a personal credential acting as its owner, and showing one user another's credentials serves no purpose that listing memberships does not serve better.
func (*APIKeyService) Revoke ¶
Revoke disables a key immediately.
Immediately in the literal sense: nothing about a key is cached, so the next request presenting it fails. That is the reason revocation is checked in the verification query rather than kept in a cache alongside the hash.
func (*APIKeyService) Start ¶
func (s *APIKeyService) Start()
Start launches the background writer for last_used_at.
type CreateAPIKeyInput ¶
CreateAPIKeyInput describes a new key.
type CreatedAPIKey ¶
type CreatedAPIKey struct {
APIKeyInfo
Key string `json:"key"`
}
CreatedAPIKey is the response to creating a key: the record, plus the only copy of the token that will ever exist.
type Hasher ¶
type Hasher struct {
// contains filtered or unexported fields
}
Hasher hashes and verifies passwords.
The semaphore is the reason this is a struct rather than free functions. Each hash allocates 64 MiB, so N concurrent logins allocate N x 64 MiB; a credential-stuffing burst would otherwise OOM the process. Limiting concurrent hashing bounds that at a fixed cost, and the login rate limiter keeps the queue behind it short.
func (*Hasher) DummyVerify ¶
DummyVerify performs a hash with the same cost as a real verification and discards the result.
Called when the account does not exist, so that login timing does not reveal whether an email is registered. Without it, "no such user" returns in microseconds while a real user costs ~50ms, which is a trivially measurable account-enumeration oracle.
func (*Hasher) NeedsRehash ¶
NeedsRehash reports whether a stored hash was made with weaker parameters than the current policy. Callers rehash on the next successful login, which is the only moment the plaintext is available.
type Identity ¶
type Identity struct {
UserID uuid.UUID
Email string
Name string
WorkspaceID uuid.UUID
OrgID uuid.UUID
SessionID uuid.UUID
Role string
// APIKeyID is set when the request authenticated with an API key instead
// of a session cookie. Services consult it for the few operations that
// must require an interactive sign-in; everything else is deliberately
// blind to which credential was used.
APIKeyID *uuid.UUID
// contains filtered or unexported fields
}
Identity is an authenticated user together with the workspace they are acting in. Both the REST handlers and the dashboard handlers resolve to this same type, so authorization cannot diverge between the two surfaces.
func (*Identity) Can ¶
Can reports whether the identity holds a permission.
This is the RBAC evaluator, and it is deliberately called from the service layer rather than from middleware. Middleware only knows the route; the service knows which workspace the object being touched belongs to, which is the question that actually matters.
func (*Identity) Permissions ¶
Permissions returns the identity's permissions, for API-key scope intersection and for rendering the UI.
type LockoutPolicy ¶
LockoutPolicy throttles repeated failed logins for one account.
Per-account, complementing the per-IP rate limit. Neither alone is enough: per-IP misses a distributed attack on one account, and per-account lets an attacker lock a victim out by failing on purpose — which is why this uses a short expiring window rather than a lock an administrator must clear.
func (LockoutPolicy) LockedUntil ¶
LockedUntil returns when a lockout expires, or the zero time if the account is not locked.
func (LockoutPolicy) ThresholdParam ¶
func (p LockoutPolicy) ThresholdParam() int32
ThresholdParam and WindowSecondsParam narrow the policy for the SQL that applies it.
Clamped, not converted. A configured value large enough to wrap would arrive in the query as a negative threshold, and `failed_login_count + 1 >= -3` is true on the first attempt — a nonsense setting would lock every account out on one typo instead of being ignored.
func (LockoutPolicy) WindowSecondsParam ¶
func (p LockoutPolicy) WindowSecondsParam() int32
type LoginInput ¶
LoginInput is a sign-in attempt.
type Params ¶
type Params struct {
MemoryKiB uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
Params are the argon2 cost parameters.
Stored in the hash string itself (PHC format), so changing these does not invalidate existing passwords: an old hash still verifies against its own recorded parameters, and NeedsRehash reports that it should be upgraded on the next successful login.
type RegisterInput ¶
type RegisterInput struct {
Email string
Name string
Password string
// IsFirstUser marks the setup flow, which is permitted even when signup is
// closed — otherwise a fresh closed instance could never create its first
// account.
IsFirstUser bool
}
RegisterInput describes a new account.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service owns registration, login and session lifecycle.
func NewService ¶
func NewService(pool *pgxpool.Pool, cfg ServiceConfig) *Service
func (*Service) Authenticate ¶
Authenticate resolves a session token to an identity.
func (*Service) ChangePassword ¶
func (s *Service) ChangePassword(ctx context.Context, userID, keepSession uuid.UUID, current, next string) error
ChangePassword updates a password and logs out every other session.
func (*Service) Hasher ¶
Hasher exposes the configured hasher for the CLI, which creates users outside a request.
func (*Service) IdentityForEmail ¶
IdentityForEmail resolves a user to an identity without a session.
For the CLI, which acts as a named user rather than as root: `lctl apikey create` goes through the same service call and the same permission checks a request would, so the CLI cannot mint a key the user could not.
func (*Service) Login ¶
func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)
Login authenticates and starts a session.
Every failure returns ErrInvalidCredentials regardless of cause — unknown email, wrong password, no local password set. Distinguishing them tells an attacker which addresses are registered.
func (*Service) NeedsSetup ¶
NeedsSetup reports whether the instance has no users yet.
func (*Service) Register ¶
Register creates a user with their personal organization, workspace and owner membership, in one transaction.
Provisioning all four together is what lets Phase 1 behave as a single-user product while every row already carries the tenancy columns Phase 2 needs. A user without a workspace would be a state no other code path expects, so it must not be possible to observe one.
type ServiceConfig ¶
type ServiceConfig struct {
Params Params
TTL SessionTTL
Lockout LockoutPolicy
}
type Session ¶
type Session struct {
ID uuid.UUID
UserID uuid.UUID
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
Session is a live login.
type SessionTTL ¶
type SessionTTL struct {
// Absolute is the hard deadline from creation. A session dies at this
// point regardless of activity, which bounds how long a stolen token stays
// useful.
Absolute time.Duration
// Idle is the maximum gap between requests. Enforced against last_seen_at
// at read time rather than by rewriting expires_at, so changing the policy
// takes effect immediately and needs no data migration.
Idle time.Duration
}
SessionTTL bundles the two expiry rules.