Help us shape the future of Go! Take the 2026 Go Developer Survey

secondfactor

package
v0.3.1 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: AGPL-3.0 Imports: 33 Imported by: 0

Documentation

Overview

Package secondfactor is a User's TOTP Second factor and Recovery codes (ADR 0020). A User enrolls in two phases: a pending secret is created, then confirmed with a valid code before it counts. The secret is sealed with the instance cipher; a TOTP code is single-use (the last accepted time step is stored) and checked against an injected clock. Recovery codes are stored hashed, each single-use, and shown once. Enrolling, regenerating, disabling and resetting bump the User's session epoch inside the change's transaction.

The Second factor belongs to the User, not a Workspace, so this package works on the raw *ent.Client (the User is reached by id).

Index

Constants

View Source
const (
	// PurposeRegistration is the ceremony that adds a Passkey to a signed-in User.
	PurposeRegistration ceremonyPurpose = "register"
	// PurposeLogin is the usernameless login ceremony (the User is not known at `begin`).
	PurposeLogin ceremonyPurpose = "login"
	// PurposeCheck is the step-up ceremony of a signed-in User: a fresh Passkey check that
	// stands in for the password (ADR 0052). It is bound to the User and their session epoch.
	PurposeCheck ceremonyPurpose = "check"
)
View Source
const CeremonyTTL = 5 * time.Minute

CeremonyTTL is how long a Passkey ceremony cookie lives: the time to touch the authenticator.

View Source
const GracePeriod = 7 * 24 * time.Hour

GracePeriod is how long a User without a Second factor keeps access to a Workspace with a Two-factor requirement (ADR 0020).

View Source
const Issuer = "sphericon"

Issuer names the instance in the authenticator app.

View Source
const RecoveryCodeCount = 10

RecoveryCodeCount is the size of a Recovery code set.

View Source
const ReminderLead = 24 * time.Hour

ReminderLead is how long before a User's grace ends they are reminded to set up a Second factor (ADR 0020).

View Source
const SpentChallengeRetention = 24 * time.Hour

SpentChallengeRetention is how long a spent challenge is kept before the daily cleanup prunes it. It is longer than CeremonyTTL, so a challenge cannot outlive its row: a replay of a pruned challenge fails on the cookie's expiry instead.

Variables

View Source
var (
	// ErrCeremony: the ceremony cookie is missing, forged, expired, another ceremony's or
	// another User's, or the User's sessions ended since it was minted.
	ErrCeremony = errors.New("secondfactor: passkey ceremony cookie unusable")
	// ErrChallengeSpent: the ceremony already finished once.
	ErrChallengeSpent = errors.New("secondfactor: passkey challenge already spent")
	// ErrPasskeysUnavailable: the Instance's public URL has no host name a Passkey can be bound
	// to (an IP address), so no Passkey can be registered or used on it.
	ErrPasskeysUnavailable = errors.New("secondfactor: passkeys need a host name, not an IP address")
	// ErrPasskeyRefused: the authenticator's response is not acceptable: malformed, not
	// signed for this challenge or origin, made without user verification, or for a
	// credential that is already registered.
	ErrPasskeyRefused = errors.New("secondfactor: passkey response refused")
	// ErrPasskeyNotFound: no Passkey of that id belongs to the User (another User's counts as
	// none).
	ErrPasskeyNotFound = errors.New("secondfactor: passkey not found")
	// ErrLastSignIn: the Passkey is the last way the User can sign in: no password, no Linked
	// identity and no other Passkey.
	ErrLastSignIn = errors.New("secondfactor: the passkey is the last way to sign in")
)
View Source
var (
	// ErrInvalidCode: the code is not a current, unused TOTP code nor an unused
	// Recovery code of the User.
	ErrInvalidCode = errors.New("secondfactor: invalid code")
	// ErrNotActive: the User has no confirmed Second factor.
	ErrNotActive = errors.New("secondfactor: no active second factor")
	// ErrAlreadyActive: enrollment is refused while a Second factor is active.
	ErrAlreadyActive = errors.New("secondfactor: second factor already active")
	// ErrNoPending: there is no pending enrollment to confirm.
	ErrNoPending = errors.New("secondfactor: no pending enrollment")
	// ErrBindingMoved: the challenge binding a second step was started under has
	// moved (another second step, an epoch bump or a password change got there
	// first), so the challenge is spent.
	ErrBindingMoved = errors.New("secondfactor: challenge binding moved")
)
View Source
var ErrPasskeyUnknown = errors.New("secondfactor: passkey credential unknown")

