Files
Felis/internal/api/otp_lock.go
T

108 lines
4.4 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
"felis.lolicon.best/internal/metrics"
)
// The account-level wrong-code budget (otpFailureBudget per otpFailureWindow)
// is enforced in the repo; this file is what the doors do with it. The two
// pre-session doors (email login, op-login) answer a locked account exactly
// like a wrong code, so they never become an existence oracle; the owner
// learns about the lock from a notice mail instead. The signed-in doors
// (onboarding, migration step-up) answer 429 otp_account_locked with the time
// the lock ends.
// noticeSender is the optional half of Mailer that sends a free-form notice;
// internal/mail.SMTP implements it (the reaper uses the same method).
type noticeSender interface {
SendNotice(ctx context.Context, email, subject, body string) error
}
// otpDoorName names each purpose in the lock notice, in both languages.
var otpDoorName = map[string][2]string{
otpPurposeLogin: {"邮箱验证码登录", "email-code sign-in"},
otpPurposeOpLogin: {"管理员登录", "staff sign-in"},
}
// writeOTPAccountLocked answers a signed-in door whose budget is spent.
func writeOTPAccountLocked(w http.ResponseWriter, r *http.Request, until, now time.Time) {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_account_locked",
"too many wrong codes on this account; email codes work again after %s",
until.UTC().Format(time.RFC3339)).retryAfter(max(until.Sub(now), time.Second)))
}
// noteOTPLock handles a redeem that met the account lock. Only the guess that
// spent the budget (JustLocked) does anything: count, audit, log, and for the
// pre-session doors mail the account so its owner learns why the right code
// stopped working. Everything here is best effort; the lock already holds.
func (a *API) noteOTPLock(r *http.Request, err error, userID, purpose string) {
var lock *OTPAccountLockedError
if !errors.As(err, &lock) || !lock.JustLocked {
return
}
metrics.OTPLockoutsTotal.WithLabelValues(purpose).Inc()
log.Printf("auth: %s codes for user %s locked until %s after %d wrong codes (request_id=%s)",
purpose, userID, lock.Until.UTC().Format(time.RFC3339), otpFailureBudget, requestIDFromContext(r.Context()))
ctx := r.Context()
actor := userID
u, uerr := a.Repo.UserByID(ctx, userID)
if uerr == nil && u.Username != "" {
actor = u.Username
}
payload, _ := json.Marshal(map[string]any{
"user_id": userID, "purpose": purpose, "until": lock.Until.UTC(), "failures": otpFailureBudget,
})
if aerr := a.Repo.Audit(ctx, AuditEntry{
Actor: actor, Source: "external", Action: "auth.otp.locked",
RequestID: requestIDFromContext(ctx), Payload: payload,
}); aerr != nil {
log.Printf("auth: audit of otp lock for user %s failed: %v", userID, aerr)
}
door, notify := otpDoorName[purpose]
if !notify || uerr != nil || u.Email == "" {
return
}
sender, ok := a.Mailer.(noticeSender)
if !ok {
log.Printf("auth: no notice mailer; user %s was not told their %s is locked", userID, purpose)
return
}
if ok, _ := a.mailGate().take(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("notice", "throttled").Inc()
log.Printf("auth: mail budget spent; user %s was not told their %s is locked", userID, purpose)
return
}
subject, body := otpLockNotice(door, lock.Until)
if err := sender.SendNotice(ctx, u.Email, subject, body); err != nil {
metrics.MailTotal.WithLabelValues("notice", "failed").Inc()
log.Printf("auth: otp lock notice to user %s failed: %v", userID, err)
return
}
metrics.MailTotal.WithLabelValues("notice", "sent").Inc()
}
// otpLockNotice renders the bilingual lock notice.
func otpLockNotice(door [2]string, until time.Time) (subject, body string) {
at := until.UTC().Format("2006-01-02 15:04 MST")
subject = "Felis " + door[0] + "已暂停 · " + door[1] + " paused"
body = fmt.Sprintf(`Felis 在 24 小时内收到了 %[1]d 次错误的邮箱验证码,已暂停这个账户的%[2]s,%[3]s 自动恢复。
如果不是你本人在尝试,说明有人在猜你的验证码。账户仍然安全:暂停期间任何验证码都无法登录。
你仍然可以用已绑定的 Passkey 登录。
Felis received %[1]d wrong email codes for this account within 24 hours and paused %[4]s until %[3]s.
If this wasn't you, someone is guessing your code. Your account is safe: no code works while it is paused.
You can still sign in with a passkey you have registered.
`, otpFailureBudget, door[0], at, door[1])
return subject, body
}