recovery

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package recovery repairs a forgotten password.

**It is a defect being closed, not a feature being added** (finding F141). Until M51 a forgotten password locked the account out permanently — true of every account on every instance, including the one that administers the box — and the only route back was an operator rewriting an argon2 hash in the database on somebody's behalf. Nothing here is scoped by taste; it is scoped by what makes that finding's claim false.

Its own package rather than a method on internal/auth, matching internal/invite and internal/signup: the two other places a bearer-shaped token admits somebody holding no credential each own their table, their mail kind and their refusals, and a third one folded into the session service would be the first exception.

Four ideas run through it.

**The mailbox is the authority, and it is the only one.** There are no security questions, no backup address and no administrator-initiated reset. The first two are worse than the mailbox; the third is a permission-model question, and inventing one inside a recovery milestone is what D38 declined to do inside a signup milestone. `lctl instance principal move` (D98) already repairs *who administers the box*, which is a different question from *who knows this password*.

**A request answers the same way whatever the address is.** Same body, same status, and the same argon2 cost either way — so neither the response nor a stopwatch says whether an address is registered. The answer goes to the address by mail, which is where it belongs: the channel that proves the address exists is the mailbox, and the person holding the mailbox is entitled to know somebody tried. That is signup's stance (F13) rather than a second one invented here, and its cost is the same one signup accepted — mail is sent to addresses that never registered, bounded by the login limiter.

**With no mailer, it refuses out loud.** This is the one place in the product that deliberately breaks the *degrades mail-free* pattern D1 established, and it breaks it by refusing rather than by degrading into nothing: a reset request that silently succeeds into a void is worse than the lockout it was meant to cure. `SMTP_HOST` unset is the shipped default, so on a default instance this is a route that says the instance has no mailer and names the operator's route back.

**A successful reset ends every session and every other token.** A recovery that leaves the thief's session alive has recovered nothing. API keys are deliberately not revoked — a key is a separate credential with its own rotation story (D9, D87), and taking them out on a password reset would make recovery an outage.

Index

Constants

View Source
const ConsumedRetentionDays = 7

ConsumedRetentionDays is how long a spent row is kept before the purge takes it. Matches signup.ConsumedRetentionDays, and short for the same reason: the password is the durable evidence and the audit log holds the rest.

View Source
const MailKind = "password-reset"

MailKind names the template that carries the link, which is also the outbox's `kind` column and the filename in internal/ui/templates/mail.

View Source
const MailKindUnavailable = "password-reset-unavailable"

MailKindUnavailable is what an address that cannot be recovered gets instead: no account here, or an account this mechanism refuses.

**One template for both, deliberately.** It exists so the *response* does not have to distinguish them — the mail reaches the address and only its owner reads it, where a status code reaches whoever typed the address into the form (F13, mirroring `account-exists` at signup). Splitting it in two would put the distinction back into the one channel that is allowed to carry it, which is fine, and would also mean a suspended account's owner learns their status from a form somebody else filled in. The message names both possibilities and points at the operator, who is the only person who can act on either.

View Source
const TTL = time.Hour

TTL is how long an emailed reset link stays usable.

A constant rather than a variable, for the reason signup.VerificationTTL is one: an invitation's window is an administrator's policy about somebody else's onboarding and D29 made that tunable, where this is a person finishing something they started minutes ago. One hour rather than signup's day, because the two links are not worth the same — a verification token creates an account whose password the holder does not know, and this one sets a password on an account that already exists. Requesting again supersedes the old link, so nobody is ever stuck waiting for this to lapse.

View Source
const TokenBytes = 32

TokenBytes is the entropy in a reset token, matching an invitation's and a verification's.

Variables