ErrPasskeyUnknown means the response names no credential: it is malformed, or its credential id was never registered (or was removed).

View Source
var ErrRequired = errors.New("second factor required")

ErrRequired means the Workspace requires a Second factor, the User has none and their grace has ended. The site answers it with 403 second_factor_required.

Functions

func Active

func Active(u *ent.User) bool

Active reports whether the User has a confirmed Second factor. A pending enrollment is not one.

func Deadline

func Deadline(m *ent.Membership) (time.Time, bool)

Deadline is the User's grace end in the Workspace of the Membership, or false when none applies: the Workspace has no requirement or the User has a Second factor. The Membership's User and Workspace edges must be loaded.

func GraceEnds

func GraceEnds(requiredAt *time.Time, memberSince time.Time) (time.Time, bool)

GraceEnds returns when a member's grace under a Workspace's Two-factor requirement ends: GracePeriod after the later of the requirement's start (requiredAt) and the Membership's creation. ok is false when the Workspace has no requirement (requiredAt is nil). It does not look at the User: one who has a Second factor is never withheld, whatever this returns.

func PruneSpentChallenges

func PruneSpentChallenges(ctx context.Context, client *ent.Client, cutoff time.Time) (int, error)

PruneSpentChallenges deletes the spent challenges recorded before cutoff and returns how many went. The cutoff must be older than a ceremony can live (SpentChallengeRetention is).

func RemindDeadlines

func RemindDeadlines(ctx context.Context, client *ent.Client, now time.Time, send SendReminder) error

RemindDeadlines reminds, once per grace, every User without a Second factor whose grace in a Workspace with a Two-factor requirement ends within ReminderLead of now. The Membership records the reminder: it is claimed before send runs and released if send fails, so a later call retries it and a concurrent or repeated call never sends it twice. A reminder from an earlier grace (the requirement switched off and on again) does not count. It lists the Memberships of every Workspace, so it works on the raw client; each claim is scoped to the Workspace of the Membership it loaded.

func UserHandle

func UserHandle(userID int64) []byte

UserHandle is the WebAuthn user handle of a User: the same for all their Passkeys, as the specification asks, and derived from the id so no column holds it. It carries no personal data. Changing it would orphan every registered Passkey.

func UserIDOfHandle

func UserIDOfHandle(handle []byte) (int64, bool)

UserIDOfHandle is the User a handle names; false for a handle UserHandle never produced.

func Withheld

func Withheld(m *ent.Membership, now time.Time, strongSession bool) bool

Withheld reports whether the Workspace of the Membership is withheld from its User at now: a deadline applies and has passed, and the session does not meet the requirement by itself. strongSession is true for a session that passed a Second factor step or began with a Passkey (ADR 0052). The Membership's User and Workspace edges must be loaded.

Types

type Enrollment

type Enrollment struct {
	Secret string // base32 key
	URI    string // otpauth:// URI
	QRCode string // PNG data URI of the URI
}

Enrollment is a pending secret, shown so the User can add it to an app.

type Method

type Method string

Method is how a code was verified.

const (
	MethodTOTP         Method = "totp"
	MethodRecoveryCode Method = "recovery_code"
)

type Module

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

Module enrolls, verifies and removes Second factors.

func New

func New(client *ent.Client, bus *events.Bus, cipher *secrets.Cipher, now func() time.Time) *Module

New builds the module. now is the clock TOTP codes are checked against (nil means time.Now); tests inject one.

func (*Module) ChallengeBinding

func (m *Module) ChallengeBinding(ctx context.Context, u *ent.User) (string, error)

ChallengeBinding is the value a login challenge (ADR 0020) is keyed to: it changes on every successful Verify (a TOTP success moves the last accepted time step, a Recovery code success lowers the count of unused codes), on a password change and on every session epoch bump (enroll, regenerate, disable, reset). Keying the challenge's signature to it makes the challenge single-use without a store of spent challenges, so it holds across instances; Verify re-checks it under a lock, so two racing second steps cannot both use it. ErrNotActive without a Second factor.

func (*Module) ConfirmEnrollment

func (m *Module) ConfirmEnrollment(ctx context.Context, userID int64, code string) (*ent.User, []string, error)

ConfirmEnrollment activates the pending secret when code is a current code for it. It issues the Recovery codes (returned once), bumps the session epoch and records `user.second_factor_enroll`, all in one transaction, and returns the stored User for reissuing the acting session. ErrNoPending without a pending enrollment, ErrInvalidCode on a wrong code.

func (*Module) DeletionCode

