fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)
This commit is contained in:
27 files changed
+529
-87
No files matched your search
+4
-4
@@ -101,10 +101,10 @@ type API struct {
|
||||
Submissions SubmissionService
|
||||
|
||||
// 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
|
||||
// logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no
|
||||
// SMTP), so the verify flow is still exercised end-to-end. Production wires a real
|
||||
// sender. The code is never returned to the client on either path.
|
||||
// optional: when nil (no [smtp] relay) every door that mails a code answers 503
|
||||
// mail_unavailable before minting one, auth options stops offering email_otp,
|
||||
// and a verified email stops counting as a reauth factor. The code is never
|
||||
// returned to the client or logged.
|
||||
Mailer OTPMailer
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// (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
|
||||
// existence is revealed. An empty methods array means "no (verified) account". That
|
||||
// makes it a mass-enumeration surface by design — an accepted product decision, the
|
||||
// existence is revealed. An empty methods array means "no (verified) account, or
|
||||
// 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
|
||||
// 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
|
||||
@@ -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_verified rows), so it is always on offer.
|
||||
methods = append(methods, "email_otp")
|
||||
// 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")
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
|
||||
}
|
||||
@@ -22,7 +22,8 @@ import (
|
||||
// door would immediately 503.
|
||||
|
||||
// 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.
|
||||
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
|
||||
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}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.Passkey = &fakePasskeyVerifier{}
|
||||
api.Mailer = &captureMailer{}
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -68,10 +68,9 @@ const (
|
||||
otpLiveLoginCodes = 3
|
||||
)
|
||||
|
||||
// OTPMailer delivers a one-time code to an email address. It is a seam, not a
|
||||
// dependency: the demo ships without SMTP, so a nil Mailer logs the code
|
||||
// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to
|
||||
// the client). Production wires a real sender.
|
||||
// OTPMailer delivers a one-time code to an email address. felis api wires the
|
||||
// [smtp] relay (internal/mail); with none configured it stays nil and every door
|
||||
// that mails a code answers 503 mail_unavailable before minting one.
|
||||
type OTPMailer interface {
|
||||
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"))
|
||||
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.
|
||||
if !a.requireReauth(w, r, p) {
|
||||
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})
|
||||
}
|
||||
|
||||
// deliverOTP hands the code to the configured Mailer, or — when none is wired (the
|
||||
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the
|
||||
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response.
|
||||
// deliverOTP hands the code to the configured Mailer. The code goes nowhere
|
||||
// else: never into a response and never into a log, since anyone who can read
|
||||
// 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);
|
||||
// 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 {
|
||||
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 nil
|
||||
return errMailUnavailable()
|
||||
}
|
||||
if ok, wait := a.mailGate().take(mailGateKey); !ok {
|
||||
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
|
||||
// fallback (the demo path): a malformed address never mints, and a nil Mailer still
|
||||
// persists a code (logged server-side) so the verify flow stays exercisable.
|
||||
// TestEmailOTPStartValidation covers the mint-side input gate and the no-relay
|
||||
// refusal: a malformed address never mints, and with no Mailer the start answers
|
||||
// 503 mail_unavailable without minting, so no code exists to leak anywhere.
|
||||
func TestEmailOTPStartValidation(t *testing.T) {
|
||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||
mk := func(repo *fakeRepo) http.Handler {
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: user}
|
||||
return api.ExternalHandler() // no Mailer wired → demo fallback
|
||||
return api.ExternalHandler() // no Mailer wired
|
||||
}
|
||||
|
||||
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()
|
||||
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||
code, msg := errEnvelope(t, w)
|
||||
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 {
|
||||
t.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps))
|
||||
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.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
|
||||
|
||||
@@ -212,12 +212,21 @@ func errMailRateLimited(wait time.Duration) *apiError {
|
||||
"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
|
||||
// address alike while the budget is spent, so the refusal says nothing about
|
||||
// whether the address has an account.
|
||||
// errMailUnavailable answers a door that would mail a code on an install with
|
||||
// no [smtp] relay. The code is never minted, so it cannot turn up anywhere.
|
||||
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 {
|
||||
if a.Mailer == nil {
|
||||
return nil
|
||||
return errMailUnavailable()
|
||||
}
|
||||
if ok, wait := a.mailGate().peek(mailGateKey); !ok {
|
||||
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
||||
|
||||
+15
-5
@@ -74,12 +74,18 @@ func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) {
|
||||
if hasPasskey {
|
||||
st.Factors = append(st.Factors, reauthFactorPasskey)
|
||||
}
|
||||
if staffRole(p.Role) {
|
||||
st.Factors = append(st.Factors, reauthFactorSignIn)
|
||||
} else if p.EmailVerified {
|
||||
st.Factors = append(st.Factors, reauthFactorEmail)
|
||||
// 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) {
|
||||
st.Factors = append(st.Factors, reauthFactorSignIn)
|
||||
} else {
|
||||
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.
|
||||
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
|
||||
// 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) {
|
||||
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 {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -202,6 +202,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB
|
||||
|
||||
func TestReauthStatusNamesTheFactors(t *testing.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["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) {
|
||||
f := reauthFixture(t)
|
||||
mailer := &captureMailer{}
|
||||
|
||||
Reference in new issue
Block a user