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
@@ -301,7 +301,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||
// at slå den fra, og den har altid spurgt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
@@ -368,7 +372,9 @@ func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
@@ -897,3 +903,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||
//
|
||||
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "det forkerte kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("en tom adgangskode blev accepteret")
|
||||
}
|
||||
}
|
||||
|
||||
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||
//
|
||||
// Den blev det, og 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. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||
// her kunne blive ved i det uendelige.
|
||||
refused := 0
|
||||
for i := 0; i < 14; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused == 0 {
|
||||
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user