func (m *Module) DeletionCode(code string) accounts.DeleteConfirm

DeletionCode is the accounts.DeleteConfirm of User deletion (ADR 0050): with a Second factor it spends code in the deletion's transaction (ErrInvalidCode on a wrong one), so the code is spent only if the deletion commits; without one it confirms nothing.

func (*Module) Disable

func (m *Module) Disable(ctx context.Context, userID int64, code string) (*ent.User, error)

Disable removes the User's Second factor when code is a current TOTP code or an unused Recovery code (the caller has checked the password). It clears the factor and its Recovery codes, bumps the session epoch and records `user.second_factor_disable`. ErrNotActive without a Second factor, ErrInvalidCode on a wrong code.

func (*Module) RegenerateRecoveryCodes

func (m *Module) RegenerateRecoveryCodes(ctx context.Context, userID int64) (*ent.User, []string, error)

RegenerateRecoveryCodes replaces the User's Recovery codes with a fresh set (returned once; the previous set stops working), bumps the session epoch and records `user.recovery_codes_regenerate`. ErrNotActive without a Second factor.

func (*Module) Reset

func (m *Module) Reset(ctx context.Context, userID int64, actor events.Actor, workspaceID int64) error

Reset clears the User's Second factor, Recovery codes and Passkeys and bumps the session epoch, ending every session of theirs: an Owner or Admin resetting a member of workspaceID. It records `user.second_factor_reset` by actor in that Workspace only. ErrNotActive without a Second factor or a Passkey (nothing changes).

func (*Module) ResetByStaff

func (m *Module) ResetByStaff(ctx context.Context, userID int64, by string) error

ResetByStaff is Reset from the staff member command: the platform Staff member `by` (shown to customers as "Sphericon staff") is recorded in every Workspace the User holds a Membership in.

func (*Module) StartEnrollment

func (m *Module) StartEnrollment(ctx context.Context, userID int64) (*Enrollment, error)

StartEnrollment creates a pending secret for the User, replacing an earlier pending one. ErrAlreadyActive when a Second factor is active: replacing it takes disabling it first (password and a current code).

func (*Module) Status

func (m *Module) Status(ctx context.Context, userID int64) (Status, error)

Status reads the User's Second factor state.

func (*Module) Verify

func (m *Module) Verify(ctx context.Context, userID int64, binding, code string) (Method, error)

Verify checks a second-step code for the User at the module's clock: a current TOTP code (each time step works once) or an unused Recovery code (spent here, and recorded as `user.recovery_code_use`). It returns which one matched.

binding is the ChallengeBinding the step's challenge was minted under. It is re-checked inside the transaction with the User row locked, so of two second steps racing on one challenge (say one with a TOTP code, one with a Recovery code) the later sees the binding the earlier moved and fails: a challenge starts at most one session. ErrNotActive without a Second factor, ErrBindingMoved when the binding moved, ErrInvalidCode otherwise.

func (*Module) VerifyIn

func (m *Module) VerifyIn(ctx context.Context, tx *ent.Client, pub events.Publisher, u *ent.User, code string) (Method, error)

VerifyIn checks a code for the User u inside the caller's transaction tx (with pub its publisher), for an action that needs the Second factor's proof and its own atomicity: the code is spent (a TOTP step used, a Recovery code consumed) only if the caller's transaction commits. The caller holds u's row locked. ErrNotActive without a Second factor, ErrInvalidCode on a wrong code.

type Passkeys

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

Passkeys registers and checks a User's Passkeys (ADR 0052). The credentials are discoverable and user verification is required; attestation is not requested. The relying party id is the host of the Instance's public URL. A Passkey belongs to the User, so it works on the raw *ent.Client.

func NewPasskeys

func NewPasskeys(client *ent.Client, bus *events.Bus, appURL, secret string, now func() time.Time) (*Passkeys, error)

NewPasskeys builds the module for the Instance at appURL (SPHERICON_APP_URL): its host is the relying party id and its origin the only one a ceremony may come from. An Instance served on an IP address gets a module that is not Available (a relying party id must be a domain), so booting never fails over it. secret is the Instance's JWT secret, from which the ceremony cookie's key is derived. now is the clock the cookie's expiry is checked against (nil means time.Now).

func (*Passkeys) Available

func (p *Passkeys) Available() bool

Available reports whether Passkeys work on this Instance: its public URL has a host name.

func (*Passkeys) BeginCheck

func (p *Passkeys) BeginCheck(ctx context.Context, u *ent.User) (options []byte, ceremony string, err error)

