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,83 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Account mail protocols
|
||||
|
||||
`authmail` provides optional verification, confirmed address changes and password
|
||||
reset. It does not deliver mail directly or own the application router. Use
|
||||
`authsqlite.Store.AccountMail` for an adapter sharing the identity database and
|
||||
encrypted transactional outbox. Call `MigrateMail` explicitly and require its
|
||||
independent schema version before enabling these routes. Base identity schema 11
|
||||
and historical commerce records are unchanged; existing mailboxes start unverified.
|
||||
|
||||
## Operations
|
||||
|
||||
- Verification requires a current unrestricted account session and confirmation
|
||||
at its existing canonical mailbox. Reading/inspecting a link does not verify it.
|
||||
- Address change requires the current password and that session, then separate
|
||||
confirmation at both current and proposed mailboxes. The current address stays
|
||||
authoritative until both confirm. No passkey-approval boolean bypass exists.
|
||||
A future passkey-only path needs a separately bound fresh-approval protocol.
|
||||
- Password reset is available only through the current verified mailbox of an
|
||||
active, fully registered account. Request results are generic for unknown,
|
||||
unverified, inactive, malformed and account-throttled addresses. A reset creates
|
||||
no login, removes no passkey/recovery code and bypasses no existing MFA policy.
|
||||
|
||||
Tokens use 32 random bytes and purpose-bound SHA-256 digests, expire in 15 minutes,
|
||||
and work once. Requests bind the user ID, canonical address, profile revision and
|
||||
current password digest; address/verification requests also bind the real acting
|
||||
session. A replacement request invalidates the previous link for that purpose.
|
||||
Already-in-flight older mail may still arrive; an invalidated token cannot act.
|
||||
|
||||
The SQLite adapter rechecks authority and identity under its writer lock, including
|
||||
fresh time after waits/password hashing. A changed password, profile, address,
|
||||
status or acting session rejects stale requests. Address uniqueness is checked
|
||||
again at final confirmation. Old-address invitations are revoked, not moved;
|
||||
new-address invitations still need normal token/authority checks to be accepted.
|
||||
Existing memberships, ownership and purchase snapshots retain the immutable user.
|
||||
|
||||
Successful reset/address changes revoke sessions, pending ceremonies, enrollment
|
||||
and recovery grants, and outstanding account-mail requests. Mailbox changes notify
|
||||
both addresses; resets notify the current mailbox. Account changes, notifications
|
||||
and secret-free audits commit atomically. Delivery capacity/audit failures roll
|
||||
everything back, leaving valid tokens retryable until their original expiry.
|
||||
|
||||
Per-account requests are limited to one per minute and five per hour across these
|
||||
purposes. At most one pending request per account/purpose remains. Applications
|
||||
must also impose IP and password-hashing concurrency limits and handle anonymous
|
||||
request responses without disclosing per-account operational failures.
|
||||
|
||||
## Required application boundaries
|
||||
|
||||
- Use a configured HTTPS origin and fixed route paths. Never build links from a
|
||||
request Host header. `Composer` customizes reviewed plain-text copy, not security
|
||||
state, sender, recipient, headers or editable executable templates.
|
||||
- Render a deliberate confirmation/reset form; only POST consumes a token. Keep
|
||||
strict same-origin/CSRF protection and private/no-store responses. Prefer
|
||||
`TokenInFragment` so the browser transfers the code into the deliberate POST
|
||||
without placing it in HTTP/proxy request targets. Native forms can require a
|
||||
same-origin referrer policy to retain a usable Origin header; fragments are
|
||||
never included in referrers. Script-only flows can use no-referrer. Do not
|
||||
weaken origin validation to accept opaque/null origins, and never allow link
|
||||
scanners or GET requests to change account state.
|
||||
- Exclude tokens, query strings, message bodies, addresses, passwords and SMTP
|
||||
credentials from logs/telemetry. Restrict token-bearing pages and avoid external
|
||||
analytics/resources. Debug redaction does not make structured serialization safe.
|
||||
- Return anonymous request responses consistently, independent of eligibility or
|
||||
SMTP acceptance. Run SMTP through the bounded outbox worker, not in the request.
|
||||
- After reset/change, clear the browser's old session and return to normal login.
|
||||
Preserve the application's passkey/MFA checks; mailbox control is not an owner
|
||||
recovery grant. Keep printed/owner-assisted recovery separate.
|
||||
- Existing Stripe receipts/billing emails are financial snapshots, not canonical
|
||||
login identifiers. Do not rewrite them as part of an account email change.
|
||||
|
||||
The design follows the applicable OWASP guidance on
|
||||
[password reset](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html)
|
||||
and [registered-email changes](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address).
|
||||
This is not a claim of a completed application security audit.
|
||||
|
||||
Local tests cover both confirmation orders, replay/expiry, generic reset requests,
|
||||
credential/session races, competing resets, address conflicts, stable ownership,
|
||||
retained factors, invitation handling, rate limits, restart and transactional
|
||||
migration/audit/outbox rollback. The account packages and mail/outbox suites pass;
|
||||
focused races and vet pass. Consumer HTTP/UI integration, release/publication and
|
||||
controlled real-mail proof remain pending in the repository queue.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/mail"
|
||||
)
|
||||
|
||||
type unavailableRepository struct{}
|
||||
|
||||
func (unavailableRepository) OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) {
|
||||
return Subject{}, ErrUnavailable
|
||||
}
|
||||
func (unavailableRepository) ResetSubject(context.Context, string, time.Time) (Subject, error) {
|
||||
return Subject{}, ErrUnavailable
|
||||
}
|
||||
func (unavailableRepository) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error {
|
||||
return ErrUnavailable
|
||||
}
|
||||
func (unavailableRepository) Pending(context.Context, [32]byte, time.Time) (Pending, error) {
|
||||
return Pending{}, ErrUnavailable
|
||||
}
|
||||
func (unavailableRepository) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) {
|
||||
return false, ErrUnavailable
|
||||
}
|
||||
|
||||
func testConfig() Config {
|
||||
return Config{Origin: "https://example.test", SiteName: "Example", ConfirmPath: "/account/email/confirm/", ResetPath: "/password/reset/", SecurityPath: "/account/security/"}
|
||||
}
|
||||
|
||||
func TestTrustedOriginAndRouteValidation(t *testing.T) {
|
||||
for _, origin := range []string{"http://example.test", "https://user:password@example.test", "https://example.test/path", "https://example.test/?query=1", "https://example.test/#fragment", "javascript:alert(1)", "//example.test", "https://"} {
|
||||
config := testConfig()
|
||||
config.Origin = origin
|
||||
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid origin accepted: %q", origin)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{"//evil.test/path", "/path?token=1", "/path#fragment", "/a/../b", "relative/path", "/bad\\path", "/bad\npath", "/%2f%2fevil.test"} {
|
||||
config := testConfig()
|
||||
config.ConfirmPath = route
|
||||
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid route accepted: %q", route)
|
||||
}
|
||||
}
|
||||
if _, err := New(nil, testConfig()); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("nil repository")
|
||||
}
|
||||
service, err := New(unavailableRepository{}, testConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := service.link("/password/reset/", "a+/&b"); got != "https://example.test/password/reset/?token=a%2B%2F%26b" {
|
||||
t.Fatalf("link encoding: %s", got)
|
||||
}
|
||||
fragmentConfig := testConfig()
|
||||
fragmentConfig.TokenInFragment = true
|
||||
fragmentService, err := New(unavailableRepository{}, fragmentConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fragmentService.link("/password/reset/", "fixture-token"); got != "https://example.test/password/reset/#token=fixture-token" {
|
||||
t.Fatal("fragment mode exposed token in request target")
|
||||
}
|
||||
config := testConfig()
|
||||
config.SiteName = "Name\nInjected: header"
|
||||
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("site name injection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxAndTokenValidation(t *testing.T) {
|
||||
if got, err := NormalizeEmail(" READER@Example.Test "); err != nil || got != "reader@example.test" {
|
||||
t.Fatalf("normalization %q %v", got, err)
|
||||
}
|
||||
for _, email := range []string{"Name <reader@example.test>", "reader@example.test\nBcc: other@example.test", "ü@example.test", "a,b@example.test", "missing-at", strings.Repeat("x", 255) + "@example.test"} {
|
||||
if _, err := NormalizeEmail(email); err == nil {
|
||||
t.Fatalf("invalid address accepted: %q", email)
|
||||
}
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
first, err := TokenDigest(token)
|
||||
if err != nil || first == ([32]byte{}) {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
for _, value := range []string{"", token + "=", token[:42], strings.Repeat("a", 200), " " + token} {
|
||||
if _, err := TokenDigest(value); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatal("malformed token accepted")
|
||||
}
|
||||
}
|
||||
service, err := New(unavailableRepository{}, testConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"not-an-email", "unknown@example.test"} {
|
||||
if err := service.RequestReset(t.Context(), value); err != nil {
|
||||
t.Fatal("reset enumeration", err)
|
||||
}
|
||||
}
|
||||
if _, err = service.Inspect(t.Context(), token); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Confirm(t.Context(), token); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAndCustomCopyDoesNotControlEnvelope(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.Compose = func(intent MailIntent) (MessageContent, error) {
|
||||
return MessageContent{Subject: "Custom subject", Text: "Custom body: " + intent.ActionURL}, nil
|
||||
}
|
||||
service, err := New(unavailableRepository{}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
message, err := service.message("verify", "recipient@example.test", "token", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.To != "recipient@example.test" || message.Subject != "Custom subject" || !strings.Contains(message.Text, "https://example.test/account/email/confirm/?token=token") {
|
||||
t.Fatal("custom copy bypassed fixed envelope/origin")
|
||||
}
|
||||
config.Compose = func(MailIntent) (MessageContent, error) {
|
||||
return MessageContent{Subject: "bad\nBcc: another@example.test", Text: "body"}, nil
|
||||
}
|
||||
service, err = New(unavailableRepository{}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.message("verify", "recipient@example.test", "token", time.Now().UTC()); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("custom header injection")
|
||||
}
|
||||
for _, kind := range []string{"verify", "reset", "change-new", "change-old", "password-changed", "email-changed"} {
|
||||
content, err := DefaultComposer(MailIntent{Kind: kind, SiteName: "Example", SecurityURL: "https://example.test/security/", ActionURL: "https://example.test/confirm/", ExpiresAt: time.Now().UTC()})
|
||||
if err != nil || content.Subject == "" || content.Text == "" {
|
||||
t.Fatal("missing copy", kind)
|
||||
}
|
||||
}
|
||||
if _, err = DefaultComposer(MailIntent{Kind: "unknown"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("unknown intent")
|
||||
}
|
||||
for _, value := range []any{Subject{Email: "private@example.test", PasswordHash: "secret-hash"}, Request{Email: "private@example.test"}, Pending{Subject: Subject{Email: "private@example.test"}}} {
|
||||
if strings.Contains(fmt.Sprintf("%v %#v", value, value), "private@example.test") {
|
||||
t.Fatal("sensitive debug output")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user