Documentation
¶
Overview ¶
Package emaillogin implements the two passwordless email flows: a magic link, and a short sign-in code the user types.
They are the same flow with different entropy ¶
Both are request-deliver-redeem, and both prove one thing: that whoever completes them can read mail sent to that address. The difference is the credential. A magic link carries 256 bits and is redeemed by presenting it, so guessing is not a threat. A six-digit code carries about twenty, and is therefore only as safe as the limit on how many times it can be guessed — which is why Config.MaxCodeAttempts exists, why a wrong code is counted even though it did not match, and why requesting a new code destroys the old one rather than adding to a pool of valid answers.
Accounts are created on redemption, never on request ¶
A passwordless sign-in can create an account, and it does so only once the token comes back. Creating one when the link is requested would let anybody fill a user table with addresses they do not control, simply by typing them into a form. Delivering to an inbox and having the token return is what proves control, and until that happens nothing is stored against the address but a pending token.
What it does not do ¶
It issues no authit credential. Like pat, device, oidc and passkey, it answers "who is this" and leaves what to hand back to the host.
Index ¶
- Variables
- type Config
- type NoopSender
- type Result
- type Sender
- type Service
- func (s *Service) RedeemMagicLink(ctx context.Context, rawToken string) (Result, error)
- func (s *Service) RedeemSignInCode(ctx context.Context, email, code string) (Result, error)
- func (s *Service) RequestMagicLink(ctx context.Context, email string) error
- func (s *Service) RequestSignInCode(ctx context.Context, email string) error
- type Stores
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidToken means the magic link or code did not resolve: wrong, // expired, already used, or destroyed by too many failed guesses. // // It is deliberately one error for all of those. Distinguishing // "expired" from "wrong" tells an attacker whether the code they tried // was ever real, and distinguishing "no such token" from "no such // account" tells them which addresses are registered. ErrInvalidToken = errors.New("authit/emaillogin: invalid or expired sign-in credential") // ErrSignUpDisabled means the address matches no account and // Config.DisableSignUp is set. It is returned at redemption, so it // never reveals at request time whether an address is registered. ErrSignUpDisabled = errors.New("authit/emaillogin: sign-up by email is disabled") )
var ErrRateLimited = ratelimit.ErrRateLimited
ErrRateLimited is returned when Config.RateLimiter refuses. It is an alias for ratelimit.ErrRateLimited, not a distinct error, so a caller holding either matches both.
It is a refusal, not a fault: the caller did nothing wrong except arrive too often, and the answer is to wait rather than to change the request. ratelimit.RetryAfter reports how long, when the limiter said.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// LinkTTL is how long a magic link stays valid. Defaults to 15
// minutes. It is short because the link is a bearer credential sitting
// in an inbox, and inboxes are forwarded, synced and breached.
LinkTTL time.Duration
// CodeTTL defaults to 10 minutes, shorter still: the shorter a
// low-entropy credential is live, the less an attacker's guessing
// budget is worth.
CodeTTL time.Duration
// CodeLength defaults to 6 digits. Raising it buys entropy that
// MaxCodeAttempts mostly makes unnecessary, at the cost of a code
// people mistype.
CodeLength int
// MaxCodeAttempts is how many wrong guesses destroy a code. Defaults
// to 5.
//
// This is the control the whole flow rests on. Six digits is a million
// possibilities; five guesses per code makes a hit a one-in-two-hundred-
// thousand event, and requesting a fresh code costs an email and starts
// again from zero. Without it the code is guessable in an afternoon.
MaxCodeAttempts int
// DisableSignUp refuses to create an account for an address that
// matches no existing user. The default is to create one, which is
// safe here because the account is created on redemption -- proof of
// inbox control -- and not on request.
DisableSignUp bool
// RateLimiter throttles issuing either credential, and redeeming a
// code. Nil means ratelimit.Noop. Keys:
//
// email-login:request:<email> a new link or code for an address
// email-login:redeem:<email> a code redemption attempt
//
// Redeeming a magic link is deliberately not metered. There is nothing
// to meter it on: the address is not known until the token resolves,
// so there is no key before the lookup -- and a 256-bit token does not
// need one, where six digits do.
//
// The request key bounds using this flow to flood somebody's inbox.
// The redeem key is a second line behind MaxCodeAttempts, bounding an
// attacker who burns codes and requests fresh ones.
RateLimiter ratelimit.Limiter
// AuditLogger receives request and redemption events.
AuditLogger audit.Logger
}
Config tunes the flows.
type NoopSender ¶
type NoopSender struct{}
NoopSender delivers nothing. It is the default, and is useful for tests and for hosts that deliver out of band.
func (NoopSender) SendMagicLink ¶
func (NoopSender) SendSignInCode ¶
type Result ¶
type Result struct {
// User is the account this sign-in resolved to.
User store.User
// CreatedUser reports whether redeeming created the account, so a host
// can run its own onboarding.
CreatedUser bool
}
Result is a completed passwordless sign-in.
type Sender ¶
type Sender interface {
SendMagicLink(ctx context.Context, email, token string) error
SendSignInCode(ctx context.Context, email, code string) error
}
Sender delivers the credential. It is separate from user.EmailSender so that adding these flows does not change an interface every existing host already implements.
The address is normalised. For SendMagicLink the token is the raw value to embed in a URL; for SendSignInCode it is the digits to show.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service implements the passwordless email flows.
func NewService ¶
NewService constructs a Service. sender may be nil, in which case NoopSender is used.
func (*Service) RedeemMagicLink ¶
RedeemMagicLink consumes a link token and resolves it to a user, creating the account if the address is new and sign-up is allowed.
func (*Service) RedeemSignInCode ¶
RedeemSignInCode consumes a numeric code for an address.
A wrong code is counted against the token even though it did not match, and the token is destroyed once MaxCodeAttempts is reached. That counting is what makes six digits survivable: without it the code is guessable in an afternoon.
func (*Service) RequestMagicLink ¶
RequestMagicLink emails a single-use sign-in link to the address.
It always reports success, whether or not the address is registered. Passwordless sign-in is a form where anybody can type any address, so a response that differed would be a membership oracle for the whole user table — and the fix costs nothing, because the person who owns the inbox finds out either way and nobody else does.