fix(auth): andenfaktoren kunne gættes igennem, og et token kunne tilmelde sin egen
To huller fra sikkerhedsgennemgangen, og de er samme historie fortalt to gange: den ene kontrol, der findes til når adgangskoden allerede er tabt, kunne omgås af netop den, der havde adgangskoden. **H1 — spanden blev fyldt af adgangskoden alene.** `handleLogin` nulstillede ratebegrænseren ved hver korrekt adgangskode, *før* den så på, om der var en andenfaktor. Det gav én, der havde kodeordet, ubegrænsede portioner på ti gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på koden, log ind igen. Med tre gyldige koder i ethvert øjeblik og tyve forespørgsler i sekundet er det lige odds inden for en eftermiddag. Spanden ryddes nu først, når login er *færdigt*, og der er kommet en spand mere: koder tælles pr. konto og ikke kun pr. adresse. Adressespanden er slået af enhver med mere end én adresse, hvilket er det almindelige — en telefon og en bærbar er allerede to. Det, der gør en sekscifret kode sikker, er at gæt er en knap ressource, og knapheden skal tælles imod det, der bliver angrebet. Den ventende session får lov at leve. At afslutte den ved første forkerte ciffer blev overvejet og valgt fra: det ville koste en, der taster ét tal forkert, sin adgangskode igen, og det køber næsten ingenting oven i loftet pr. konto. **M3 — et lækket token kunne tilmelde sin egen andenfaktor.** `/auth/totp/setup` lå kun bag `requireAuth` og spurgte om ingenting, mens `disable` altid har krævet adgangskoden: døren åbnede med et token og lukkede kun med et kodeord. Et stjålet token kunne hente hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den eneste kopi af gendannelseskoderne. Ejerens næste login ville stoppe ved en kode, de ikke havde, og de kunne ikke slå den fra uden at logge ind først. Et tyveri bliver til et lejemål — den sætning står allerede i filen ved siden af om passkeys, og det er den samme regel. Nu bag `requireSession` sammen med passkeys og sessionerne, og `setup` spørger om adgangskoden ligesom `disable`. Symmetrien er pointen: at slå den til er mindst lige så indgribende som at slå den fra. Og en note kan oprettes inde fra et projekt. At ligge i et projekt *er* måden en note deles på, så det er én handling frem for to — man skulle før oprette noten under Noter og bagefter finde projektet frem i "Del i". Ruden findes nu, også før der er en eneste note, for ellers er der ingen knap dér, hvor man opdager at der skal skrives noget ned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bdde9cc6a5
commit
ce3fc466b2
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
// The bucket is cleared only when the login is *finished*.
|
||||
//
|
||||
// It used to be cleared here, on the strength of the password alone — before
|
||||
// the second factor had been asked for at all. That handed somebody who
|
||||
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||
// second, that is even odds inside an afternoon — against the one control that
|
||||
// exists precisely for when the password is already gone.
|
||||
//
|
||||
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||
// it, once the code is right.
|
||||
if !user.TOTPEnabled {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
}
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
@@ -191,6 +205,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
// Counted per account as well as per address.
|
||||
//
|
||||
// The address bucket alone is defeated by anybody with more than one, which is
|
||||
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||
// scarcity has to be counted against the thing being attacked.
|
||||
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many verification codes; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
@@ -198,6 +225,15 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
// The pending session is left alive on purpose.
|
||||
//
|
||||
// Ending it on the first wrong digit was considered and rejected: it
|
||||
// would make somebody who mistyped one number type their password again,
|
||||
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||
// guesses are scarce, and the per-account bucket above is what makes
|
||||
// them scarce — ten an hour, however many times the password is
|
||||
// re-entered. The attack this closes was never "one session, many
|
||||
// guesses"; it was "a fresh bucket for every password check".
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
@@ -205,6 +241,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||
// fumbled the code twice is not still waiting once they get it right.
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user