verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
456 lines
16 KiB
Go
456 lines
16 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
// Package authmail implements mailbox verification and password-reset protocols.
|
|
// Applications own HTTP CSRF/origin checks, IP/concurrency limits and templates.
|
|
// A confirmation must be a deliberate POST, never a mail-scanner-triggered GET.
|
|
package authmail
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
stdmail "net/mail"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gamertan.com/web/auth"
|
|
"gamertan.com/web/mail"
|
|
)
|
|
|
|
var (
|
|
ErrInvalid = errors.New("authmail: invalid request")
|
|
ErrUnavailable = errors.New("authmail: link or account unavailable")
|
|
ErrLimited = errors.New("authmail: please wait before requesting another message")
|
|
ErrAddressUnavailable = errors.New("authmail: address cannot be used")
|
|
)
|
|
|
|
type Purpose string
|
|
|
|
const (
|
|
Verify Purpose = "verify"
|
|
Change Purpose = "change"
|
|
Reset Purpose = "reset"
|
|
Lifetime = 15 * time.Minute
|
|
)
|
|
|
|
// Subject is a repository/service boundary, not a public response or log value.
|
|
// PasswordHash is needed for reauthentication and credential-race checks.
|
|
type Subject struct {
|
|
UserID, Email, PasswordHash string
|
|
Revision int64
|
|
Verified bool
|
|
}
|
|
|
|
func (Subject) String() string { return "authmail.Subject{identity:redacted}" }
|
|
func (subject Subject) GoString() string { return subject.String() }
|
|
|
|
type Request struct {
|
|
ID, UserID, Email, NewEmail string
|
|
Purpose Purpose
|
|
Revision int64
|
|
CredentialDigest, SessionDigest, NewDigest, OldDigest [32]byte
|
|
CreatedAt, ExpiresAt time.Time
|
|
}
|
|
|
|
func (Request) String() string { return "authmail.Request{identity:redacted}" }
|
|
func (request Request) GoString() string { return request.String() }
|
|
|
|
type Pending struct {
|
|
Request Request
|
|
Subject Subject
|
|
OldToken bool
|
|
}
|
|
|
|
func (Pending) String() string { return "authmail.Pending{identity:redacted}" }
|
|
func (pending Pending) GoString() string { return pending.String() }
|
|
|
|
// Repository must atomically recheck current account/session/credential identity,
|
|
// throttling, token expiry and replay; mail intent and audit join each mutation.
|
|
// Implementations must never grant authentication or remove MFA credentials.
|
|
type Repository interface {
|
|
OwnSubject(context.Context, [32]byte, time.Time) (Subject, error)
|
|
ResetSubject(context.Context, string, time.Time) (Subject, error)
|
|
Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error
|
|
Pending(context.Context, [32]byte, time.Time) (Pending, error)
|
|
Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error)
|
|
}
|
|
|
|
// Config contains trusted deployment values, never a request Host header. The
|
|
// two route paths must render deliberate confirmation forms. Plain-text copy is
|
|
// centralized here; applications can provide a reviewed Composer to customize it.
|
|
type Config struct {
|
|
Origin, SiteName, ConfirmPath, ResetPath, SecurityPath string
|
|
// TokenInFragment keeps link tokens out of HTTP/proxy request targets. The
|
|
// application must transfer it locally into the deliberate confirmation POST.
|
|
TokenInFragment bool
|
|
Now func() time.Time
|
|
Random io.Reader
|
|
Compose Composer
|
|
}
|
|
|
|
type MessageContent struct{ Subject, Text string }
|
|
|
|
func (MessageContent) String() string { return "authmail.MessageContent{content:redacted}" }
|
|
func (content MessageContent) GoString() string { return content.String() }
|
|
|
|
type MailIntent struct {
|
|
Kind string
|
|
SiteName, ActionURL, SecurityURL string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (MailIntent) String() string { return "authmail.MailIntent{links:redacted}" }
|
|
func (intent MailIntent) GoString() string { return intent.String() }
|
|
|
|
// Composer is trusted application code, not editable template code or arbitrary
|
|
// HTML. It does not choose recipients, sender, identifiers or security state.
|
|
type Composer func(MailIntent) (MessageContent, error)
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
origin *url.URL
|
|
config Config
|
|
}
|
|
|
|
func New(repository Repository, config Config) (*Service, error) {
|
|
if repository == nil || !plainHeader(config.SiteName, 100) {
|
|
return nil, ErrInvalid
|
|
}
|
|
origin, err := url.Parse(config.Origin)
|
|
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") || origin.Opaque != "" {
|
|
return nil, ErrInvalid
|
|
}
|
|
for _, route := range []string{config.ConfirmPath, config.ResetPath, config.SecurityPath} {
|
|
if !strings.HasPrefix(route, "/") || strings.HasPrefix(route, "//") || strings.ContainsAny(route, "?#\\%") || !plainHeader(route, 256) || strings.TrimSuffix(route, "/") != path.Clean(route) {
|
|
return nil, ErrInvalid
|
|
}
|
|
}
|
|
if config.Now == nil {
|
|
config.Now = time.Now
|
|
}
|
|
if config.Random == nil {
|
|
config.Random = rand.Reader
|
|
}
|
|
if config.Compose == nil {
|
|
config.Compose = DefaultComposer
|
|
}
|
|
return &Service{repository: repository, origin: origin, config: config}, nil
|
|
}
|
|
|
|
func plainHeader(value string, limit int) bool {
|
|
if strings.TrimSpace(value) == "" || len(value) > limit {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NormalizeEmail preserves this identity store's case-insensitive mailbox rule.
|
|
// SMTPUTF8, display-name syntax and mailbox-provider alias rewriting are absent.
|
|
func NormalizeEmail(value string) (string, error) {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if !plainHeader(value, 254) {
|
|
return "", ErrInvalid
|
|
}
|
|
for _, r := range value {
|
|
if r > 127 {
|
|
return "", ErrInvalid
|
|
}
|
|
}
|
|
address, err := stdmail.ParseAddress(value)
|
|
if err != nil || address.Name != "" || address.Address != value || !strings.Contains(value, "@") {
|
|
return "", ErrInvalid
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func TokenDigest(token string) ([32]byte, error) {
|
|
raw, err := base64.RawURLEncoding.DecodeString(token)
|
|
if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != token {
|
|
return [32]byte{}, ErrUnavailable
|
|
}
|
|
return sha256.Sum256([]byte("gwf.authmail.v1:" + token)), nil
|
|
}
|
|
|
|
func (service *Service) random(size int) (string, error) {
|
|
raw := make([]byte, size)
|
|
if _, err := io.ReadFull(service.config.Random, raw); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
func (service *Service) link(route, token string) string {
|
|
link := *service.origin
|
|
link.Path = route
|
|
link.RawQuery = ""
|
|
if token != "" {
|
|
if service.config.TokenInFragment {
|
|
link.Fragment = url.Values{"token": {token}}.Encode()
|
|
} else {
|
|
link.RawQuery = url.Values{"token": {token}}.Encode()
|
|
}
|
|
}
|
|
return link.String()
|
|
}
|
|
|
|
func (service *Service) message(kind, recipient, token string, now time.Time) (mail.Message, error) {
|
|
route := service.config.ConfirmPath
|
|
if kind == "reset" {
|
|
route = service.config.ResetPath
|
|
}
|
|
intent := MailIntent{Kind: kind, SiteName: service.config.SiteName, SecurityURL: service.link(service.config.SecurityPath, ""), ExpiresAt: now.Add(Lifetime)}
|
|
if token != "" {
|
|
intent.ActionURL = service.link(route, token)
|
|
}
|
|
content, err := service.config.Compose(intent)
|
|
if err != nil {
|
|
return mail.Message{}, ErrInvalid
|
|
}
|
|
id, err := service.random(18)
|
|
if err != nil {
|
|
return mail.Message{}, err
|
|
}
|
|
message := mail.Message{ID: "mail_" + id, To: recipient, Subject: content.Subject, Text: content.Text, CreatedAt: now}
|
|
if message.Validate() != nil {
|
|
return mail.Message{}, ErrInvalid
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func (service *Service) audit(action, userID string, now time.Time) (auth.AuditEvent, error) {
|
|
id, err := service.random(18)
|
|
if err != nil {
|
|
return auth.AuditEvent{}, err
|
|
}
|
|
actor := userID
|
|
if strings.HasPrefix(action, "reset") {
|
|
actor = ""
|
|
}
|
|
return auth.AuditEvent{ID: "mailaudit_" + id, ActorUserID: actor, Action: "auth.mail." + action, ResourceType: "user", ResourceID: userID, Summary: "Account mail operation", CreatedAt: now}, nil
|
|
}
|
|
|
|
// Status requires a current unrestricted session; it is not an address lookup.
|
|
func (service *Service) Status(ctx context.Context, session [32]byte) (bool, error) {
|
|
subject, err := service.repository.OwnSubject(ctx, session, service.config.Now().UTC())
|
|
return subject.Verified, err
|
|
}
|
|
|
|
func (service *Service) RequestVerification(ctx context.Context, session [32]byte) error {
|
|
now := service.config.Now().UTC().Truncate(time.Second)
|
|
subject, err := service.repository.OwnSubject(ctx, session, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if subject.Verified {
|
|
return nil
|
|
}
|
|
return service.issue(ctx, subject, session, Verify, "", now)
|
|
}
|
|
|
|
// RequestChange requires the current password and confirmations at BOTH old and
|
|
// new mailboxes. It does not change the canonical address immediately. Passkey-
|
|
// only reauthentication would be a separate, operation-bound protocol, not a bool.
|
|
func (service *Service) RequestChange(ctx context.Context, session [32]byte, currentPassword, newEmail string) error {
|
|
if len(currentPassword) > 1024 {
|
|
return ErrInvalid
|
|
}
|
|
now := service.config.Now().UTC().Truncate(time.Second)
|
|
subject, err := service.repository.OwnSubject(ctx, session, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !auth.VerifyPassword(subject.PasswordHash, currentPassword) {
|
|
return auth.ErrInvalidCredentials
|
|
}
|
|
newEmail, err = NormalizeEmail(newEmail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
current, err := NormalizeEmail(subject.Email)
|
|
if err != nil || newEmail == current {
|
|
return ErrInvalid
|
|
}
|
|
return service.issue(ctx, subject, session, Change, newEmail, now)
|
|
}
|
|
|
|
// RequestReset has the same result for unknown, unverified, inactive, malformed
|
|
// and account-rate-limited addresses. Applications must likewise keep responses
|
|
// generic, rate-limit by IP and avoid response timing tied to actual SMTP work.
|
|
func (service *Service) RequestReset(ctx context.Context, email string) error {
|
|
email, err := NormalizeEmail(email)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
now := service.config.Now().UTC().Truncate(time.Second)
|
|
subject, err := service.repository.ResetSubject(ctx, email, now)
|
|
if errors.Is(err, ErrUnavailable) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = service.issue(ctx, subject, [32]byte{}, Reset, "", now)
|
|
if errors.Is(err, ErrLimited) || errors.Is(err, ErrUnavailable) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (service *Service) issue(ctx context.Context, subject Subject, session [32]byte, purpose Purpose, newEmail string, now time.Time) error {
|
|
email, err := NormalizeEmail(subject.Email)
|
|
if err != nil {
|
|
return ErrUnavailable
|
|
}
|
|
id, err := service.random(18)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, err := service.random(32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digest, _ := TokenDigest(token)
|
|
request := Request{ID: "request_" + id, UserID: subject.UserID, Email: email, NewEmail: newEmail, Purpose: purpose, Revision: subject.Revision, CredentialDigest: sha256.Sum256([]byte(subject.PasswordHash)), SessionDigest: session, NewDigest: digest, CreatedAt: now, ExpiresAt: now.Add(Lifetime)}
|
|
kind, target := string(purpose), email
|
|
if purpose == Change {
|
|
kind, target = "change-new", newEmail
|
|
}
|
|
message, err := service.message(kind, target, token, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
messages := []mail.Message{message}
|
|
if purpose == Change {
|
|
oldToken, err := service.random(32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.OldDigest, _ = TokenDigest(oldToken)
|
|
oldMessage, err := service.message("change-old", email, oldToken, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
messages = append(messages, oldMessage)
|
|
}
|
|
audit, err := service.audit(string(purpose)+".request", subject.UserID, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return service.repository.Issue(ctx, request, messages, audit)
|
|
}
|
|
|
|
// Inspect is read-only and intentionally returns no account or address details.
|
|
func (service *Service) Inspect(ctx context.Context, token string) (Purpose, error) {
|
|
digest, err := TokenDigest(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
pending, err := service.repository.Pending(ctx, digest, service.config.Now().UTC())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pending.Request.Purpose, nil
|
|
}
|
|
|
|
// Confirm consumes a verification/change token. false means the other mailbox
|
|
// still needs confirmation. It never issues a login or changes a password.
|
|
func (service *Service) Confirm(ctx context.Context, token string) (bool, error) {
|
|
return service.complete(ctx, token, "", false)
|
|
}
|
|
|
|
// ResetPassword requires a reset token and retains passkeys/recovery codes. The
|
|
// application must send the user through its normal sign-in and MFA afterwards.
|
|
func (service *Service) ResetPassword(ctx context.Context, token, password string) error {
|
|
if err := auth.ValidatePassword(password); err != nil {
|
|
return err
|
|
}
|
|
_, err := service.complete(ctx, token, password, true)
|
|
return err
|
|
}
|
|
|
|
func (service *Service) complete(ctx context.Context, token, password string, reset bool) (bool, error) {
|
|
digest, err := TokenDigest(token)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
now := service.config.Now().UTC().Truncate(time.Second)
|
|
pending, err := service.repository.Pending(ctx, digest, now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if (pending.Request.Purpose == Reset) != reset {
|
|
return false, ErrUnavailable
|
|
}
|
|
var hash string
|
|
var notices []mail.Message
|
|
if reset {
|
|
if auth.VerifyPassword(pending.Subject.PasswordHash, password) {
|
|
return false, auth.ErrPasswordUnchanged
|
|
}
|
|
hash, err = auth.HashPasswordWithRandom(password, service.config.Random)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
notice, err := service.message("password-changed", pending.Request.Email, "", now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
notices = append(notices, notice)
|
|
} else if pending.Request.Purpose == Change {
|
|
for _, recipient := range []string{pending.Request.Email, pending.Request.NewEmail} {
|
|
notice, err := service.message("email-changed", recipient, "", now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
notices = append(notices, notice)
|
|
}
|
|
}
|
|
audit, err := service.audit(string(pending.Request.Purpose)+".confirm", pending.Subject.UserID, now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return service.repository.Complete(ctx, digest, pending.Request.ID, hash, notices, audit)
|
|
}
|
|
|
|
func DefaultComposer(intent MailIntent) (MessageContent, error) {
|
|
var title, text string
|
|
switch intent.Kind {
|
|
case "verify":
|
|
title, text = "Confirm your email", "Confirm that this is the email address you'd like to use for your account."
|
|
case "reset":
|
|
title, text = "Reset your password", "Someone requested a password reset for your account. If that was you, choose a new password using the link below."
|
|
case "change-new":
|
|
title, text = "Confirm your new email", "Confirm this address to continue your account email change. Your current mailbox also needs to approve the change."
|
|
case "change-old":
|
|
title, text = "Approve your email change", "Someone who confirmed your current password requested an account email change. Approve it only if you made this request; the new mailbox must confirm too."
|
|
case "password-changed":
|
|
title, text = "Your password was changed", "Your account password was reset. Existing sessions were signed out. Your passkeys and recovery codes were not removed."
|
|
case "email-changed":
|
|
title, text = "Your account email was changed", "Both mailboxes confirmed your account email change. Existing sessions were signed out. Your purchases and memberships still belong to the same account."
|
|
default:
|
|
return MessageContent{}, ErrInvalid
|
|
}
|
|
if intent.ActionURL != "" {
|
|
text += fmt.Sprintf("\n\n%s\n\nThis link expires at %s. Opening it alone does not change your account; the page asks you to confirm.", intent.ActionURL, intent.ExpiresAt.UTC().Format(time.RFC1123))
|
|
}
|
|
if intent.ActionURL != "" {
|
|
text += "\n\nIf you did not request this, do not confirm it."
|
|
} else {
|
|
text += "\n\nIf this was not you, please contact the site's support team promptly."
|
|
}
|
|
text += "\nVisit your account security page or contact support:\n" + intent.SecurityURL + "\n\n" + intent.SiteName + "\n"
|
|
return MessageContent{Subject: title + " — " + intent.SiteName, Text: text}, nil
|
|
}
|