fix(auth): 邮箱验证码按账号累计错误次数封顶并通知
This commit is contained in:
17 files changed
+664
-37
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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) {
|
||||
secs := int64(until.Sub(now).Round(time.Second) / time.Second)
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(secs, 10))
|
||||
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)))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
subject, body := otpLockNotice(door, lock.Until)
|
||||
if err := sender.SendNotice(ctx, u.Email, subject, body); err != nil {
|
||||
log.Printf("auth: otp lock notice to user %s failed: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user