View Source
var (
	// ErrNoMailer is a reset asked for on an instance with no relay configured.
	//
	// Loud, and that is the decision rather than an accident. Every other
	// consumer of the mailer degrades when it is absent; this one cannot,
	// because the mail *is* the mechanism. Answering "check your inbox" to
	// somebody whose instance can send nothing is the failure mode this refusal
	// exists to prevent.
	ErrNoMailer = errors.New("recovery: this instance has no mailer configured")

	// ErrNotResettable is every failure to complete a reset: no such token,
	// expired, already spent, an account whose status is not active, an account
	// with no password to replace.
	//
	// **One error for all of them, answered 404 and never 410.** A caller
	// enumerating cannot tell the five apart, which is the point: 410 would
	// confirm that a token existed, and a distinct refusal for a suspended
	// account would tell whoever holds the link what state the account is in.
	// Saying that is the operator's job and not this form's.
	ErrNotResettable = errors.New("recovery: this link is no longer valid")
)

Errors this package returns that a caller distinguishes.

Functions

This section is empty.

Types

type Completed

type Completed struct {
	UserID uuid.UUID
	Email  string
}

Completed is a reset that landed.

type Config

type Config struct {
	// AppURL is the origin a reset link points at.
	AppURL string
	// Hasher writes the new password, at the cost parameters the operator
	// configured, and equalizes the timing of a request. Required: a hasher this
	// package invented for itself would use costs nobody chose.
	Hasher *auth.Hasher
	// Mail delivers the link. Nil is an instance with no relay, and it is the one
	// dependency here whose absence is a refusal rather than a degradation.
	Mail Enqueuer
	// Audit records the completed reset. Nil records nothing.
	Audit audit.Recorder
	Log   *slog.Logger
}

Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree: the package doing the work does not read the environment.

type Enqueuer

type Enqueuer interface {
	Enqueue(ctx context.Context, to, kind string, data map[string]string) error
}

Enqueuer is internal/mail's writing half, as this package needs it.

Declared here rather than imported so "no mailer configured" is a nil interface rather than a flag every call site has to remember to check, and so a test satisfies it in four lines.

type Requested

type Requested struct {
	Email string
}

Requested is what a caller tells the person afterwards.

The normalized address and nothing else. No expiry, no identifier, no hint of which branch ran — the whole value of this struct is that there is only one shape of it. signup.Registered carries an ExpiresAt and had to truncate it to microseconds so Go's nanosecond clock did not answer, in the fractional digits, the question the status code no longer did (F13). The cheapest way not to repeat that is to return no timestamp: the mail says when the link lapses, and the mail only reaches somebody who has an account.

type Service

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

Service requests and completes password resets.

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) (*Service, error)

func (*Service) MailerConfigured

func (s *Service) MailerConfigured() bool

MailerConfigured reports whether this instance can deliver a reset link.

Read by the two surfaces before they draw a form, so somebody does not type an address into a page that was never going to send anything. Request refuses again on its own, because a surface remembering to ask is not the invariant.

func (*Service) PurgeFinished

func (s *Service) PurgeFinished(ctx context.Context, batch int32) (int64, error)

PurgeFinished removes lapsed and spent rows, reporting how many went. Called by the hourly maintenance pass, for the reason the signup sweep exists: a waiting room with no sweep is a table that grows forever with nothing watching it.

func (*Service) Request

func (s *Service) Request(ctx context.Context, address string) (*Requested, error)

Request mints a reset link and mails it, if the address can be recovered.

The refusals are silent by construction. Every path below returns the same Requested value and pays the same argon2 cost, so the caller cannot tell an address with an account from one without, and neither can a stopwatch. What differs is only which message lands in the mailbox.

func (*Service) Reset

func (s *Service) Reset(ctx context.Context, token, password string) (*Completed, error)

Reset writes a new password against a token and ends every session for the account.

The new password goes through auth.WritePassword, which is the function POST /account/password reaches through auth.Service.ChangePassword — one password-writing code path in the product and not two, which is what the milestone asked for by name rather than two call sites that happen to agree.

Jump to

Keyboard shortcuts

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