Documentation
¶
Overview ¶
Package accounts is the part of authentication an end user touches: registering, confirming an address, signing in and out, recovering a password, and being locked out after enough wrong guesses.
Nucleus already had the substrate — a chain of credential backends, a session manager, password hashing, a policy engine — and none of the product. Every application built the same six handlers on top of it, and each one re-decided how long a reset token lives, whether it can be used twice, and what a failed login reveals. Those are not application decisions; they are the decisions an authentication library exists to have made once, correctly.
What this package owns and what it does not ¶
It owns the FLOWS and their security properties: single-use tokens stored as hashes, answers that do not disclose whether an address is registered, session rotation on sign-in, progressive lockout, and re-authentication before a password change.
It does not own where accounts live. Store is the port; SQLStore is an implementation with its own tables for applications that do not have one yet, and an application with an existing users table implements the same three-method-plus-tokens interface against it.
Index ¶
- Constants
- Variables
- func ClientIP(r *http.Request) string
- func Module(service *Service) nucleus.ModuleSpec
- func NewTOTPSecret() (string, error)
- func TOTPURI(issuer, account, secret string) string
- func TestingTOTPCode(tb testing.TB, secret string, at time.Time) string
- func VerifyTOTP(secret, code string, at time.Time) (uint64, bool)
- type Account
- type Config
- type Factor
- type FactorKind
- type Flavor
- type MFAStore
- type Mailer
- type SQLStore
- func (s *SQLStore) ByEmail(ctx context.Context, email string) (Account, error)
- func (s *SQLStore) ByID(ctx context.Context, id string) (Account, error)
- func (s *SQLStore) ClearFailures(ctx context.Context, key string) error
- func (s *SQLStore) ConsumeRecoveryCode(ctx context.Context, accountID, hash string) (int, error)
- func (s *SQLStore) ConsumeToken(ctx context.Context, purpose TokenPurpose, hash string) (Token, error)
- func (s *SQLStore) CountRecoveryCodes(ctx context.Context, accountID string) (int, error)
- func (s *SQLStore) Create(ctx context.Context, account Account) (Account, error)
- func (s *SQLStore) CreateToken(ctx context.Context, token Token) error
- func (s *SQLStore) DeleteFactor(ctx context.Context, accountID string, kind FactorKind) error
- func (s *SQLStore) DeleteTokens(ctx context.Context, accountID string, purpose TokenPurpose) error
- func (s *SQLStore) FailureCount(ctx context.Context, key string, now time.Time) (int, error)
- func (s *SQLStore) GetFactor(ctx context.Context, accountID string, kind FactorKind) (Factor, error)
- func (s *SQLStore) PurgeExpired(ctx context.Context, now time.Time) (int64, error)
- func (s *SQLStore) PutFactor(ctx context.Context, factor Factor) error
- func (s *SQLStore) RecordFailure(ctx context.Context, key string, now time.Time) (int, error)
- func (s *SQLStore) ReplaceRecoveryCodes(ctx context.Context, accountID string, hashes []string) error
- func (s *SQLStore) Update(ctx context.Context, account Account) error
- func (s *SQLStore) UpdateFactorStep(ctx context.Context, accountID string, kind FactorKind, step uint64) error
- type SQLStoreConfig
- type Service
- func (s *Service) BeginTOTPEnrolment(ctx context.Context, accountID string) (secret, uri string, err error)
- func (s *Service) ChangePassword(ctx context.Context, accountID, current, next string) error
- func (s *Service) CompleteSecondFactor(ctx context.Context, code string) (Account, error)
- func (s *Service) ConfirmTOTPEnrolment(ctx context.Context, accountID, code string) ([]string, error)
- func (s *Service) ConsumeMagicLink(ctx context.Context, token string) (Account, error)
- func (s *Service) DisableTOTP(ctx context.Context, accountID string) error
- func (s *Service) HasConfirmedFactor(ctx context.Context, accountID string) bool
- func (s *Service) Login(ctx context.Context, email, password string) (Account, error)
- func (s *Service) LoginFrom(ctx context.Context, email, password, clientKey string) (Account, error)
- func (s *Service) Logout(ctx context.Context) error
- func (s *Service) MarkAuthenticated(ctx context.Context)
- func (s *Service) RegenerateRecoveryCodes(ctx context.Context, accountID string) ([]string, error)
- func (s *Service) Register(ctx context.Context, email, username, password string) error
- func (s *Service) RemainingRecoveryCodes(ctx context.Context, accountID string) (int, error)
- func (s *Service) RequestMagicLink(ctx context.Context, email string) error
- func (s *Service) RequestPasswordReset(ctx context.Context, email string) error
- func (s *Service) RequireFreshAuth(ctx context.Context, maxAge time.Duration) error
- func (s *Service) ResetPassword(ctx context.Context, token, password string) (Account, error)
- func (s *Service) RevokeSessions(ctx context.Context, accountID string) error
- func (s *Service) StartPendingSession(ctx context.Context, account Account) error
- func (s *Service) StartSession(ctx context.Context, account Account) error
- func (s *Service) UseSessions(sm *auth.SessionManager)
- func (s *Service) VerifyEmail(ctx context.Context, token string) (Account, error)
- func (s *Service) VerifySecondFactor(ctx context.Context, accountID, code string) error
- type Store
- type Token
- type TokenPurpose
Constants ¶
const ( RouteRegister = "/auth/register" RouteVerifyEmail = "/auth/verify-email" RouteLogin = "/auth/login" RouteLogout = "/auth/logout" RoutePasswordReset = "/auth/password/reset" RoutePasswordChange = "/auth/password/change" RouteMagicLink = "/auth/magic-link" )
Routes are the paths this module serves. They are constants because an application links to them, a policy file names them, and a test asks for them — three places that must not drift apart.
const ( RouteTOTP = "/auth/mfa/totp" RouteMFAVerify = "/auth/mfa/verify" RouteRecoveryCodes = "/auth/mfa/recovery-codes" )
Second-factor routes.
const ( SessionKeyAccountID = "account_id" SessionKeyEmail = "account_email" )
SessionKeyAccountID and SessionKeyEmail are where a signed-in identity is recorded. They are exported because an operator surface enumerating sessions (orbit) needs to know which key holds the identity.
const ReauthWindow = 15 * time.Minute
ReauthWindow is how fresh a sign-in must be to enrol or remove a second factor. Fifteen minutes is long enough not to be a nuisance inside one sitting, and short enough that a session left open on a shared machine is not a way to take the account over.
const RecoveryCodeCount = 10
RecoveryCodeCount is how many single-use codes an enrolment issues.
const SessionKeyAuthenticatedAt = "account_authenticated_at"
SessionKeyAuthenticatedAt records when identity was last PROVEN in this session — a password or a second factor, not merely a cookie that still resolves.
const SessionKeyPendingAccountID = "account_pending_id"
SessionKeyPendingAccountID holds the account that has passed the password step and not the second factor. It is a DIFFERENT key from SessionKeyAccountID on purpose: anything that reads the signed-in identity must not see a half-finished sign-in as a finished one.
Variables ¶
var ( // ErrNotFound reports that no account matches. Handlers must not turn // it into a user-visible "no such account": that is the disclosure // this package exists to avoid. ErrNotFound = errors.New("accounts: no such account") // ErrInvalidCredentials is returned for a wrong password AND for an // unknown account, deliberately indistinguishable. ErrInvalidCredentials = errors.New("accounts: invalid credentials") // ErrAccountLocked reports too many recent failures for this identity. ErrAccountLocked = errors.New("accounts: too many attempts, try again later") // ErrEmailNotVerified reports a sign-in attempt on an account whose // address was never confirmed, when the configuration requires it. ErrEmailNotVerified = errors.New("accounts: email address is not verified") // ErrAccountDisabled reports an account an operator turned off. ErrAccountDisabled = errors.New("accounts: account is disabled") // ErrInvalidToken covers a token that never existed, already ran out, // or was already used. One error, because telling them apart tells an // attacker which guess was closer. ErrInvalidToken = errors.New("accounts: invalid or expired token") // ErrWeakPassword reports a password below the configured minimum. ErrWeakPassword = errors.New("accounts: password is too short") // ErrEmailTaken reports a duplicate registration to the STORE layer. // The service never returns it to a caller: a registration form that // says "already taken" is an account enumeration oracle. ErrEmailTaken = errors.New("accounts: email is already registered") )
Errors a caller is expected to distinguish. Everything else is wrapped with context and reported as a server-side failure.
var ( // ErrMFARequired reports that the password was right and a second // factor is still needed. It is not a failure: the caller continues // with VerifySecondFactor. ErrMFARequired = errors.New("accounts: a second factor is required") // ErrInvalidCode covers a wrong, expired or already-used TOTP code // and a wrong recovery code. One error, for the same reason // ErrInvalidToken is one. ErrInvalidCode = errors.New("accounts: invalid code") // ErrMFANotEnrolled reports an account with no second factor. ErrMFANotEnrolled = errors.New("accounts: no second factor is enrolled") // — no MFAStore, or no encryption key for the secrets. ErrMFAUnavailable = errors.New("accounts: second factors are not available in this deployment") // ErrReauthenticationRequired reports an operation that needs a // fresher proof of identity than the session carries. ErrReauthenticationRequired = errors.New("accounts: re-authentication required") )
Errors the second-factor flows return.
Functions ¶
func ClientIP ¶
ClientIP is re-exported for handlers that want to key a lockout on the address as well as the identity.
func Module ¶
func Module(service *Service) nucleus.ModuleSpec
Module mounts the account flows on the application's router.
The handlers are thin on purpose: every decision that matters — what a failed login reveals, how long a token lives, whether a link can be used twice — is in Service, where it is tested once instead of in each application's copy of these seven handlers. The module takes the application's OWN session manager at start-up rather than whatever the caller happened to build the service with. That is not a convenience: a service holding a DIFFERENT manager from the one mounted as middleware writes into a session the request never carries, and the first symptom is a 500 on sign-in — which is exactly how this was found.
func NewTOTPSecret ¶
NewTOTPSecret returns a fresh base32 secret, in the alphabet authenticator apps read (no padding, upper case).
func TOTPURI ¶
TOTPURI builds the otpauth:// URI an authenticator app scans as a QR code. issuer is the product name the app shows; account is what distinguishes one entry from another, usually the email address.
The issuer appears TWICE — in the label and as a parameter — because that is what the de-facto spec requires and what makes an app group entries correctly. Getting it wrong is why an app sometimes shows "Unknown" next to a code.
func TestingTOTPCode ¶
TestingTOTPCode returns the code a factor's secret produces at a given time. It is exported for tests OUTSIDE this package — the module's own HTTP tests, and an application's tests for a login page it wrote — which otherwise have no way to produce a valid code without reimplementing RFC 6238 to check an implementation of RFC 6238.
It takes a testing.TB so it cannot be called from production code by accident.
func VerifyTOTP ¶
VerifyTOTP reports whether code is valid for the secret at this time, and returns the COUNTER STEP it matched.
The step is returned, and not discarded, for the property that makes a one-time password one-time: a caller records the last accepted step and refuses anything at or below it. Without that, a code shouted across a room works for the rest of its thirty seconds, and every replay inside the window succeeds.
The comparison is constant-time. A code is a six-digit secret; comparing it with == leaks how many leading digits were right to anyone who can measure, which is what turns a million guesses into a thousand.
Types ¶
type Account ¶
type Account struct {
ID string
Email string
Username string
PasswordHash string
// EmailVerified records whether the address was confirmed. An account
// created by an operator may start verified; one that registered
// itself does not.
EmailVerified bool
// Disabled is an operator switch. A disabled account cannot sign in
// and cannot recover a password.
Disabled bool
Role string
CreatedAt time.Time
UpdatedAt time.Time
}
Account is one registered identity.
type Config ¶
type Config struct {
// BaseURL is the address a link in an email points at, e.g.
// "https://app.example.com". Required when mail is sent.
BaseURL string
// From is the sender address for account mail.
From string
// RequireEmailVerification refuses sign-in until the address is
// confirmed. Default false: an application that does not send mail
// must not lock every user out by omission.
RequireEmailVerification bool
// MinPasswordLength defaults to 12. It is a length and not a
// composition rule on purpose: length is what resists guessing, and
// composition rules push people towards Passw0rd!.
MinPasswordLength int
// VerifyTokenTTL defaults to 24h, ResetTokenTTL to 1h and
// MagicLinkTTL to 15m. A reset link lives shorter than a verification
// one because it grants more.
VerifyTokenTTL time.Duration
ResetTokenTTL time.Duration
MagicLinkTTL time.Duration
// Issuer is the product name an authenticator app shows next to a
// code. Defaults to "Nucleus".
Issuer string
// MFAEncryptionKey is a 32-byte key that encrypts factor secrets at
// rest. Without it second factors are REFUSED rather than stored in
// the clear: a TOTP secret is a password equivalent, and a database
// backup that leaks one hands over the factor forever.
MFAEncryptionKey []byte
// LockoutThreshold is how many recent failures lock an identity out;
// default 10. LockoutWindow (default 15m) is how far back failures
// count, and LockoutBase (default 1s) is the first backoff step —
// each additional failure doubles the wait, capped at LockoutWindow.
LockoutThreshold int
LockoutWindow time.Duration
LockoutBase time.Duration
}
Config tunes the flows. Every zero value has a documented default, so an application that sets nothing gets the posture this package considers correct rather than an unusable one.
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig returns the configuration a zero Config resolves to. It exists so a caller — or a posture document — can read the defaults instead of restating them, which is how a documented default drifts from the real one.
type Factor ¶
type Factor struct {
AccountID string
Kind FactorKind
Secret string
Confirmed bool
LastStep uint64
CreatedAt time.Time
}
Factor is one enrolled second factor.
Secret is stored ENCRYPTED (see MFAEncryptionKey): a TOTP secret is a password equivalent — anyone holding it can mint codes forever — and a database backup that leaks it hands over every second factor at once. LastStep is the counter step most recently accepted, which is what makes a one-time password one-time.
type FactorKind ¶
type FactorKind string
FactorKind names a second factor.
const FactorTOTP FactorKind = "totp"
FactorTOTP is a time-based one-time password from an authenticator app.
type Flavor ¶
type Flavor string
Flavor is the SQL dialect the store speaks. The differences are small and real: placeholder syntax, the type of a timestamp, and how a conflict is reported. They are handled explicitly rather than by string-matching driver errors, which is how a store ends up "working" on one engine.
type MFAStore ¶
type MFAStore interface {
PutFactor(ctx context.Context, factor Factor) error
// GetFactor returns ErrMFANotEnrolled when there is none.
GetFactor(ctx context.Context, accountID string, kind FactorKind) (Factor, error)
DeleteFactor(ctx context.Context, accountID string, kind FactorKind) error
// UpdateFactorStep records the last accepted counter step, refusing
// to move it backwards.
UpdateFactorStep(ctx context.Context, accountID string, kind FactorKind, step uint64) error
ReplaceRecoveryCodes(ctx context.Context, accountID string, hashes []string) error
// ConsumeRecoveryCode marks one code used, ATOMICALLY, and reports
// how many remain. ErrInvalidCode when it does not match.
ConsumeRecoveryCode(ctx context.Context, accountID, hash string) (remaining int, err error)
CountRecoveryCodes(ctx context.Context, accountID string) (int, error)
}
MFAStore is where factors and recovery codes live.
It is a SEPARATE interface from Store, and deliberately: an application that implemented Store against its own users table keeps working without it, and gains second factors by implementing this too. Widening Store would have broken every existing implementation to add a feature they may not want.
type Mailer ¶
Mailer is what the service needs to send account mail: one method, so an application can hand it a queue-backed sender (mail.EnqueueTx through a small adapter) instead of the direct one.
type SQLStore ¶
type SQLStore struct {
// contains filtered or unexported fields
}
SQLStore keeps accounts, tokens and failure counters in three tables it owns. An application with its own users table implements Store instead; this one exists so a new application has somewhere for them to live on the first day.
func NewSQLStore ¶
NewSQLStore creates the tables if they do not exist and returns the store.
func (*SQLStore) ClearFailures ¶
ClearFailures implements Store.
func (*SQLStore) ConsumeRecoveryCode ¶
ConsumeRecoveryCode implements MFAStore atomically, the same shape as ConsumeToken: the UPDATE carries the conditions, so one code cannot be spent twice by two concurrent requests.
func (*SQLStore) ConsumeToken ¶
func (s *SQLStore) ConsumeToken(ctx context.Context, purpose TokenPurpose, hash string) (Token, error)
ConsumeToken implements Store atomically: the UPDATE carries every condition, so two concurrent uses of the same link cannot both win. A read-then-write here would make a password-reset link replayable under exactly the race an attacker would try to cause.
func (*SQLStore) CountRecoveryCodes ¶
CountRecoveryCodes implements MFAStore, counting the unused ones.
func (*SQLStore) Create ¶
Create implements Store. A duplicate address is reported as ErrEmailTaken by asking the database, not by checking first: a read-then-write leaves a window where two registrations both find the address free.
func (*SQLStore) CreateToken ¶
CreateToken implements Store.
func (*SQLStore) DeleteFactor ¶
DeleteFactor implements MFAStore.
func (*SQLStore) DeleteTokens ¶
DeleteTokens implements Store.
func (*SQLStore) FailureCount ¶
FailureCount implements Store.
func (*SQLStore) GetFactor ¶
func (s *SQLStore) GetFactor(ctx context.Context, accountID string, kind FactorKind) (Factor, error)
GetFactor implements MFAStore.
func (*SQLStore) PurgeExpired ¶
PurgeExpired drops used and expired tokens and stale failure rows. An application calls it from a scheduled job; nothing depends on it for correctness, because every read already filters on time.
func (*SQLStore) PutFactor ¶
PutFactor implements MFAStore. It replaces any factor of the same kind: re-enrolling is how somebody who lost their phone starts over, and it must not need a delete first.
func (*SQLStore) RecordFailure ¶
RecordFailure implements Store, returning the count INCLUDING this one.
func (*SQLStore) ReplaceRecoveryCodes ¶
func (s *SQLStore) ReplaceRecoveryCodes(ctx context.Context, accountID string, hashes []string) error
ReplaceRecoveryCodes implements MFAStore.
func (*SQLStore) UpdateFactorStep ¶
func (s *SQLStore) UpdateFactorStep(ctx context.Context, accountID string, kind FactorKind, step uint64) error
UpdateFactorStep implements MFAStore. The predicate refuses to move the step backwards, so two requests racing with the same code cannot both find it unused: the second one updates zero rows.
type SQLStoreConfig ¶
SQLStoreConfig configures the store. TablePrefix defaults to "nucleus_".
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service runs the account flows over a Store.
func New ¶
func New(store Store, sessions *auth.SessionManager, mailer Mailer, templates *mail.Templates, cfg Config, logger *slog.Logger) (*Service, error)
New builds the service. sessions may be nil for an API-only deployment that signs in with tokens rather than cookies; mailer may be nil, in which case the flows that need mail refuse rather than pretending to have sent it.
func (*Service) BeginTOTPEnrolment ¶
func (s *Service) BeginTOTPEnrolment(ctx context.Context, accountID string) (secret, uri string, err error)
BeginTOTPEnrolment issues a secret and the otpauth URI to show as a QR code. The factor is stored UNCONFIRMED: a secret nobody has proven they can read must not be able to lock an account out of its own sign-in.
func (*Service) ChangePassword ¶
ChangePassword sets a new password for a signed-in account, after verifying the current one. The re-authentication is the point: a session left open on a shared machine must not be enough to take the account over.
func (*Service) CompleteSecondFactor ¶
CompleteSecondFactor finishes a pending sign-in with a TOTP or recovery code and returns the account it signed in.
Failures here count towards the same lockout as a wrong password: a second factor that can be guessed without limit is a six-digit password.
func (*Service) ConfirmTOTPEnrolment ¶
func (s *Service) ConfirmTOTPEnrolment(ctx context.Context, accountID, code string) ([]string, error)
ConfirmTOTPEnrolment verifies the first code and turns the factor on, returning the recovery codes. They are shown ONCE — only their hashes are kept — which is why they are returned here and nowhere else.
func (*Service) ConsumeMagicLink ¶
ConsumeMagicLink signs in with a one-time link. A magic link also confirms the address: the owner just proved they read mail there.
func (*Service) DisableTOTP ¶
DisableTOTP removes the factor and every recovery code with it.
func (*Service) HasConfirmedFactor ¶
HasConfirmedFactor reports whether sign-in needs a second step.
func (*Service) Login ¶
Login verifies a password and, when a session manager is configured, starts a session. It counts failures against the ACCOUNT alone; see LoginFrom for the scoping an internet-facing deployment wants.
func (*Service) LoginFrom ¶
func (s *Service) LoginFrom(ctx context.Context, email, password, clientKey string) (Account, error)
LoginFrom is Login with the caller's identity — an address, or whatever the deployment uses to tell one client from another — folded into the lockout key.
The choice matters and has no free answer. Counting failures per ACCOUNT is what ASVS asks for and what stops credential stuffing against one user; it also lets anyone lock a known address out for the window by typing ten wrong passwords, which is a denial of service against that person. Counting per CLIENT stops that and lets a botnet spread its guesses across addresses.
So the framework does not choose silently: Login counts per account (the safe default against guessing), and an application that fronts the internet passes a client key here to count per pair — the attacker then locks out only themselves, and a distributed attack still meets the per-account limit through the rate limiter, which keys on identity.
svc.LoginFrom(ctx, email, password, accounts.ClientIP(r))
func (*Service) MarkAuthenticated ¶
MarkAuthenticated stamps the session as freshly authenticated. Login and a completed second factor call it.
func (*Service) RegenerateRecoveryCodes ¶
RegenerateRecoveryCodes issues a fresh set and invalidates the old one.
func (*Service) Register ¶
Register creates an account and sends a verification link.
It returns nil for an address that is ALREADY registered, after sending that address a "someone tried to register you" style verification link instead. That is not politeness: a registration form that answers differently for a taken address is an account enumeration oracle, and it is the most-used one on the web.
func (*Service) RemainingRecoveryCodes ¶
RemainingRecoveryCodes reports how many are left, for the warning an account page shows before there are none.
func (*Service) RequestMagicLink ¶
RequestMagicLink emails a one-time sign-in link. Like the reset flow, it reports success for an unknown address.
func (*Service) RequestPasswordReset ¶
RequestPasswordReset sends a reset link, and reports success whether or not the address is registered — the same non-disclosure Register keeps.
func (*Service) RequireFreshAuth ¶
RequireFreshAuth returns ErrReauthenticationRequired unless identity was proven within maxAge.
This is what a sensitive operation asks before it proceeds — changing an email address, disabling a second factor, issuing an API key. A session that has been open for three weeks is evidence that somebody signed in three weeks ago, and nothing about who is at the keyboard now.
func (*Service) ResetPassword ¶
ResetPassword consumes a reset token and sets a new password. Every other outstanding reset token for the account is dropped, and so is every active session: a password reset that leaves the attacker's session running has not recovered the account.
func (*Service) RevokeSessions ¶
RevokeSessions ends every stored session belonging to an account. It is what a password reset does, and what a "sign out everywhere" button calls.
func (*Service) StartPendingSession ¶
StartPendingSession records a sign-in waiting on its second factor.
func (*Service) StartSession ¶
StartSession records the signed-in identity in the request's session, rotating the token first so a session fixed before sign-in cannot be reused after it.
func (*Service) UseSessions ¶
func (s *Service) UseSessions(sm *auth.SessionManager)
UseSessions installs the session manager the flows write into. The module calls it at start-up with the application's own manager; an application wiring the service by hand outside a module calls it itself.
func (*Service) VerifyEmail ¶
VerifyEmail consumes a verification token and marks the address confirmed.
func (*Service) VerifySecondFactor ¶
VerifySecondFactor checks a TOTP code or a recovery code.
The step the code matched is recorded, and a code at or below the last accepted step is refused: without that, a code read over someone's shoulder stays valid for the rest of its thirty seconds and every replay inside the window succeeds.
type Store ¶
type Store interface {
Create(ctx context.Context, account Account) (Account, error)
ByID(ctx context.Context, id string) (Account, error)
ByEmail(ctx context.Context, email string) (Account, error)
Update(ctx context.Context, account Account) error
CreateToken(ctx context.Context, token Token) error
// ConsumeToken atomically marks a token used and returns it. It
// returns ErrInvalidToken for a token that is unknown, expired, of
// another purpose, or already used.
ConsumeToken(ctx context.Context, purpose TokenPurpose, hash string) (Token, error)
// DeleteTokens removes every outstanding token of a purpose for an
// account — what a completed reset does to the links it superseded.
DeleteTokens(ctx context.Context, accountID string, purpose TokenPurpose) error
RecordFailure(ctx context.Context, key string, now time.Time) (int, error)
ClearFailures(ctx context.Context, key string) error
// FailureCount reports recent failures without recording one, for the
// check that happens BEFORE a password is verified.
FailureCount(ctx context.Context, key string, now time.Time) (int, error)
}
Store is where accounts, their single-use tokens and their failed-attempt counters live.
The contract has three properties the flows depend on, and an implementation that breaks any of them breaks a security property rather than a feature:
- Create reports ErrEmailTaken rather than overwriting.
- ConsumeToken is ATOMIC: two concurrent uses of the same token return it exactly once. A reset link that works twice is a reset link an attacker can replay after the owner has used it.
- RecordFailure returns the count INCLUDING the failure it just recorded, so a caller cannot be off by one about a lockout.
type Token ¶
type Token struct {
Hash string
AccountID string
Purpose TokenPurpose
ExpiresAt time.Time
CreatedAt time.Time
UsedAt time.Time
}
Token is a single-use credential sent out of band.
Hash is stored, never the token itself: the rows are as readable as any other table, and a leaked database must not hand over live password-reset links. The token is a secret with the same weight as a password, and it is treated like one.
type TokenPurpose ¶
type TokenPurpose string
TokenPurpose says what a single-use token is for. A token issued to verify an address must not be usable to reset a password, so the purpose is part of what is looked up rather than a comment on the row.
const ( // PurposeVerifyEmail confirms an address at registration. PurposeVerifyEmail TokenPurpose = "verify_email" // PurposeResetPassword authorises setting a new password. PurposeResetPassword TokenPurpose = "reset_password" // PurposeMagicLink signs in without a password. PurposeMagicLink TokenPurpose = "magic_link" )