mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 16:34:35 +08:00
- 新增 web-server 运行模式与浏览器端运行时桥接 - 支持管理员密码、会话策略、Google Authenticator 与恢复码 - 设置中心补充浏览器访问认证状态与改密入口 - 优化初始化向导,未启用 2FA 时跳过验证器步骤 Refs #618
196 lines
6.4 KiB
Go
196 lines
6.4 KiB
Go
package webserver
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHashPasswordAndVerify(t *testing.T) {
|
|
hash, err := hashPassword("strong-password-123")
|
|
if err != nil {
|
|
t.Fatalf("hashPassword failed: %v", err)
|
|
}
|
|
if !verifyPassword(hash, "strong-password-123") {
|
|
t.Fatalf("expected password verification to succeed")
|
|
}
|
|
if verifyPassword(hash, "wrong-password") {
|
|
t.Fatalf("expected password verification to fail for wrong password")
|
|
}
|
|
}
|
|
|
|
func TestTOTPValidationAcceptsCurrentWindow(t *testing.T) {
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
now := time.Unix(1_720_000_000, 0).UTC()
|
|
code, err := generateTOTPCodeAt(secret, now)
|
|
if err != nil {
|
|
t.Fatalf("generateTOTPCodeAt failed: %v", err)
|
|
}
|
|
if !validateTOTPCode(secret, code, now) {
|
|
t.Fatalf("expected TOTP validation to accept current time window")
|
|
}
|
|
if validateTOTPCode(secret, "000000", now) {
|
|
t.Fatalf("expected TOTP validation to reject invalid code")
|
|
}
|
|
}
|
|
|
|
func TestConsumeRecoveryCodeRemovesUsedCode(t *testing.T) {
|
|
cfg := normalizeWebAuthConfig(webAuthConfig{
|
|
Enabled: true,
|
|
TOTPEnabled: true,
|
|
RecoveryCodeHashes: []string{hashRecoveryCode("ABCD-EFGH-IJKL"), hashRecoveryCode("MNOP-QRST-UVWX")},
|
|
})
|
|
|
|
next, consumed := consumeRecoveryCode(cfg, "abcd efgh ijkl")
|
|
if !consumed {
|
|
t.Fatalf("expected recovery code to be consumed")
|
|
}
|
|
if len(next.RecoveryCodeHashes) != 1 {
|
|
t.Fatalf("expected one recovery code to remain, got %d", len(next.RecoveryCodeHashes))
|
|
}
|
|
if next.RecoveryCodeHashes[0] != hashRecoveryCode("MNOP-QRST-UVWX") {
|
|
t.Fatalf("unexpected remaining recovery code hash: %q", next.RecoveryCodeHashes[0])
|
|
}
|
|
}
|
|
|
|
func TestWebSessionManagerExpiresIdleAndAbsolute(t *testing.T) {
|
|
manager := newWebSessionManager()
|
|
cfg := normalizeWebAuthConfig(webAuthConfig{
|
|
Enabled: true,
|
|
PasswordHash: "hash",
|
|
SessionIdleMinutes: 5,
|
|
SessionAbsoluteHours: 1,
|
|
SessionRememberDays: 1,
|
|
})
|
|
start := time.Unix(1_720_000_000, 0).UTC()
|
|
sessionID, err := manager.Create(cfg, start)
|
|
if err != nil {
|
|
t.Fatalf("create session failed: %v", err)
|
|
}
|
|
if !manager.Authenticate(sessionID, cfg, start.Add(4*time.Minute)) {
|
|
t.Fatalf("expected session to remain valid before idle timeout")
|
|
}
|
|
if manager.Authenticate(sessionID, cfg, start.Add(10*time.Minute)) {
|
|
t.Fatalf("expected session to expire after idle timeout")
|
|
}
|
|
|
|
sessionID, err = manager.Create(cfg, start)
|
|
if err != nil {
|
|
t.Fatalf("create session failed: %v", err)
|
|
}
|
|
if manager.Authenticate(sessionID, cfg, start.Add(2*time.Hour)) {
|
|
t.Fatalf("expected session to expire after absolute timeout")
|
|
}
|
|
}
|
|
|
|
func TestGenerateQRCodeDataURL(t *testing.T) {
|
|
dataURL, err := generateQRCodeDataURL("otpauth://totp/GoNavi:admin%40localhost?secret=JBSWY3DPEHPK3PXP&issuer=GoNavi")
|
|
if err != nil {
|
|
t.Fatalf("generateQRCodeDataURL failed: %v", err)
|
|
}
|
|
if !strings.HasPrefix(dataURL, "data:image/png;base64,") {
|
|
t.Fatalf("unexpected QR code data URL prefix: %q", dataURL)
|
|
}
|
|
}
|
|
|
|
func TestWebAuthManagerChangePasswordRotatesSession(t *testing.T) {
|
|
manager, err := newWebAuthManager(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("newWebAuthManager failed: %v", err)
|
|
}
|
|
now := time.Unix(1_720_000_000, 0).UTC()
|
|
manager.now = func() time.Time { return now }
|
|
|
|
setup, err := manager.BeginSetup("127.0.0.1:34115")
|
|
if err != nil {
|
|
t.Fatalf("BeginSetup failed: %v", err)
|
|
}
|
|
code, err := generateTOTPCodeAt(setup.Secret, now)
|
|
if err != nil {
|
|
t.Fatalf("generateTOTPCodeAt failed: %v", err)
|
|
}
|
|
cfg, sessionID, err := manager.CompleteSetup(setup.SetupToken, "strong-password-123", code, true, 30, 24, 7)
|
|
if err != nil {
|
|
t.Fatalf("CompleteSetup failed: %v", err)
|
|
}
|
|
|
|
summary, err := manager.Settings()
|
|
if err != nil {
|
|
t.Fatalf("Settings failed: %v", err)
|
|
}
|
|
if !summary.Configured || !summary.TOTPEnabled {
|
|
t.Fatalf("unexpected settings summary: %+v", summary)
|
|
}
|
|
if summary.RecoveryCodesRemaining != 8 {
|
|
t.Fatalf("expected 8 recovery codes, got %d", summary.RecoveryCodesRemaining)
|
|
}
|
|
|
|
nextCfg, nextSessionID, usedRecoveryCode, err := manager.ChangePassword("strong-password-123", code, "strong-password-456")
|
|
if err != nil {
|
|
t.Fatalf("ChangePassword failed: %v", err)
|
|
}
|
|
if usedRecoveryCode {
|
|
t.Fatalf("expected TOTP code path instead of recovery code")
|
|
}
|
|
if sessionID == nextSessionID || strings.TrimSpace(nextSessionID) == "" {
|
|
t.Fatalf("expected ChangePassword to rotate the session, old=%q new=%q", sessionID, nextSessionID)
|
|
}
|
|
if manager.sessions.Authenticate(sessionID, cfg, now) {
|
|
t.Fatalf("expected previous session to be invalidated after password change")
|
|
}
|
|
if !manager.sessions.Authenticate(nextSessionID, nextCfg, now) {
|
|
t.Fatalf("expected rotated session to remain valid")
|
|
}
|
|
if !verifyPassword(nextCfg.PasswordHash, "strong-password-456") {
|
|
t.Fatalf("expected new password hash to validate")
|
|
}
|
|
if verifyPassword(nextCfg.PasswordHash, "strong-password-123") {
|
|
t.Fatalf("expected previous password to stop validating")
|
|
}
|
|
|
|
if _, _, _, _, err := manager.Login("strong-password-123", code, ""); !errors.Is(err, errWebAuthInvalidCredentials) {
|
|
t.Fatalf("expected old password login to fail with invalid credentials, got %v", err)
|
|
}
|
|
if _, _, _, _, err := manager.Login("strong-password-456", code, ""); err != nil {
|
|
t.Fatalf("expected new password login to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWebAuthManagerChangePasswordConsumesRecoveryCode(t *testing.T) {
|
|
manager, err := newWebAuthManager(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("newWebAuthManager failed: %v", err)
|
|
}
|
|
now := time.Unix(1_720_000_123, 0).UTC()
|
|
manager.now = func() time.Time { return now }
|
|
|
|
setup, err := manager.BeginSetup("127.0.0.1:34115")
|
|
if err != nil {
|
|
t.Fatalf("BeginSetup failed: %v", err)
|
|
}
|
|
code, err := generateTOTPCodeAt(setup.Secret, now)
|
|
if err != nil {
|
|
t.Fatalf("generateTOTPCodeAt failed: %v", err)
|
|
}
|
|
if _, _, err := manager.CompleteSetup(setup.SetupToken, "strong-password-123", code, true, 30, 24, 7); err != nil {
|
|
t.Fatalf("CompleteSetup failed: %v", err)
|
|
}
|
|
|
|
_, _, usedRecoveryCode, err := manager.ChangePassword("strong-password-123", setup.RecoveryCodes[0], "strong-password-456")
|
|
if err != nil {
|
|
t.Fatalf("ChangePassword failed: %v", err)
|
|
}
|
|
if !usedRecoveryCode {
|
|
t.Fatalf("expected recovery code usage to be reported")
|
|
}
|
|
|
|
summary, err := manager.Settings()
|
|
if err != nil {
|
|
t.Fatalf("Settings failed: %v", err)
|
|
}
|
|
if summary.RecoveryCodesRemaining != 7 {
|
|
t.Fatalf("expected one recovery code to be consumed, got %d remaining", summary.RecoveryCodesRemaining)
|
|
}
|
|
}
|