accounts

package
v1.28.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

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

View Source
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.

View Source
const (
	RouteTOTP          = "/auth/mfa/totp"
	RouteMFAVerify     = "/auth/mfa/verify"
	RouteRecoveryCodes = "/auth/mfa/recovery-codes"
)

Second-factor routes.

View Source
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.

View Source
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.

View Source
const RecoveryCodeCount = 10

RecoveryCodeCount is how many single-use codes an enrolment issues.

View Source
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.

View Source
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

View Source
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.

View Source
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")
	// ErrMFAUnavailable reports that the deployment cannot store factors
	// — 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

func ClientIP(r *http.Request) string

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

func NewTOTPSecret() (string, error)

NewTOTPSecret returns a fresh base32 secret, in the alphabet authenticator apps read (no padding, upper case).

func TOTPURI

func TOTPURI(issuer, account, secret string) string

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

func TestingTOTPCode(tb testing.TB, secret string, at time.Time) string

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

func VerifyTOTP(secret, code string, at time.Time) (uint64, bool)

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.

const (
	FlavorSQLite   Flavor = "sqlite"
	FlavorPostgres Flavor = "postgres"
	FlavorMySQL    Flavor = "mysql"
)

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

type Mailer interface {
	Send(ctx context.Context, msg mail.Message) error
}

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

func NewSQLStore(ctx context.Context, db *sql.DB, cfg SQLStoreConfig) (*SQLStore, error)

NewSQLStore creates the tables if they do not exist and returns the store.

func (*SQLStore) ByEmail

func (s *SQLStore) ByEmail(ctx context.Context, email string) (Account, error)

ByEmail implements Store.

func (*SQLStore) ByID

func (s *SQLStore) ByID(ctx context.Context, id string) (Account, error)

ByID implements Store.

func (*SQLStore) ClearFailures

func (s *SQLStore) ClearFailures(ctx context.Context, key string) error

ClearFailures implements Store.

func (*SQLStore) ConsumeRecoveryCode

func (s *SQLStore) ConsumeRecoveryCode(ctx context.Context, accountID, hash string) (int, error)

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

func (s *SQLStore) CountRecoveryCodes(ctx context.Context, accountID string) (int, error)

CountRecoveryCodes implements MFAStore, counting the unused ones.

func (*SQLStore) Create

func (s *SQLStore) Create(ctx context.Context, account Account) (Account, error)

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

func (s *SQLStore) CreateToken(ctx context.Context, token Token) error

CreateToken implements Store.

func (*SQLStore) DeleteFactor

func (s *SQLStore) DeleteFactor(ctx context.Context, accountID string, kind FactorKind) error

DeleteFactor implements MFAStore.

func (*SQLStore) DeleteTokens

func (s *SQLStore) DeleteTokens(ctx context.Context, accountID string, purpose TokenPurpose) error

DeleteTokens implements Store.

func (*SQLStore) FailureCount

func (s *SQLStore) FailureCount(ctx context.Context, key string, now time.Time) (int, error)

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

func (s *SQLStore) PurgeExpired(ctx context.Context, now time.Time) (int64, error)

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

func (s *SQLStore) PutFactor(ctx context.Context, factor Factor) error

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

func (s *SQLStore) RecordFailure(ctx context.Context, key string, now time.Time) (int, error)

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) Update

func (s *SQLStore) Update(ctx context.Context, account Account) error

Update implements Store.

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

type SQLStoreConfig struct {
	Flavor      Flavor
	TablePrefix string
}

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

func (s *Service) ChangePassword(ctx context.Context, accountID, current, next string) error

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

func (s *Service) CompleteSecondFactor(ctx context.Context, code string) (Account, error)

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 (s *Service) ConsumeMagicLink(ctx context.Context, token string) (Account, error)

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

func (s *Service) DisableTOTP(ctx context.Context, accountID string) error

DisableTOTP removes the factor and every recovery code with it.

func (*Service) HasConfirmedFactor

func (s *Service) HasConfirmedFactor(ctx context.Context, accountID string) bool

HasConfirmedFactor reports whether sign-in needs a second step.

func (*Service) Login

func (s *Service) Login(ctx context.Context, email, password string) (Account, error)

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) Logout

func (s *Service) Logout(ctx context.Context) error

Logout ends the session in this context.

func (*Service) MarkAuthenticated

func (s *Service) MarkAuthenticated(ctx context.Context)

MarkAuthenticated stamps the session as freshly authenticated. Login and a completed second factor call it.

func (*Service) RegenerateRecoveryCodes

func (s *Service) RegenerateRecoveryCodes(ctx context.Context, accountID string) ([]string, error)

RegenerateRecoveryCodes issues a fresh set and invalidates the old one.

func (*Service) Register

func (s *Service) Register(ctx context.Context, email, username, password string) error

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

func (s *Service) RemainingRecoveryCodes(ctx context.Context, accountID string) (int, error)

RemainingRecoveryCodes reports how many are left, for the warning an account page shows before there are none.

func (s *Service) RequestMagicLink(ctx context.Context, email string) error

RequestMagicLink emails a one-time sign-in link. Like the reset flow, it reports success for an unknown address.

func (*Service) RequestPasswordReset

func (s *Service) RequestPasswordReset(ctx context.Context, email string) error

RequestPasswordReset sends a reset link, and reports success whether or not the address is registered — the same non-disclosure Register keeps.

func (*Service) RequireFreshAuth

func (s *Service) RequireFreshAuth(ctx context.Context, maxAge time.Duration) error

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

func (s *Service) ResetPassword(ctx context.Context, token, password string) (Account, error)

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

func (s *Service) RevokeSessions(ctx context.Context, accountID string) error

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

func (s *Service) StartPendingSession(ctx context.Context, account Account) error

StartPendingSession records a sign-in waiting on its second factor.

func (*Service) StartSession

func (s *Service) StartSession(ctx context.Context, account Account) error

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

func (s *Service) VerifyEmail(ctx context.Context, token string) (Account, error)

VerifyEmail consumes a verification token and marks the address confirmed.

func (*Service) VerifySecondFactor

func (s *Service) VerifySecondFactor(ctx context.Context, accountID, code string) error

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:

  1. Create reports ErrEmailTaken rather than overwriting.
  2. 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.
  3. 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"
)

Jump to

Keyboard shortcuts

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