103 lines
4.3 KiB
Go
103 lines
4.3 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,
|
|
})
|
|
a.auditEntry(r, AuditEntry{Actor: actor, ActorUserID: userID, Action: "auth.otp.locked", Payload: payload})
|
|
|
|
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
|
|
}
|