fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)
This commit is contained in:
27 files changed
+525
-83
No files matched your search
+20
-8
@@ -140,7 +140,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
// Email one-time codes go through the [smtp] relay when one is configured; the
|
// Email one-time codes go through the [smtp] relay when one is configured; the
|
||||||
// password is read from the env var password_ref names (default SMTPPasswordEnv,
|
// password is read from the env var password_ref names (default SMTPPasswordEnv,
|
||||||
// injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and
|
// injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and
|
||||||
// deliverOTP logs each code server-side (the pre-SMTP bootstrap posture).
|
// every door that mails a code answers 503 mail_unavailable: a code that is
|
||||||
|
// not mailed is never written anywhere else either.
|
||||||
var mailer api.OTPMailer
|
var mailer api.OTPMailer
|
||||||
if cfg.SMTP.Host != "" {
|
if cfg.SMTP.Host != "" {
|
||||||
passRef := cfg.SMTP.PasswordRef
|
passRef := cfg.SMTP.PasswordRef
|
||||||
@@ -151,15 +152,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
if cfg.SMTP.Username != "" && password == "" {
|
if cfg.SMTP.Username != "" && password == "" {
|
||||||
fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef)
|
fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef)
|
||||||
}
|
}
|
||||||
mailer = &mail.SMTP{
|
mailer = smtpRelay(cfg.SMTP, password)
|
||||||
Host: cfg.SMTP.Host,
|
if !cfg.SMTP.TLSRequired() {
|
||||||
Port: cfg.SMTP.Port,
|
fmt.Fprintf(stderr, "felis api: warning: [smtp] %s may be sent codes without TLS (require_tls off or a relay on this host)\n", cfg.SMTP.Host)
|
||||||
From: cfg.SMTP.From,
|
|
||||||
Username: cfg.SMTP.Username,
|
|
||||||
Password: password,
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(stderr, "felis api: [smtp] not configured — email one-time codes are logged, not mailed")
|
fmt.Fprintln(stderr, "felis api: [smtp] not configured — email sign-in and verification are off (503 mail_unavailable); sign in with a passkey, or run felis setup to add a relay")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build subsystem (spec §16): the weak-SA build Job runs in the configured
|
// Build subsystem (spec §16): the weak-SA build Job runs in the configured
|
||||||
@@ -824,3 +822,17 @@ func internalCallerTokens(getenv func(string) string) (api.CallerTokens, error)
|
|||||||
}
|
}
|
||||||
return api.NewCallerTokens(tokens)
|
return api.NewCallerTokens(tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// smtpRelay is the relay [smtp] names, with the resolved password and the TLS
|
||||||
|
// posture config.SMTPConfig.TLSRequired picks. felis api, the reaper and the
|
||||||
|
// watchdog all send through it, so none can drift to a weaker posture.
|
||||||
|
func smtpRelay(c config.SMTPConfig, password string) *mail.SMTP {
|
||||||
|
return &mail.SMTP{
|
||||||
|
Host: c.Host,
|
||||||
|
Port: c.Port,
|
||||||
|
From: c.From,
|
||||||
|
Username: c.Username,
|
||||||
|
Password: password,
|
||||||
|
RequireTLS: c.TLSRequired(),
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-8
@@ -16,7 +16,6 @@ import (
|
|||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/backup"
|
"felis.lolicon.best/internal/backup"
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
"felis.lolicon.best/internal/mail"
|
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
"felis.lolicon.best/internal/reaper"
|
"felis.lolicon.best/internal/reaper"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
@@ -126,13 +125,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
return email, nil
|
return email, nil
|
||||||
},
|
},
|
||||||
notifier: &mail.SMTP{
|
notifier: smtpRelay(cfg.SMTP, password),
|
||||||
Host: cfg.SMTP.Host,
|
|
||||||
Port: cfg.SMTP.Port,
|
|
||||||
From: cfg.SMTP.From,
|
|
||||||
Username: cfg.SMTP.Username,
|
|
||||||
Password: password,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent")
|
fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent")
|
||||||
|
|||||||
+24
-11
@@ -9,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
"felis.lolicon.best/internal/mail"
|
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
@@ -311,24 +310,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("port %q is not a number", in.port)
|
return fmt.Errorf("port %q is not a number", in.port)
|
||||||
}
|
}
|
||||||
relay := &mail.SMTP{Host: in.host, Port: port, From: in.from, Username: in.username, Password: in.password}
|
var prev config.SMTPConfig
|
||||||
if err := relay.Ping(ctx); err != nil {
|
if cur, err := config.Load(hostSetupConfigPath); err == nil {
|
||||||
|
prev = cur.SMTP
|
||||||
|
}
|
||||||
|
// Ping under the posture felis api will send with, so a relay without
|
||||||
|
// STARTTLS is turned down here rather than at a player's first code.
|
||||||
|
if err := smtpRelay(setupSMTPConfig(in, port, prev), in.password).Ping(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpCfg := config.SMTPConfig{
|
|
||||||
Host: in.host,
|
|
||||||
Port: port,
|
|
||||||
From: in.from,
|
|
||||||
Username: in.username,
|
|
||||||
PasswordRef: platform.SMTPPasswordEnv,
|
|
||||||
}
|
|
||||||
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
||||||
cfg, err := config.Load(path)
|
cfg, err := config.Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg.SMTP = smtpCfg
|
cfg.SMTP = setupSMTPConfig(in, port, cfg.SMTP)
|
||||||
if err := writeConfig(path, cfg); err != nil {
|
if err := writeConfig(path, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,6 +348,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
|
|||||||
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupSMTPConfig is the [smtp] block this screen writes: the relay it just
|
||||||
|
// proved, plus the keys only an operator sets by hand (require_tls,
|
||||||
|
// max_per_hour), carried over from the block it replaces so reconfiguring the
|
||||||
|
// relay does not quietly reset them.
|
||||||
|
func setupSMTPConfig(in smtpInputs, port int, prev config.SMTPConfig) config.SMTPConfig {
|
||||||
|
return config.SMTPConfig{
|
||||||
|
Host: in.host,
|
||||||
|
Port: port,
|
||||||
|
From: in.from,
|
||||||
|
Username: in.username,
|
||||||
|
PasswordRef: platform.SMTPPasswordEnv,
|
||||||
|
MaxPerHour: prev.MaxPerHour,
|
||||||
|
RequireTLS: prev.RequireTLS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// smtpSecretManifest renders the felis-smtp Secret for the given namespace, the
|
// smtpSecretManifest renders the felis-smtp Secret for the given namespace, the
|
||||||
// one the receiving Deployment/CronJob resolves its secretKeyRef against (felis
|
// one the receiving Deployment/CronJob resolves its secretKeyRef against (felis
|
||||||
// for felis-api, the workload namespace for the reaper's mirror). The namespace
|
// for felis-api, the workload namespace for the reaper's mirror). The namespace
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/mail"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,3 +38,39 @@ func TestSMTPSecretManifestCarriesTargetNamespace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSMTPRelayCarriesTLSPosture: the relay felis api, the reaper and the
|
||||||
|
// watchdog send through refuses plaintext for a remote host and allows it for
|
||||||
|
// one on this host, as [smtp] says.
|
||||||
|
func TestSMTPRelayCarriesTLSPosture(t *testing.T) {
|
||||||
|
remote := smtpRelay(config.SMTPConfig{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis"}, "pw")
|
||||||
|
want := &mail.SMTP{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis", Password: "pw", RequireTLS: true}
|
||||||
|
if !reflect.DeepEqual(remote, want) {
|
||||||
|
t.Errorf("remote relay = %+v, want %+v", remote, want)
|
||||||
|
}
|
||||||
|
if local := smtpRelay(config.SMTPConfig{Host: "127.0.0.1", Port: 25, From: "[email protected]"}, ""); local.RequireTLS {
|
||||||
|
t.Error("a relay on this host must not require TLS by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetupSMTPConfigKeepsHandSetKeys: re-running the email screen replaces the
|
||||||
|
// relay but keeps require_tls and max_per_hour, which only an operator sets.
|
||||||
|
func TestSetupSMTPConfigKeepsHandSetKeys(t *testing.T) {
|
||||||
|
off := false
|
||||||
|
prev := config.SMTPConfig{Host: "old.example.net", Port: 25, From: "[email protected]", MaxPerHour: 500, RequireTLS: &off}
|
||||||
|
in := smtpInputs{host: "smtp.example.net", from: "[email protected]", username: "felis"}
|
||||||
|
got := setupSMTPConfig(in, 465, prev)
|
||||||
|
if got.Host != "smtp.example.net" || got.Port != 465 || got.From != "[email protected]" ||
|
||||||
|
got.Username != "felis" || got.PasswordRef != "FELIS_SMTP_PASSWORD" {
|
||||||
|
t.Errorf("relay fields = %+v", got)
|
||||||
|
}
|
||||||
|
if got.MaxPerHour != 500 {
|
||||||
|
t.Errorf("max_per_hour = %d, want 500", got.MaxPerHour)
|
||||||
|
}
|
||||||
|
if got.RequireTLS == nil || *got.RequireTLS {
|
||||||
|
t.Errorf("require_tls = %v, want the operator's false", got.RequireTLS)
|
||||||
|
}
|
||||||
|
if fresh := setupSMTPConfig(in, 587, config.SMTPConfig{}); fresh.RequireTLS != nil || fresh.MaxPerHour != 0 {
|
||||||
|
t.Errorf("first setup = %+v, want require_tls and max_per_hour unset", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
"felis.lolicon.best/internal/mail"
|
|
||||||
"felis.lolicon.best/internal/offsite"
|
"felis.lolicon.best/internal/offsite"
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
"felis.lolicon.best/internal/store"
|
"felis.lolicon.best/internal/store"
|
||||||
@@ -245,7 +244,7 @@ func sendAlert(ctx context.Context, cfg *config.Config, state *watchdog.State, s
|
|||||||
if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" {
|
if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" {
|
||||||
password = os.Getenv(ref)
|
password = os.Getenv(ref)
|
||||||
}
|
}
|
||||||
relay := &mail.SMTP{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From, Username: cfg.SMTP.Username, Password: password}
|
relay := smtpRelay(cfg.SMTP, password)
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, to := range state.Recipients {
|
for _, to := range state.Recipients {
|
||||||
if err := relay.SendNotice(ctx, to, subject, body); err != nil {
|
if err := relay.SendNotice(ctx, to, subject, body); err != nil {
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ listen = "0.0.0.0:8080"
|
|||||||
from = "[email protected]"
|
from = "[email protected]"
|
||||||
username = "relay-user"
|
username = "relay-user"
|
||||||
password_ref = "smtp-password"
|
password_ref = "smtp-password"
|
||||||
|
require_tls = false
|
||||||
|
|
||||||
# Third-party Yggdrasil sources federated by the hasJoined multiplexer. Mojang is
|
# Third-party Yggdrasil sources federated by the hasJoined multiplexer. Mojang is
|
||||||
# always the code-owned identity anchor (premium-first), prepended in Go; sources here
|
# always the code-owned identity anchor (premium-first), prepended in Go; sources here
|
||||||
@@ -367,6 +368,7 @@ out="$(run_smtp "$smtp_dir")"
|
|||||||
expect "a configured [smtp] relay is carried" 'host = "mail.example"' "$out"
|
expect "a configured [smtp] relay is carried" 'host = "mail.example"' "$out"
|
||||||
expect "its port survives the carry" 'port = 587' "$out"
|
expect "its port survives the carry" 'port = 587' "$out"
|
||||||
expect "its credentials reference survives" 'password_ref = "smtp-password"' "$out"
|
expect "its credentials reference survives" 'password_ref = "smtp-password"' "$out"
|
||||||
|
expect "an operator's require_tls survives" 'require_tls = false' "$out"
|
||||||
case "$out" in
|
case "$out" in
|
||||||
*"#"*)
|
*"#"*)
|
||||||
echo "FAIL: the carry hoards comment lines:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
|
echo "FAIL: the carry hoards comment lines:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
|
||||||
|
|||||||
@@ -116,12 +116,11 @@ worth revisiting.
|
|||||||
|
|
||||||
## Wired since the marker was written
|
## Wired since the marker was written
|
||||||
|
|
||||||
- `internal/api/handlers_email_otp.go:54,223,227` and `internal/api/api.go:84` —
|
- `internal/api/handlers_email_otp.go` and `internal/api/api.go` — SMTP shipped on
|
||||||
SMTP shipped on
|
2026-07-20 (`internal/mail`, wired in `cmd/felis/api.go`). An install with no
|
||||||
2026-07-20 (`internal/mail`, wired at `cmd/felis/api.go:264`). The nil-`Mailer`
|
`[smtp]` section leaves the `Mailer` nil, and every door that mails a code answers
|
||||||
branch that logs the code server-side is a runtime fallback for an install with no
|
503 `mail_unavailable`; codes are never logged. The reading "Felis cannot send
|
||||||
`[smtp]` section, not an unbuilt feature. The comments are accurate; the reading
|
mail" is stale.
|
||||||
"Felis cannot send mail" is not.
|
|
||||||
- `internal/config/config.go:117` — was stale. It described the upload transport as a
|
- `internal/config/config.go:117` — was stale. It described the upload transport as a
|
||||||
deferred integration after both backends had shipped (`LocalContextStore`,
|
deferred integration after both backends had shipped (`LocalContextStore`,
|
||||||
`S3ContextStore`, selected in `cmd/felis/api.go` by the shape of the configured
|
`S3ContextStore`, selected in `cmd/felis/api.go` by the shape of the configured
|
||||||
|
|||||||
+24
-2
@@ -192,6 +192,15 @@ components:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
MailUnavailable:
|
||||||
|
description: >
|
||||||
|
This install has no [smtp] relay (code mail_unavailable), so no code was minted
|
||||||
|
or sent. The public doors answer it before resolving the address, so it is the
|
||||||
|
same for every address. Sign in with a passkey, or have the operator configure
|
||||||
|
email with felis setup.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
RateLimited:
|
RateLimited:
|
||||||
description: >
|
description: >
|
||||||
This client address called the public sign-in doors faster than the per-address
|
This client address called the public sign-in doors faster than the per-address
|
||||||
@@ -2162,7 +2171,10 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: >-
|
description: >-
|
||||||
The login methods available for the address, in a deterministic order
|
The login methods available for the address, in a deterministic order
|
||||||
(passkey before email_otp). An empty array means no verified account.
|
(passkey before email_otp). email_otp is offered only when the install has
|
||||||
|
a mail relay, passkey only when a verifier is wired and the account has a
|
||||||
|
credential. An empty array means no verified account, or none of its methods
|
||||||
|
is available on this install.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -2546,6 +2558,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'502':
|
'502':
|
||||||
$ref: '#/components/responses/MailUndeliverable'
|
$ref: '#/components/responses/MailUndeliverable'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/MailUnavailable'
|
||||||
|
|
||||||
/api/v1/auth/email/verify:
|
/api/v1/auth/email/verify:
|
||||||
post:
|
post:
|
||||||
@@ -2673,6 +2687,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'502':
|
'502':
|
||||||
$ref: '#/components/responses/MailUndeliverable'
|
$ref: '#/components/responses/MailUndeliverable'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/MailUnavailable'
|
||||||
|
|
||||||
/api/v1/auth/op-login/status/{id}:
|
/api/v1/auth/op-login/status/{id}:
|
||||||
get:
|
get:
|
||||||
@@ -4113,7 +4129,7 @@ paths:
|
|||||||
email: { type: string, format: email }
|
email: { type: string, format: email }
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Code minted and dispatched (or logged server-side when no mailer is wired).
|
description: Code minted and mailed.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -4142,6 +4158,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'502':
|
'502':
|
||||||
$ref: '#/components/responses/MailUndeliverable'
|
$ref: '#/components/responses/MailUndeliverable'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/MailUnavailable'
|
||||||
|
|
||||||
/api/v1/account/email/verify:
|
/api/v1/account/email/verify:
|
||||||
post:
|
post:
|
||||||
@@ -4540,6 +4558,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'502':
|
'502':
|
||||||
$ref: '#/components/responses/MailUndeliverable'
|
$ref: '#/components/responses/MailUndeliverable'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/MailUnavailable'
|
||||||
|
|
||||||
/api/v1/account/reauth/email/verify:
|
/api/v1/account/reauth/email/verify:
|
||||||
post:
|
post:
|
||||||
@@ -4757,6 +4777,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'502':
|
'502':
|
||||||
$ref: '#/components/responses/MailUndeliverable'
|
$ref: '#/components/responses/MailUndeliverable'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/MailUnavailable'
|
||||||
|
|
||||||
/api/v1/account/migrate/confirm/otp/verify:
|
/api/v1/account/migrate/confirm/otp/verify:
|
||||||
post:
|
post:
|
||||||
|
|||||||
+30
-2
@@ -1947,11 +1947,12 @@ Skips the pre-migration snapshot (`migrate up -no-backup`). The installer warns
|
|||||||
loudly when it is set. Use it only when the snapshot cannot work and you have
|
loudly when it is set. Use it only when the snapshot cannot work and you have
|
||||||
another backup, e.g. an external database newer than the host's `pg_dump`.
|
another backup, e.g. an external database newer than the host's `pg_dump`.
|
||||||
|
|
||||||
## 17. Sign-in refused with 429, mail budget, account code locks, failed sign-ins
|
## 17. Sign-in refused: 429 limits, no mail relay, account code locks, failed sign-ins
|
||||||
|
|
||||||
The public sign-in doors (`/api/v1/auth/*` except logout and the op-login
|
The public sign-in doors (`/api/v1/auth/*` except logout and the op-login
|
||||||
status poll) have three limits of their own. Each answers 429 with a
|
status poll) have three limits of their own. Each answers 429 with a
|
||||||
`Retry-After` header and a distinct error code.
|
`Retry-After` header and a distinct error code. The doors that mail a code
|
||||||
|
also answer 503 `mail_unavailable` on an install with no mail relay.
|
||||||
|
|
||||||
### `rate_limited`: one address called the doors too often
|
### `rate_limited`: one address called the doors too often
|
||||||
|
|
||||||
@@ -1991,6 +1992,33 @@ raise `max_per_hour` to what your relay allows.
|
|||||||
(`felis_mail_total{result="failed"}`, 502 `mail_undeliverable` to the caller).
|
(`felis_mail_total{result="failed"}`, 502 `mail_undeliverable` to the caller).
|
||||||
The relay's reason is in the `felis-api` log.
|
The relay's reason is in the `felis-api` log.
|
||||||
|
|
||||||
|
### `mail_unavailable`: no mail relay
|
||||||
|
|
||||||
|
With no `[smtp]` section every door that mails a code answers 503
|
||||||
|
`mail_unavailable` before minting one: email sign-in, op.console sign-in,
|
||||||
|
email verification, and the email step-up for sensitive changes and
|
||||||
|
migration. The public doors answer before looking up the address, so every
|
||||||
|
address gets the same reply. Sign-in is by passkey only, and a verified email
|
||||||
|
stops counting as a way into the account (it is no longer offered as a
|
||||||
|
re-verification factor). Codes are never logged: `felis api` says at start
|
||||||
|
`[smtp] not configured`. Run `felis setup` and configure email to open the
|
||||||
|
doors.
|
||||||
|
|
||||||
|
### Relay refused for lacking TLS
|
||||||
|
|
||||||
|
A relay on port 465 is spoken to over TLS from the first byte. On any other
|
||||||
|
port Felis upgrades with STARTTLS, and when the relay does not offer it the
|
||||||
|
send fails with `smtp: <host>:<port> does not offer STARTTLS` (502
|
||||||
|
`mail_undeliverable` to the caller, the full text in the `felis-api` log, and
|
||||||
|
the same error on the `felis setup` email screen). Without TLS anyone on the
|
||||||
|
path reads the codes, and anyone who can rewrite the conversation can strip
|
||||||
|
the STARTTLS offer, so this is the default for every relay except one on this
|
||||||
|
host (`localhost`, `127.0.0.0/8`, `::1`). Use port 465 or a relay that offers
|
||||||
|
STARTTLS. For a relay you reach over a link you trust, set
|
||||||
|
`require_tls = false` under `[smtp]` in `/etc/felis/felis.toml` and
|
||||||
|
`/etc/felis/felis.pod.toml`; installer re-runs and the setup email screen keep
|
||||||
|
it. `felis api` warns at start whenever codes may go out without TLS.
|
||||||
|
|
||||||
### `otp_account_locked`: ten wrong codes in 24 hours
|
### `otp_account_locked`: ten wrong codes in 24 hours
|
||||||
|
|
||||||
Ten wrong email codes for one account within 24 hours, counted across every
|
Ten wrong email codes for one account within 24 hours, counted across every
|
||||||
|
|||||||
+4
-4
@@ -101,10 +101,10 @@ type API struct {
|
|||||||
Submissions SubmissionService
|
Submissions SubmissionService
|
||||||
|
|
||||||
// Mailer delivers player email one-time codes (spec §B2 onboarding). It is
|
// Mailer delivers player email one-time codes (spec §B2 onboarding). It is
|
||||||
// optional: when nil the email-OTP start route mints and persists the code but
|
// optional: when nil (no [smtp] relay) every door that mails a code answers 503
|
||||||
// logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no
|
// mail_unavailable before minting one, auth options stops offering email_otp,
|
||||||
// SMTP), so the verify flow is still exercised end-to-end. Production wires a real
|
// and a verified email stops counting as a reauth factor. The code is never
|
||||||
// sender. The code is never returned to the client on either path.
|
// returned to the client or logged.
|
||||||
Mailer OTPMailer
|
Mailer OTPMailer
|
||||||
|
|
||||||
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
||||||
|
|||||||
@@ -815,3 +815,33 @@ func TestDeadAccountsCannotLogInOrKeepSessions(t *testing.T) {
|
|||||||
t.Error("refused redeem consumed the code; re-enabling the account must stay retryable within TTL")
|
t.Error("refused redeem consumed the code; re-enabling the account must stay retryable within TTL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPublicMailDoorsWithoutRelay: with no [smtp] relay both public doors that mail
|
||||||
|
// a code answer 503 mail_unavailable before the address is looked up, so a known
|
||||||
|
// address, a staff address and an unknown one get the same answer, no code or
|
||||||
|
// op-login request is minted, and no cooldown is spent for when a relay is added.
|
||||||
|
func TestPublicMailDoorsWithoutRelay(t *testing.T) {
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Email: "[email protected]", Role: "admin", EmailVerified: true}
|
||||||
|
api.Mailer = nil
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
for _, door := range []string{"/api/v1/auth/email/start", "/api/v1/auth/op-login/start"} {
|
||||||
|
for _, email := range []string{"[email protected]", "[email protected]", "[email protected]"} {
|
||||||
|
w := do(eh, "POST", door, `{"email":"`+email+`"}`, jsonHeader)
|
||||||
|
code, msg := errEnvelope(t, w)
|
||||||
|
if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" ||
|
||||||
|
msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" {
|
||||||
|
t.Errorf("%s %s = %d %s, want 503 mail_unavailable", door, email, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(repo.otps) != 0 || len(repo.opLogins) != 0 {
|
||||||
|
t.Fatalf("refused starts minted %d codes and %d op-login requests", len(repo.otps), len(repo.opLogins))
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Mailer = &captureMailer{}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("start once a relay is wired = %d (%s), want 202", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,9 @@ import (
|
|||||||
// It is the deliberate counter-slice to the anti-enumeration login doors
|
// It is the deliberate counter-slice to the anti-enumeration login doors
|
||||||
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
|
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
|
||||||
// address has an account precisely because THIS endpoint is the one sanctioned place
|
// address has an account precisely because THIS endpoint is the one sanctioned place
|
||||||
// existence is revealed. An empty methods array means "no (verified) account". That
|
// existence is revealed. An empty methods array means "no (verified) account, or
|
||||||
// makes it a mass-enumeration surface by design — an accepted product decision, the
|
// none of its methods is available on this install". That makes it a
|
||||||
|
// mass-enumeration surface by design — an accepted product decision, the
|
||||||
// same one the email door's header records. The handler sends no mail and mutates
|
// same one the email door's header records. The handler sends no mail and mutates
|
||||||
// nothing, so a per-recipient cooldown would merely block a legitimate retry; what
|
// nothing, so a per-recipient cooldown would merely block a legitimate retry; what
|
||||||
// bounds enumeration is the per-client-address token bucket shared by every public
|
// bounds enumeration is the per-client-address token bucket shared by every public
|
||||||
@@ -87,8 +88,12 @@ func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
|
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
|
||||||
// email_verified rows), so it is always on offer.
|
// email_verified rows), so it is on offer whenever a relay can mail the code; with
|
||||||
|
// none the email door answers 503 mail_unavailable, so it is left out like an
|
||||||
|
// unwired passkey verifier.
|
||||||
|
if a.Mailer != nil {
|
||||||
methods = append(methods, "email_otp")
|
methods = append(methods, "email_otp")
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
|
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,8 @@ import (
|
|||||||
// door would immediately 503.
|
// door would immediately 503.
|
||||||
|
|
||||||
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
|
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
|
||||||
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
|
// (u1) and a verified staff account (a1), and a passkey verifier and a mail relay wired
|
||||||
|
// by default.
|
||||||
// Callers seed passkey credentials per-test to set the credential state.
|
// Callers seed passkey credentials per-test to set the credential state.
|
||||||
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
|
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -32,6 +33,7 @@ func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
|
|||||||
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
|
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
api.Passkey = &fakePasskeyVerifier{}
|
api.Passkey = &fakePasskeyVerifier{}
|
||||||
|
api.Mailer = &captureMailer{}
|
||||||
return api, repo
|
return api, repo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +130,22 @@ func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAuthOptionsEmailRequiresMailRelay: with no [smtp] relay the email door answers
|
||||||
|
// 503 mail_unavailable, so options leaves email_otp out; an account with a passkey is
|
||||||
|
// still offered it, and one without is offered nothing.
|
||||||
|
func TestAuthOptionsEmailRequiresMailRelay(t *testing.T) {
|
||||||
|
api, repo := seedAuthOptionsAPI(t)
|
||||||
|
api.Mailer = nil
|
||||||
|
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Body.String() != `{"methods":["passkey"]}`+"\n" {
|
||||||
|
t.Errorf("passkey account body = %q, want only passkey", w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Body.String() != `{"methods":[]}`+"\n" {
|
||||||
|
t.Errorf("email-only account body = %q, want no methods", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
|
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
|
||||||
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
|
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
|
||||||
// that state, so options must NOT advertise passkey — it would be a dead offer.
|
// that state, so options must NOT advertise passkey — it would be a dead offer.
|
||||||
|
|||||||
@@ -68,10 +68,9 @@ const (
|
|||||||
otpLiveLoginCodes = 3
|
otpLiveLoginCodes = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// OTPMailer delivers a one-time code to an email address. It is a seam, not a
|
// OTPMailer delivers a one-time code to an email address. felis api wires the
|
||||||
// dependency: the demo ships without SMTP, so a nil Mailer logs the code
|
// [smtp] relay (internal/mail); with none configured it stays nil and every door
|
||||||
// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to
|
// that mails a code answers 503 mail_unavailable before minting one.
|
||||||
// the client). Production wires a real sender.
|
|
||||||
type OTPMailer interface {
|
type OTPMailer interface {
|
||||||
SendOTP(ctx context.Context, email, code string) error
|
SendOTP(ctx context.Context, email, code string) error
|
||||||
}
|
}
|
||||||
@@ -140,6 +139,12 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// No relay (or a spent budget) is said before asking for a re-verification
|
||||||
|
// the player could not then use.
|
||||||
|
if err := a.checkMailBudget(); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Gate the start: the verify only redeems a code minted here.
|
// Gate the start: the verify only redeems a code minted here.
|
||||||
if !a.requireReauth(w, r, p) {
|
if !a.requireReauth(w, r, p) {
|
||||||
return
|
return
|
||||||
@@ -274,16 +279,17 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
|
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverOTP hands the code to the configured Mailer, or — when none is wired (the
|
// deliverOTP hands the code to the configured Mailer. The code goes nowhere
|
||||||
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the
|
// else: never into a response and never into a log, since anyone who can read
|
||||||
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response.
|
// the API's logs could otherwise sign in as any player. The doors refuse a
|
||||||
|
// relay-less install before minting (checkMailBudget); the nil check here only
|
||||||
|
// keeps a future caller that skips that check from minting a code no one gets.
|
||||||
//
|
//
|
||||||
// Every real send spends one token of the install-wide mail budget (mailGate);
|
// Every real send spends one token of the install-wide mail budget (mailGate);
|
||||||
// a spent budget is a 429 mail_rate_limited and nothing reaches the relay.
|
// a spent budget is a 429 mail_rate_limited and nothing reaches the relay.
|
||||||
func (a *API) deliverOTP(ctx context.Context, email, code string) error {
|
func (a *API) deliverOTP(ctx context.Context, email, code string) error {
|
||||||
if a.Mailer == nil {
|
if a.Mailer == nil {
|
||||||
log.Printf("email-otp: no Mailer configured; code for %s is %s (KNOWN-LIMITATION: demo has no SMTP)", email, code)
|
return errMailUnavailable()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if ok, wait := a.mailGate().take(mailGateKey); !ok {
|
if ok, wait := a.mailGate().take(mailGateKey); !ok {
|
||||||
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
||||||
|
|||||||
@@ -139,15 +139,15 @@ func TestWithRecoverLogsPanicStack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmailOTPStartValidation covers the mint-side input gate and the no-mailer
|
// TestEmailOTPStartValidation covers the mint-side input gate and the no-relay
|
||||||
// fallback (the demo path): a malformed address never mints, and a nil Mailer still
|
// refusal: a malformed address never mints, and with no Mailer the start answers
|
||||||
// persists a code (logged server-side) so the verify flow stays exercisable.
|
// 503 mail_unavailable without minting, so no code exists to leak anywhere.
|
||||||
func TestEmailOTPStartValidation(t *testing.T) {
|
func TestEmailOTPStartValidation(t *testing.T) {
|
||||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
mk := func(repo *fakeRepo) http.Handler {
|
mk := func(repo *fakeRepo) http.Handler {
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = staticExternal{p: user}
|
api.External = staticExternal{p: user}
|
||||||
return api.ExternalHandler() // no Mailer wired → demo fallback
|
return api.ExternalHandler() // no Mailer wired
|
||||||
}
|
}
|
||||||
|
|
||||||
bad := map[string]string{
|
bad := map[string]string{
|
||||||
@@ -174,16 +174,50 @@ func TestEmailOTPStartValidation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("no mailer still persists a code (demo fallback)", func(t *testing.T) {
|
t.Run("no mailer refuses without minting", func(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
|
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
|
||||||
if w.Code != http.StatusAccepted {
|
code, msg := errEnvelope(t, w)
|
||||||
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
|
if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
|
||||||
|
t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if len(repo.otps) != 1 {
|
if msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" {
|
||||||
t.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps))
|
t.Errorf("message = %q", msg)
|
||||||
|
}
|
||||||
|
if len(repo.otps) != 0 {
|
||||||
|
t.Fatalf("a refused start minted %d codes", len(repo.otps))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// No relay is said before a re-verification the player could not use.
|
||||||
|
t.Run("no mailer is said before reauth", func(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-p", CreatedAt: frozenNow}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user", ViaSession: true}}
|
||||||
|
w := do(api.ExternalHandler(), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
|
||||||
|
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
|
||||||
|
t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverOTPWithoutMailer: a caller that reaches deliverOTP with no relay gets
|
||||||
|
// the 503, and the code is written nowhere, the log included.
|
||||||
|
func TestDeliverOTPWithoutMailer(t *testing.T) {
|
||||||
|
var logged strings.Builder
|
||||||
|
old := log.Writer()
|
||||||
|
log.SetOutput(&logged)
|
||||||
|
t.Cleanup(func() { log.SetOutput(old) })
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
err := api.deliverOTP(context.Background(), "[email protected]", "042137")
|
||||||
|
var ae *apiError
|
||||||
|
if !errors.As(err, &ae) || ae.status != http.StatusServiceUnavailable || ae.code != "mail_unavailable" {
|
||||||
|
t.Fatalf("deliverOTP = %v, want 503 mail_unavailable", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(logged.String(), "042137") {
|
||||||
|
t.Errorf("the code reached the log: %s", logged.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmailOTPStartRateLimited closes the email-bomb vector: handleEmailOTPStart is
|
// TestEmailOTPStartRateLimited closes the email-bomb vector: handleEmailOTPStart is
|
||||||
|
|||||||
@@ -212,12 +212,21 @@ func errMailRateLimited(wait time.Duration) *apiError {
|
|||||||
"this server is sending too much mail right now; try again shortly").retryAfter(wait)
|
"this server is sending too much mail right now; try again shortly").retryAfter(wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMailBudget is the public doors' pre-resolution check: it refuses every
|
// errMailUnavailable answers a door that would mail a code on an install with
|
||||||
// address alike while the budget is spent, so the refusal says nothing about
|
// no [smtp] relay. The code is never minted, so it cannot turn up anywhere.
|
||||||
// whether the address has an account.
|
func errMailUnavailable() *apiError {
|
||||||
|
return newError(http.StatusServiceUnavailable, "mail_unavailable",
|
||||||
|
"this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMailBudget runs before a door mints a code: with no relay it refuses
|
||||||
|
// with mail_unavailable, and while the install-wide budget is spent with
|
||||||
|
// mail_rate_limited. The public doors call it before resolving the address, so
|
||||||
|
// either refusal is the same for every address and says nothing about whether
|
||||||
|
// it has an account.
|
||||||
func (a *API) checkMailBudget() error {
|
func (a *API) checkMailBudget() error {
|
||||||
if a.Mailer == nil {
|
if a.Mailer == nil {
|
||||||
return nil
|
return errMailUnavailable()
|
||||||
}
|
}
|
||||||
if ok, wait := a.mailGate().peek(mailGateKey); !ok {
|
if ok, wait := a.mailGate().peek(mailGateKey); !ok {
|
||||||
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
||||||
|
|||||||
+12
-2
@@ -74,12 +74,18 @@ func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) {
|
|||||||
if hasPasskey {
|
if hasPasskey {
|
||||||
st.Factors = append(st.Factors, reauthFactorPasskey)
|
st.Factors = append(st.Factors, reauthFactorPasskey)
|
||||||
}
|
}
|
||||||
|
// A verified email is a way in only while a relay can mail it a code: with
|
||||||
|
// none, the email and op-login doors answer 503 mail_unavailable, so it is
|
||||||
|
// neither a factor to offer nor a door to guard.
|
||||||
|
emailWayIn := p.EmailVerified && a.Mailer != nil
|
||||||
|
if emailWayIn {
|
||||||
if staffRole(p.Role) {
|
if staffRole(p.Role) {
|
||||||
st.Factors = append(st.Factors, reauthFactorSignIn)
|
st.Factors = append(st.Factors, reauthFactorSignIn)
|
||||||
} else if p.EmailVerified {
|
} else {
|
||||||
st.Factors = append(st.Factors, reauthFactorEmail)
|
st.Factors = append(st.Factors, reauthFactorEmail)
|
||||||
}
|
}
|
||||||
if !hasPasskey && !p.EmailVerified {
|
}
|
||||||
|
if !hasPasskey && !emailWayIn {
|
||||||
// Nothing to protect yet: the session is the account's only way in.
|
// Nothing to protect yet: the session is the account's only way in.
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
@@ -342,6 +348,10 @@ func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Pri
|
|||||||
// address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown
|
// address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown
|
||||||
// so the step-up doors never perturb each other's throttle.
|
// so the step-up doors never perturb each other's throttle.
|
||||||
func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) {
|
func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) {
|
||||||
|
if err := a.checkMailBudget(); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil {
|
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB
|
|||||||
|
|
||||||
func TestReauthStatusNamesTheFactors(t *testing.T) {
|
func TestReauthStatusNamesTheFactors(t *testing.T) {
|
||||||
f := reauthFixture(t)
|
f := reauthFixture(t)
|
||||||
|
f.api.Mailer = &captureMailer{}
|
||||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||||
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
||||||
|
|
||||||
@@ -228,6 +229,30 @@ func TestReauthStatusNamesTheFactors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReauthWithoutMailRelay: with no [smtp] relay a verified email is no way in
|
||||||
|
// (the email and op-login doors answer 503), so it is neither offered as a factor
|
||||||
|
// nor guarded; a passkey still is, and the email start door says why it cannot help.
|
||||||
|
func TestReauthWithoutMailRelay(t *testing.T) {
|
||||||
|
f := reauthFixture(t)
|
||||||
|
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
||||||
|
|
||||||
|
steve := getReauthStatus(t, f, laptopTok)
|
||||||
|
if steve.Needed || len(steve.Factors) != 0 {
|
||||||
|
t.Fatalf("email-only player status = %+v, want not needed and no factors", steve)
|
||||||
|
}
|
||||||
|
pam := getReauthStatus(t, f, opTok)
|
||||||
|
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey" {
|
||||||
|
t.Fatalf("operator with a passkey status = %+v, want needed with passkey only", pam)
|
||||||
|
}
|
||||||
|
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok))
|
||||||
|
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
|
||||||
|
t.Fatalf("email start = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(f.repo.otps); n != 0 {
|
||||||
|
t.Errorf("a refused start minted %d codes", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReauthByEmailCode(t *testing.T) {
|
func TestReauthByEmailCode(t *testing.T) {
|
||||||
f := reauthFixture(t)
|
f := reauthFixture(t)
|
||||||
mailer := &captureMailer{}
|
mailer := &captureMailer{}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ type AuthSourceConfig struct {
|
|||||||
|
|
||||||
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
||||||
// email one-time codes through (onboarding, email login, op-login). It is
|
// email one-time codes through (onboarding, email login, op-login). It is
|
||||||
// OPTIONAL — an empty host means "no mailer", and felis-api falls back to
|
// OPTIONAL — an empty host means "no mailer": every door that mails a code
|
||||||
// logging each code server-side (the pre-SMTP bootstrap posture). Only the
|
// answers 503 mail_unavailable and sign-in is by passkey only. Only the
|
||||||
// coordinates live here; the password follows the tree's credential rule
|
// coordinates live here; the password follows the tree's credential rule
|
||||||
// (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment
|
// (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment
|
||||||
// variable felis-api reads it from — the secret itself is never written into
|
// variable felis-api reads it from — the secret itself is never written into
|
||||||
@@ -79,6 +79,27 @@ type SMTPConfig struct {
|
|||||||
// so a flood cannot spend the relay's quota and get the account suspended.
|
// so a flood cannot spend the relay's quota and get the account suspended.
|
||||||
// 0 means DefaultMailPerHour. Size it to the relay's own limit.
|
// 0 means DefaultMailPerHour. Size it to the relay's own limit.
|
||||||
MaxPerHour int `toml:"max_per_hour"`
|
MaxPerHour int `toml:"max_per_hour"`
|
||||||
|
// RequireTLS refuses to send through a relay on a port other than 465 that
|
||||||
|
// does not offer STARTTLS. Unset, it is on for every relay except one on
|
||||||
|
// this host (see TLSRequired). A code sent in the clear can be read by
|
||||||
|
// anyone on the path, and a relay's STARTTLS offer can be stripped by
|
||||||
|
// anyone who can rewrite the conversation.
|
||||||
|
RequireTLS *bool `toml:"require_tls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSRequired reports whether mail may go to this relay only over TLS: the
|
||||||
|
// explicit require_tls when set, otherwise true unless the relay is this
|
||||||
|
// host (localhost or a loopback address), where the path never leaves the
|
||||||
|
// machine.
|
||||||
|
func (c SMTPConfig) TLSRequired() bool {
|
||||||
|
if c.RequireTLS != nil {
|
||||||
|
return *c.RequireTLS
|
||||||
|
}
|
||||||
|
if strings.EqualFold(c.Host, "localhost") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(c.Host)
|
||||||
|
return ip == nil || !ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is
|
// DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is
|
||||||
|
|||||||
@@ -598,6 +598,46 @@ url = "postgres://felis@db/felis"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSMTPRequireTLS pins when mail may go out in the clear: only to a relay
|
||||||
|
// on this host unless require_tls says otherwise, and an explicit value wins
|
||||||
|
// in both directions. The key is read from felis.toml, not only set in code.
|
||||||
|
func TestSMTPRequireTLS(t *testing.T) {
|
||||||
|
load := func(smtp string) config.SMTPConfig {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
[smtp]
|
||||||
|
from = "[email protected]"
|
||||||
|
`+smtp))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
return cfg.SMTP
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
smtp string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{`host = "smtp.example.net"`, true},
|
||||||
|
{`host = "10.0.0.5"`, true},
|
||||||
|
{`host = "localhost"`, false},
|
||||||
|
{`host = "LocalHost"`, false},
|
||||||
|
{`host = "127.0.0.1"`, false},
|
||||||
|
{`host = "127.0.0.53"`, false},
|
||||||
|
{`host = "::1"`, false},
|
||||||
|
{"host = \"smtp.example.net\"\nrequire_tls = false", false},
|
||||||
|
{"host = \"127.0.0.1\"\nrequire_tls = true", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := load(c.smtp).TLSRequired(); got != c.want {
|
||||||
|
t.Errorf("%q: TLSRequired = %v, want %v", c.smtp, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay
|
// TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay
|
||||||
// host commits the block to being sendable, so a missing/invalid From fails at
|
// host commits the block to being sendable, so a missing/invalid From fails at
|
||||||
// load rather than at the first OTP a player is waiting on.
|
// load rather than at the first OTP a player is waiting on.
|
||||||
|
|||||||
+13
-4
@@ -6,10 +6,12 @@
|
|||||||
// notice (SendNotice).
|
// notice (SendNotice).
|
||||||
//
|
//
|
||||||
// TLS posture: port 465 dials implicit TLS; any other port dials plaintext and
|
// TLS posture: port 465 dials implicit TLS; any other port dials plaintext and
|
||||||
// upgrades via STARTTLS when the relay advertises it. AUTH is attempted only
|
// upgrades via STARTTLS. With RequireTLS set (the default for any relay not on
|
||||||
// when a username is configured, and net/smtp's PlainAuth itself refuses to
|
// this host, config.SMTPConfig.TLSRequired) a relay that does not offer
|
||||||
// send credentials over an unencrypted connection — a relay that offers no
|
// STARTTLS is refused before a single address or code is sent, so a relay
|
||||||
// TLS can carry unauthenticated mail but can never be handed the password.
|
// without TLS, or a path that strips the offer, fails loudly. AUTH is
|
||||||
|
// attempted only when a username is configured, and net/smtp's PlainAuth
|
||||||
|
// itself refuses to send credentials over an unencrypted connection.
|
||||||
package mail
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,6 +40,9 @@ type SMTP struct {
|
|||||||
From string
|
From string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
// RequireTLS refuses a relay on a port other than 465 that does not offer
|
||||||
|
// STARTTLS. Callers set it from config.SMTPConfig.TLSRequired.
|
||||||
|
RequireTLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendOTP mails code to email as a small bilingual plain-text message. It is
|
// SendOTP mails code to email as a small bilingual plain-text message. It is
|
||||||
@@ -172,6 +177,10 @@ func (s *SMTP) connect(ctx context.Context) (*smtp.Client, error) {
|
|||||||
c.Close()
|
c.Close()
|
||||||
return nil, fmt.Errorf("smtp: starttls: %w", err)
|
return nil, fmt.Errorf("smtp: starttls: %w", err)
|
||||||
}
|
}
|
||||||
|
} else if s.RequireTLS {
|
||||||
|
c.Close()
|
||||||
|
return nil, fmt.Errorf("smtp: %s does not offer STARTTLS, so mail to it would cross the network unencrypted; "+
|
||||||
|
"use port 465 or a relay with STARTTLS, or set [smtp] require_tls = false for a relay you reach over a trusted link", addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.Username != "" {
|
if s.Username != "" {
|
||||||
|
|||||||
+106
-4
@@ -7,6 +7,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -91,6 +92,34 @@ func TestNoticeShape(t *testing.T) {
|
|||||||
// unconditionally and only render their verdict after the message body, so a
|
// unconditionally and only render their verdict after the message body, so a
|
||||||
// probe stopping short of end-of-DATA reports a green relay that cannot send.
|
// probe stopping short of end-of-DATA reports a green relay that cannot send.
|
||||||
func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
|
func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
|
||||||
|
t.Helper()
|
||||||
|
return startRelay(t, &relayOpts{dataVerdict: dataVerdict})
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayOpts shapes a fake relay: its end-of-DATA verdict, whether its EHLO
|
||||||
|
// offers STARTTLS (it cannot complete one: it answers 220 and hangs up), and
|
||||||
|
// the commands it received, in order.
|
||||||
|
type relayOpts struct {
|
||||||
|
dataVerdict string
|
||||||
|
starttls bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *relayOpts) record(cmd string) {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
o.seen = append(o.seen, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *relayOpts) commands() []string {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return append([]string(nil), o.seen...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRelay(t *testing.T, opts *relayOpts) (string, int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +132,7 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go serveFakeRelay(conn, dataVerdict)
|
go serveFakeRelay(conn, opts)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
host, portStr, err := net.SplitHostPort(ln.Addr().String())
|
host, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||||
@@ -117,18 +146,28 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
|
|||||||
return host, port
|
return host, port
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveFakeRelay(conn net.Conn, dataVerdict string) {
|
func serveFakeRelay(conn net.Conn, opts *relayOpts) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
br := bufio.NewReader(conn)
|
br := bufio.NewReader(conn)
|
||||||
io.WriteString(conn, "220 fake ESMTP\r\n")
|
io.WriteString(conn, "220 fake ESMTP\r\n")
|
||||||
for {
|
for {
|
||||||
line, err := br.ReadString('\n')
|
line, err := br.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
opts.record("<closed>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
|
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||||
|
if f := strings.Fields(cmd); len(f) > 0 {
|
||||||
|
opts.record(f[0])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(cmd, "EHLO") && opts.starttls:
|
||||||
|
io.WriteString(conn, "250-fake\r\n250 STARTTLS\r\n")
|
||||||
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
||||||
io.WriteString(conn, "250 fake\r\n")
|
io.WriteString(conn, "250 fake\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "STARTTLS"):
|
||||||
|
io.WriteString(conn, "220 ready\r\n")
|
||||||
|
return
|
||||||
case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"):
|
case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"):
|
||||||
io.WriteString(conn, "250 2.1.0 Ok\r\n")
|
io.WriteString(conn, "250 2.1.0 Ok\r\n")
|
||||||
case strings.HasPrefix(cmd, "DATA"):
|
case strings.HasPrefix(cmd, "DATA"):
|
||||||
@@ -142,7 +181,7 @@ func serveFakeRelay(conn net.Conn, dataVerdict string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
io.WriteString(conn, dataVerdict+"\r\n")
|
io.WriteString(conn, opts.dataVerdict+"\r\n")
|
||||||
case strings.HasPrefix(cmd, "QUIT"):
|
case strings.HasPrefix(cmd, "QUIT"):
|
||||||
io.WriteString(conn, "221 bye\r\n")
|
io.WriteString(conn, "221 bye\r\n")
|
||||||
return
|
return
|
||||||
@@ -199,3 +238,66 @@ func TestSendOTPSurfacesEndOfDataRefusal(t *testing.T) {
|
|||||||
t.Errorf("want the relay's refusal surfaced, got: %v", err)
|
t.Errorf("want the relay's refusal surfaced, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRequireTLSRefusesPlaintextRelay: with RequireTLS a relay that offers no
|
||||||
|
// STARTTLS gets nothing past EHLO — no sender, no recipient, no code — and the
|
||||||
|
// operator is told which relay and which knob.
|
||||||
|
func TestRequireTLSRefusesPlaintextRelay(t *testing.T) {
|
||||||
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
|
||||||
|
host, port := startRelay(t, relay)
|
||||||
|
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
|
||||||
|
|
||||||
|
err := s.SendOTP(context.Background(), "[email protected]", "042137")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("SendOTP sent a code through a relay without STARTTLS")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"does not offer STARTTLS", host + ":" + strconv.Itoa(port), "require_tls = false"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The client hangs up instead of leaving the connection to the garbage
|
||||||
|
// collector; the relay sees that shortly after SendOTP returns.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for len(relay.commands()) < 2 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if got := strings.Join(relay.commands(), " "); got != "EHLO <closed>" {
|
||||||
|
t.Errorf("relay received %q, want EHLO and then the connection closed", got)
|
||||||
|
}
|
||||||
|
if err := s.Ping(context.Background()); err == nil || !strings.Contains(err.Error(), "does not offer STARTTLS") {
|
||||||
|
t.Errorf("Ping = %v, want the STARTTLS refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaintextRelayAllowedWithoutRequireTLS: require_tls = false (or a relay
|
||||||
|
// on this host) keeps the old opportunistic posture and the code is delivered.
|
||||||
|
func TestPlaintextRelayAllowedWithoutRequireTLS(t *testing.T) {
|
||||||
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
|
||||||
|
host, port := startRelay(t, relay)
|
||||||
|
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
|
||||||
|
|
||||||
|
if err := s.SendOTP(context.Background(), "[email protected]", "042137"); err != nil {
|
||||||
|
t.Fatalf("SendOTP: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(relay.commands(), " "); got != "EHLO MAIL RCPT DATA QUIT" {
|
||||||
|
t.Errorf("relay received %q, want EHLO MAIL RCPT DATA QUIT", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireTLSTakesAnOfferedStartTLS: when the relay does offer STARTTLS the
|
||||||
|
// client goes for it rather than refusing; this fake then hangs up mid-upgrade,
|
||||||
|
// so the failure is the upgrade's own.
|
||||||
|
func TestRequireTLSTakesAnOfferedStartTLS(t *testing.T) {
|
||||||
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok", starttls: true}
|
||||||
|
host, port := startRelay(t, relay)
|
||||||
|
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
|
||||||
|
|
||||||
|
err := s.SendOTP(context.Background(), "[email protected]", "042137")
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), "smtp: starttls:") {
|
||||||
|
t.Fatalf("SendOTP = %v, want the STARTTLS upgrade failure", err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(relay.commands(), " "); got != "EHLO STARTTLS" {
|
||||||
|
t.Errorf("relay received %q, want EHLO STARTTLS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.",
|
"bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.",
|
||||||
"email_taken": "That email is already verified on another account — sign in with it or use another address.",
|
"email_taken": "That email is already verified on another account — sign in with it or use another address.",
|
||||||
"mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.",
|
"mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.",
|
||||||
|
"mail_unavailable": "This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.",
|
||||||
"bad_path": "That path is invalid — use a relative path inside the world directory.",
|
"bad_path": "That path is invalid — use a relative path inside the world directory.",
|
||||||
"too_large": "That is larger than the size limit.",
|
"too_large": "That is larger than the size limit.",
|
||||||
"files_timeout": "The file operation timed out — try again shortly.",
|
"files_timeout": "The file operation timed out — try again shortly.",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。",
|
"bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。",
|
||||||
"email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。",
|
"email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。",
|
||||||
"mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。",
|
"mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。",
|
||||||
|
"mail_unavailable": "服务器没有配置邮件服务,发不出验证码。请改用 Passkey 登录,或请管理员配置邮件。",
|
||||||
"bad_path": "路径不合法——请使用世界目录内的相对路径。",
|
"bad_path": "路径不合法——请使用世界目录内的相对路径。",
|
||||||
"too_large": "内容超出大小限制,无法处理。",
|
"too_large": "内容超出大小限制,无法处理。",
|
||||||
"files_timeout": "文件操作超时——请稍后重试。",
|
"files_timeout": "文件操作超时——请稍后重试。",
|
||||||
|
|||||||
@@ -402,6 +402,13 @@ describe("api access-control wire shapes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says email codes are off when the install has no mail relay", async () => {
|
||||||
|
const { humanizeError } = await import("./api");
|
||||||
|
expect(humanizeError({ status: 503, code: "mail_unavailable" })).toBe(
|
||||||
|
"This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps the backup rationing codes to their own copy", async () => {
|
it("maps the backup rationing codes to their own copy", async () => {
|
||||||
const { humanizeError } = await import("./api");
|
const { humanizeError } = await import("./api");
|
||||||
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
|
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
|
||||||
|
|||||||
@@ -941,6 +941,9 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("email_taken");
|
return t("email_taken");
|
||||||
case "mail_undeliverable":
|
case "mail_undeliverable":
|
||||||
return t("mail_undeliverable");
|
return t("mail_undeliverable");
|
||||||
|
// No [smtp] relay at all: every door that mails a code refuses before minting.
|
||||||
|
case "mail_unavailable":
|
||||||
|
return t("mail_unavailable");
|
||||||
// File editor (spec §7): path/size refusals from the sandboxed job, plus the
|
// File editor (spec §7): path/size refusals from the sandboxed job, plus the
|
||||||
// subsystem being unwired.
|
// subsystem being unwired.
|
||||||
case "bad_path":
|
case "bad_path":
|
||||||
|
|||||||
@@ -2488,6 +2488,15 @@ export interface components {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"application/json": components["schemas"]["Error"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description This install has no [smtp] relay (code mail_unavailable), so no code was minted or sent. The public doors answer it before resolving the address, so it is the same for every address. Sign in with a passkey, or have the operator configure email with felis setup. */
|
||||||
|
MailUnavailable: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */
|
/** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */
|
||||||
RateLimited: {
|
RateLimited: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -3963,7 +3972,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description The login methods available for the address, in a deterministic order (passkey before email_otp). An empty array means no verified account. */
|
/** @description The login methods available for the address, in a deterministic order (passkey before email_otp). email_otp is offered only when the install has a mail relay, passkey only when a verifier is wired and the account has a credential. An empty array means no verified account, or none of its methods is available on this install. */
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@@ -4364,6 +4373,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
502: components["responses"]["MailUndeliverable"];
|
502: components["responses"]["MailUndeliverable"];
|
||||||
|
503: components["responses"]["MailUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
loginEmailVerify: {
|
loginEmailVerify: {
|
||||||
@@ -4492,6 +4502,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
502: components["responses"]["MailUndeliverable"];
|
502: components["responses"]["MailUndeliverable"];
|
||||||
|
503: components["responses"]["MailUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
opLoginStatus: {
|
opLoginStatus: {
|
||||||
@@ -5960,7 +5971,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Code minted and dispatched (or logged server-side when no mailer is wired). */
|
/** @description Code minted and mailed. */
|
||||||
202: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@@ -5995,6 +6006,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
502: components["responses"]["MailUndeliverable"];
|
502: components["responses"]["MailUndeliverable"];
|
||||||
|
503: components["responses"]["MailUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
emailOtpVerify: {
|
emailOtpVerify: {
|
||||||
@@ -6388,6 +6400,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
502: components["responses"]["MailUndeliverable"];
|
502: components["responses"]["MailUndeliverable"];
|
||||||
|
503: components["responses"]["MailUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
reauthEmailVerify: {
|
reauthEmailVerify: {
|
||||||
@@ -6614,6 +6627,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
502: components["responses"]["MailUndeliverable"];
|
502: components["responses"]["MailUndeliverable"];
|
||||||
|
503: components["responses"]["MailUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
migrateConfirmOtpVerify: {
|
migrateConfirmOtpVerify: {
|
||||||
|
|||||||
Reference in new issue
Block a user