Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The SHA-1 test vectors of RFC 6238 appendix B, cut to 6 digits.
|
||||
func TestCodeRFC6238(t *testing.T) {
|
||||
secret := b32.EncodeToString([]byte("12345678901234567890"))
|
||||
for _, c := range []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{
|
||||
{59, "287082"},
|
||||
{1111111109, "081804"},
|
||||
{1111111111, "050471"},
|
||||
{1234567890, "005924"},
|
||||
{2000000000, "279037"},
|
||||
{20000000000, "353130"},
|
||||
} {
|
||||
got, err := Code(secret, Step(time.Unix(c.unix, 0)))
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("T=%d: %q, %v; want %q", c.unix, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
secret := NewSecret()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
cur := Step(now)
|
||||
code := func(step int64) string { c, _ := Code(secret, step); return c }
|
||||
if step, ok := Verify(secret, code(cur), now, 0); !ok || step != cur {
|
||||
t.Fatal("current code refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur - 1)[:3]+" "+code(cur - 1)[3:], now, 0); !ok {
|
||||
t.Error("previous step (with a space) refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur+1), now, 0); !ok {
|
||||
t.Error("next step refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur-2), now, 0); ok {
|
||||
t.Error("code from a minute ago accepted")
|
||||
}
|
||||
// Replay: a step at or before the last used one is refused.
|
||||
if _, ok := Verify(secret, code(cur), now, cur); ok {
|
||||
t.Error("reused code accepted")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur+1), now, cur); !ok {
|
||||
t.Error("newer code refused after a used one")
|
||||
}
|
||||
if _, ok := Verify(secret, "abcdef", now, 0); ok {
|
||||
t.Error("letters accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURIAndQR(t *testing.T) {
|
||||
u := URI("Jarl ns1", "adam", "JBSWY3DPEHPK3PXP")
|
||||
if !strings.HasPrefix(u, "otpauth://totp/Jarl%20ns1:adam?") || !strings.Contains(u, "secret=JBSWY3DPEHPK3PXP") || !strings.Contains(u, "issuer=Jarl+ns1") {
|
||||
t.Errorf("uri = %s", u)
|
||||
}
|
||||
img, err := QRDataURI(u)
|
||||
if err != nil || !strings.HasPrefix(img, "data:image/svg+xml;base64,") {
|
||||
t.Errorf("qr = %.60s, %v", img, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCodes(t *testing.T) {
|
||||
codes := NewRecoveryCodes(RecoveryCodes)
|
||||
seen := map[string]bool{}
|
||||
for _, c := range codes {
|
||||
if len(c) != 11 || c[5] != '-' || seen[c] || IsCode(c) {
|
||||
t.Errorf("bad code %q", c)
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
if NormalizeRecovery(" K7DM2-XQ9PW ") != "k7dm2xq9pw" {
|
||||
t.Error("normalize")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user