BeginCheck starts a fresh Passkey check of the signed-in User u (ADR 0052): the request options for the browser (JSON), which allow only u's own Passkeys, and the sealed ceremony cookie value, bound to u and their session epoch. It writes nothing. ErrPasskeyUnknown when u holds no Passkey.

func (*Passkeys) BeginLogin

func (p *Passkeys) BeginLogin() (options []byte, ceremony string, err error)

BeginLogin starts a usernameless Passkey login: the request options for the browser (JSON) and the sealed ceremony cookie value. The User is not known yet, so the options allow no credential in particular and the browser offers the Passkeys it holds for this host. It writes nothing: the challenge's single use comes from the row FinishLogin inserts.

func (*Passkeys) BeginRegistration

func (p *Passkeys) BeginRegistration(ctx context.Context, u *ent.User) (options []byte, ceremony string, err error)

BeginRegistration starts adding a Passkey to u: the creation options for the browser (JSON) and the sealed ceremony cookie value. It writes nothing. The options exclude the User's existing Passkeys, so one authenticator is not registered twice.

func (*Passkeys) FindLogin

func (p *Passkeys) FindLogin(ctx context.Context, response []byte) (*ent.User, error)

FindLogin returns the User a login response belongs to, by the credential id it carries. It verifies nothing: the caller consults the User's Login throttle and the verification gate first, then calls FinishLogin. ErrPasskeyUnknown when no credential matches.

func (*Passkeys) FinishCheck

func (p *Passkeys) FinishCheck(ctx context.Context, u *ent.User, ceremony string, response []byte) error

FinishCheck verifies a Passkey check for the signed-in User u, whom FindLogin named from the response: it is FinishLogin for a ceremony BeginCheck sealed, which must be u's and of u's current session epoch. It takes no part in the Login throttle: the caller is already signed in, and a refusal reveals nothing a guess could use. The errors are FinishLogin's.

func (*Passkeys) FinishLogin

func (p *Passkeys) FinishLogin(ctx context.Context, u *ent.User, ceremony string, response []byte) error

FinishLogin verifies the authenticator's response (the browser's AuthenticationResponseJSON) against the ceremony cookie for User u, whom FindLogin named. The response must be signed for the cookie's challenge and this Instance's origin, by a credential u owns, with the user verified. One transaction spends the challenge, advances the signature counter and records the use.

ErrCeremony for an unusable cookie, ErrChallengeSpent when the ceremony already finished, ErrPasskeyRefused for any response that does not verify (including one made without user verification, and one whose signature counter went backwards: a cloned authenticator).

func (*Passkeys) FinishRegistration

func (p *Passkeys) FinishRegistration(ctx context.Context, userID int64, ceremony string, response []byte, name string) (*ent.User, *ent.Passkey, error)

FinishRegistration completes the ceremony begun under cookie for User userID, with the authenticator's response (the browser's RegistrationResponseJSON), and stores the Passkey under name. The caller has proven the User's identity. One transaction spends the challenge, stores the Passkey and bumps the session epoch (ending every session of the User), and returns the saved User for reissuing the acting session.

ErrCeremony for an unusable cookie, ErrChallengeSpent when the ceremony already finished, ErrPasskeyRefused when the response is not acceptable (including an authenticator that did not verify the user).

func (*Passkeys) List

func (p *Passkeys) List(ctx context.Context, userID int64) ([]*ent.Passkey, error)

List returns the User's Passkeys, oldest first.

func (*Passkeys) Remove

func (p *Passkeys) Remove(ctx context.Context, userID, id int64) (*ent.User, *ent.Passkey, error)

Remove deletes the User's Passkey id and bumps the session epoch, ending every session of the User, in one transaction. It returns the saved User, for reissuing the acting session, and the removed Passkey. The caller has proven the User's identity. ErrPasskeyNotFound when the Passkey is not theirs; ErrLastSignIn when it is the last way the User can sign in.

func (*Passkeys) Rename

func (p *Passkeys) Rename(ctx context.Context, userID, id int64, name string) (*ent.Passkey, error)

Rename gives the User's Passkey id a new name; ErrPasskeyNotFound when it is not theirs.

type SendReminder

type SendReminder func(ctx context.Context, m *ent.Membership, deadline time.Time) error

SendReminder delivers the reminder of one Membership whose grace ends at deadline.

type Status

type Status struct {
	Enabled                bool
	Pending                bool
	RecoveryCodesRemaining int
}

Status is what the User may know about their Second factor.

Jump to

Keyboard shortcuts

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