diff options
Diffstat (limited to 'totp_test.go')
| -rw-r--r-- | totp_test.go | 517 |
1 files changed, 517 insertions, 0 deletions
diff --git a/totp_test.go b/totp_test.go new file mode 100644 index 0000000..1c5b240 --- /dev/null +++ b/totp_test.go @@ -0,0 +1,517 @@ +package main + +import ( + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// The RFC's own vectors, which is the only way to know an authenticator will +// agree. +func TestTOTPAgainstRFC6238(t *testing.T) { + // The RFC's SHA-1 key is the ASCII "12345678901234567890". + secret := knownSecret + cases := map[int64]string{ + 59: "287082", + 1111111109: "081804", + 1111111111: "050471", + 1234567890: "005924", + 2000000000: "279037", + } + for at, want := range cases { + if got := totpCode(secret, time.Unix(at, 0)); got != want { + t.Errorf("at %d: %s, wanted %s", at, got, want) + } + } + if totpCode("not base32 !!", time.Now()) != "" { + t.Error("an unreadable secret produced a code") + } +} + +func TestTOTPSkew(t *testing.T) { + secret := totpSecret() + now := time.Now() + if totpStep(secret, totpCode(secret, now), now) < 0 { + t.Error("the current code was refused") + } + if totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now) < 0 { + t.Error("the previous code was refused") + } + if totpStep(secret, totpCode(secret, now.Add(-5*totpPeriod)), now) >= 0 { + t.Error("a code from two minutes ago was accepted") + } + if totpStep(secret, "000000 ", now) >= 0 && totpCode(secret, now) != "000000" { + t.Error("a wrong code was accepted") + } + // The step reported is the one the code was made for, which is what stops + // the previous code once the current one has been used. + if got, want := totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now), now.Unix()/30-1; got != want { + t.Errorf("the previous code reports step %d, wanted %d", got, want) + } +} + +func TestTOTPLoginAndRecovery(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + + // Enrolling needs a code that proves the authenticator has the secret. + page := get(h, "/settings/totp", session).Body.String() + secret := between(page, `name="secret" value="`, `"`) + if secret == "" { + t.Fatal("no proposed secret on the enrolment page") + } + // Enrolling costs the password as well as a code. + bare := url.Values{"csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}} + if w := post(h, "/settings/totp", bare, session); w.Code != http.StatusUnauthorized { + t.Errorf("enrolling with no password: status %d", w.Code) + } + wrong := url.Values{"csrf": {token}, "secret": {secret}, "code": {"000000"}, "current": {"hunter2hunter2"}} + retry := post(h, "/settings/totp", wrong, session) + if retry.Code != http.StatusBadRequest { + t.Errorf("enrolling with a wrong code: status %d", retry.Code) + } + // The page says to try the next code, which is only possible if the secret + // is still the one already typed into the authenticator. + if got := between(retry.Body.String(), `name="secret" value="`, `"`); got != secret { + t.Errorf("a wrong code rotated the secret to %q", got) + } + w := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, session) + if w.Code != http.StatusOK { + t.Fatalf("enrolling: status %d", w.Code) + } + codes := recoveryFrom(w.Body.String()) + if len(codes) != recoveryCodes { + t.Fatalf("expected %d recovery codes, got %v", recoveryCodes, codes) + } + if u, _ := f.store.User("alice"); u.TOTP == nil || u.TOTP.Secret != secret || u.Epoch == 0 { + t.Errorf("enrolment did not stick, or did not bump the epoch: %+v", u.TOTP) + } + + // The password alone now stops at the second step. + first := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil) + if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "Authenticator code") { + t.Fatalf("password alone: status %d", first.Code) + } + if len(first.Result().Cookies()) != 0 { + t.Error("the password alone issued a session") + } + pending := url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + } + + badCode := maps.Clone(pending) + badCode.Set("code", "000000") + if w := post(h, "/login/totp", badCode, nil); w.Code != http.StatusUnauthorized { + t.Errorf("a wrong code: status %d", w.Code) + } + right := maps.Clone(pending) + right.Set("code", freshCode(t, f, "alice", secret)) + if w := post(h, "/login/totp", right, nil); w.Code != http.StatusSeeOther || len(w.Result().Cookies()) == 0 { + t.Errorf("the right code: status %d", w.Code) + } + // A code works once, however long its window has left. + if w := post(h, "/login/totp", right, nil); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) != 0 { + t.Errorf("the same code again: status %d", w.Code) + } + + // A recovery code works once. + recovery := maps.Clone(pending) + recovery.Set("code", codes[0]) + if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusSeeOther { + t.Errorf("a recovery code: status %d", w.Code) + } + if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 { + t.Errorf("the recovery code was not spent: %d left", len(u.TOTP.Recovery)) + } + if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusUnauthorized { + t.Errorf("the same recovery code twice: status %d", w.Code) + } +} + +// The flag is not enough on its own. +func TestAdminNeedsTOTP(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + err := f.store.UpdateUsers("users: admin", f.store.forgeAuthor(), func(m map[string]User) error { + u := m["alice"] + u.Admin = true + m["alice"] = u + return nil + }) + if err != nil { + t.Fatal(err) + } + h := f.routes() + session := signIn(t, f, h, "alice") + + w := get(h, "/admin", session) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/settings/totp" { + t.Fatalf("an admin with no authenticator reached /admin: %d %s", w.Code, w.Header().Get("Location")) + } + + token := f.csrf(withCookies("/", session)) + secret := between(get(h, "/settings/totp", session).Body.String(), `name="secret" value="`, `"`) + enrolled := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, session) + session = enrolled.Result().Cookies() + if get(h, "/admin", session).Code != http.StatusOK { + t.Error("an enrolled admin still cannot reach /admin") + } + + // And an admin may not take the second factor off again. + token = f.csrf(withCookies("/", session)) + form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}} + if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusForbidden { + t.Errorf("an admin removed their authenticator: status %d", w.Code) + } +} + +// Enrolling and reissuing recovery codes both outlive the session they were +// done from, so both cost the password. +func TestSecondFactorNeedsPassword(t *testing.T) { + f := testForge(t) + addUser(t, f, "victim", "hunter2hunter2") + h := f.routes() + stolen := signIn(t, f, h, "victim") + token := f.csrf(withCookies("/", stolen)) + secret := knownSecret + + w := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}}, stolen) + if w.Code != http.StatusUnauthorized { + t.Fatalf("a cookie alone enrolled a second factor: status %d", w.Code) + } + if u, _ := f.store.User("victim"); u.TOTP != nil { + t.Fatal("the account was taken with a cookie alone") + } + // The retry has to offer the same secret, or the authenticator that was + // just set up by hand no longer matches. + if got := between(w.Body.String(), `name="secret" value="`, `"`); got != secret { + t.Errorf("the retry offered a different secret: %q", got) + } + + w = post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, stolen) + if w.Code != http.StatusOK { + t.Fatalf("enrolling with the password: status %d", w.Code) + } + session := w.Result().Cookies() + token = f.csrf(withCookies("/", session)) + + if w := post(h, "/settings/totp/codes", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized { + t.Errorf("recovery codes regenerated without a password: status %d", w.Code) + } + w = post(h, "/settings/totp/codes", url.Values{"csrf": {token}, "current": {"hunter2hunter2"}}, session) + if w.Code != http.StatusOK || len(recoveryFrom(w.Body.String())) != recoveryCodes { + t.Errorf("regenerating with the password: status %d", w.Code) + } +} + +// An external account has no password, so the enrolled authenticator is what +// stands between a stolen cookie and losing the second factor. +func TestExternalSecondFactorNeedsACode(t *testing.T) { + f := testForge(t) + err := f.store.UpdateUsers("users: add sso:carol", f.store.forgeAuthor(), func(m map[string]User) error { + m["sso:carol"] = User{Origin: originOIDC, Iss: "https://idp.example", Sub: "carol", Notify: true} + return nil + }) + if err != nil { + t.Fatal(err) + } + addTOTP(t, f, "sso:carol") + h := f.routes() + + // No password and no provider round trip: the cookie is all the thief has. + stolen := httptest.NewRecorder() + f.setSession(stolen, "sso:carol", 0) + session := stolen.Result().Cookies() + token := f.csrf(withCookies("/", session)) + + for _, path := range []string{"/settings/totp/remove", "/settings/totp/codes"} { + if w := post(h, path, url.Values{"csrf": {token}}, session); w.Code != http.StatusForbidden { + t.Errorf("%s with the cookie alone: status %d", path, w.Code) + } + if u, _ := f.store.User("sso:carol"); u.TOTP == nil { + t.Fatalf("%s took the second factor off with the cookie alone", path) + } + } + + code := url.Values{"csrf": {token}, "code": {totpCode(knownSecret, time.Now())}} + if w := post(h, "/settings/totp/codes", code, session); w.Code != http.StatusOK { + t.Errorf("regenerating with the code: status %d", w.Code) + } + code.Set("code", freshCode(t, f, "sso:carol", knownSecret)) + if w := post(h, "/settings/totp/remove", code, session); w.Code != http.StatusSeeOther { + t.Errorf("removing with the code: status %d", w.Code) + } + if u, _ := f.store.User("sso:carol"); u.TOTP != nil { + t.Error("the code was right and the authenticator is still there") + } +} + +// The way back in when both the authenticator and the recovery codes are gone. +func TestAdminClearsLostAuthenticator(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + addTOTP(t, f, "bob") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + + // Clearing somebody's second factor hands out access, so it asks for a code. + bare := url.Values{"csrf": {token}, "name": {"bob"}} + if w := post(h, "/admin/user/totp", bare, session); w.Code != http.StatusForbidden { + t.Errorf("cleared bob's factor with no code of our own: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP == nil { + t.Fatal("it was cleared anyway") + } + withCode := func(name string) url.Values { + return url.Values{"csrf": {token}, "name": {name}, "code": {freshCode(t, f, "alice", knownSecret)}} + } + if w := post(h, "/admin/user/totp", withCode("alice"), session); w.Code != http.StatusForbidden { + t.Errorf("an admin cleared their own factor from the admin page: status %d", w.Code) + } + if w := post(h, "/admin/user/totp", withCode("bob"), session); w.Code != http.StatusSeeOther { + t.Fatalf("clearing bob: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP != nil || u.Epoch == 0 { + t.Error("bob's factor was not cleared, or his sessions were left alive") + } + if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther { + t.Errorf("bob cannot get back in: status %d", w.Code) + } +} + +// The budget is for guessing, and it is the same one the login form spends. An +// admin charged for every code that was right would lock themselves out. +func TestConfirmCodeCountsWrongCodesOnly(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + // Signing in spent one of these, which is its own rule. Count from here. + f.totpLimit = newLimiter() + + right := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}} + for i := range 5 { + right.Set("code", freshCode(t, f, "alice", knownSecret)) + if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusSeeOther { + t.Fatalf("admin action %d: status %d", i, w.Code) + } + } + if f.totpLimit.over(accountKey("alice"), 0) { + t.Error("codes that were right spent the budget for guessing") + } + + wrong := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}, "code": {"000000"}} + for range 3 { + if w := post(h, "/admin/user/admin", wrong, session); w.Code != http.StatusForbidden { + t.Fatalf("a wrong code was accepted: status %d", w.Code) + } + } + // The code that was just accepted is refused a second time, and counted. + if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusForbidden { + t.Errorf("a code used already was accepted again: status %d", w.Code) + } + if !f.totpLimit.over(accountKey("alice"), 3) { + t.Error("wrong codes were not counted") + } + if f.totpLimit.over(accountKey("alice"), 4) { + t.Error("more was counted than was tried") + } +} + +// The secret arrives in a form, so what is enrolled is only as strong as this +// check makes it. +func TestEnrolledSecretMustBeTheMintedSize(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + session := signIn(t, f, h, "alice") + for _, secret := range []string{"AA", "", "!!!", knownSecret + "GE"} { + w := post(h, "/settings/totp", url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "secret": {secret}, + "code": {totpCode(secret, time.Now())}, "current": {"hunter2hunter2"}, + }, session) + if w.Code != http.StatusBadRequest { + t.Errorf("a secret of %d characters: status %d", len(secret), w.Code) + } + } + if u, _ := f.store.User("alice"); u.TOTP != nil { + t.Error("a secret this forge would not mint was enrolled") + } +} + +// Two logins with one recovery code, from the same read of the account: the +// write decides, not the read. +func TestRecoveryCodeIsSpentInTheWrite(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + show, keep := newRecoveryCodes() + err := f.store.UpdateUser("alice", "users: enrol", f.store.forgeAuthor(), func(acct *User) error { + acct.TOTP = &TOTP{Secret: knownSecret, Recovery: keep} + return nil + }) + if err != nil { + t.Fatal(err) + } + stale, _ := f.store.User("alice") + if !f.spendRecoveryCode("alice", stale, show[0]) { + t.Fatal("a fresh recovery code was refused") + } + if f.spendRecoveryCode("alice", stale, show[0]) { + t.Error("the same recovery code was accepted twice") + } + if f.spendRecoveryCode("alice", stale, "not-a-code") { + t.Error("a made up recovery code was accepted") + } + if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 { + t.Errorf("%d recovery codes left, wanted %d", len(u.TOTP.Recovery), recoveryCodes-1) + } +} + +// knownSecret is the base32 of the ASCII "12345678901234567890", the key the +// RFC's own vectors use. +const knownSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +// addTOTP enrols the known authenticator on an account, so that totpCode can +// answer for it. +func addTOTP(t *testing.T, f *Forge, name string) { + t.Helper() + err := f.store.UpdateUsers("users: enrol "+name, f.store.forgeAuthor(), func(m map[string]User) error { + u := m[name] + u.TOTP = &TOTP{Secret: knownSecret, Recovery: []string{}} + m[name] = u + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// freshCode clears the step the account last accepted and returns the current +// code. A code works once, and a test cannot wait thirty seconds for the next. +func freshCode(t *testing.T, f *Forge, name, secret string) string { + t.Helper() + err := f.store.touchUser(name, func(acct *User) error { + if acct.TOTP != nil { + acct.TOTP.Used = 0 + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return totpCode(secret, time.Now()) +} + +// addAdmin hands out the global admin flag, with the authenticator the flag +// does nothing without. +func addAdmin(t *testing.T, f *Forge, name string) { + t.Helper() + addTOTP(t, f, name) + err := f.store.UpdateUsers("users: admin "+name, f.store.forgeAuthor(), func(m map[string]User) error { + u := m[name] + u.Admin = true + m[name] = u + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// codeSession signs an account holding the known authenticator all the way +// in, through both steps. +func codeSession(t *testing.T, f *Forge, h http.Handler, name, password string) []*http.Cookie { + t.Helper() + first := post(h, "/login", url.Values{"name": {name}, "password": {password}}, nil) + if !strings.Contains(first.Body.String(), "Authenticator code") { + t.Fatal("expected a second step") + } + w := post(h, "/login/totp", url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + "code": {totpCode(knownSecret, time.Now())}, + }, nil) + if w.Code != http.StatusSeeOther { + t.Fatalf("second step: %d", w.Code) + } + return w.Result().Cookies() +} + +func between(s, start, end string) string { + _, rest, ok := strings.Cut(s, start) + if !ok { + return "" + } + out, _, _ := strings.Cut(rest, end) + return out +} + +func recoveryFrom(page string) []string { + block := between(page, "<pre>", "</pre>") + var codes []string + for _, line := range strings.Split(block, "\n") { + if line = strings.TrimSpace(line); line != "" { + codes = append(codes, line) + } + } + return codes +} + +// Removing it is the other half of enrolling: it costs the password too, and +// it takes every session with it. +func TestSecondFactorIsRemoved(t *testing.T) { + f := testForge(t) + addUser(t, f, "bob", "hunter2hunter2") + addTOTP(t, f, "bob") + h := f.routes() + session := codeSession(t, f, h, "bob", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + + if w := post(h, "/settings/totp/remove", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized { + t.Fatalf("removing with no password: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP == nil { + t.Fatal("it was removed anyway") + } + form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}} + if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("removing with the password: status %d", w.Code) + } + u, _ := f.store.User("bob") + if u.TOTP != nil { + t.Error("the authenticator is still enrolled") + } + if u.Epoch == 0 { + t.Error("removing it left every other session alive") + } + // And the password alone gets back in. + if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther { + t.Errorf("signing in after the removal: status %d", w.Code) + } +} |