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.
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user