mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
Verify bcrypt passwords in Direct mode and the shipped procedures, hash on register/reset, ignore client-supplied roles and level at registration and drop the password from the jwt_login payload. Legacy cleartext upgrade is opt-in. Row security templates now bind the user as a parameter, validate identifiers, attach via common.SelectQuery and fail the request if the filter cannot be attached. ApplyColumnSecurity and GetRowSecurityTemplate convert panics to errors and the hooks fail closed. Update audit status.
536 lines
18 KiB
Go
536 lines
18 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
func futureTime() time.Time {
|
|
return time.Now().Add(1 * time.Hour)
|
|
}
|
|
|
|
// newDirectTestDB opens a fresh in-memory SQLite database and applies the
|
|
// portable Direct-mode schema (database_schema_sqlite.sql), giving every
|
|
// Direct-mode test a real, isolated database to exercise end-to-end.
|
|
func newDirectTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
|
|
if err != nil {
|
|
t.Fatalf("failed to open sqlite db: %v", err)
|
|
}
|
|
db.SetMaxOpenConns(1) // keep the shared in-memory db single-connection so state isn't lost
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
schemaPath := filepath.Join("database_schema_sqlite.sql")
|
|
schema, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read schema: %v", err)
|
|
}
|
|
if _, err := db.Exec(string(schema)); err != nil {
|
|
t.Fatalf("failed to apply schema: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func authenticatedRequest(token string) *http.Request {
|
|
req, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return req
|
|
}
|
|
|
|
func TestDirectMode_RegisterThenLogin(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
regResp, err := auth.Register(ctx, RegisterRequest{
|
|
Username: "alice",
|
|
Password: "hunter2",
|
|
Email: "alice@example.com",
|
|
Roles: []string{"user", "admin"},
|
|
UserLevel: 99,
|
|
Claims: map[string]any{"ip_address": "127.0.0.1", "user_agent": "test-agent"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
if regResp.Token == "" || regResp.User == nil {
|
|
t.Fatalf("Register() returned incomplete response: %+v", regResp)
|
|
}
|
|
if len(regResp.User.Roles) != 0 || regResp.User.UserLevel != 0 {
|
|
t.Errorf("client-supplied privileges must be ignored, got level=%d roles=%v", regResp.User.UserLevel, regResp.User.Roles)
|
|
}
|
|
|
|
// Password must be stored as a bcrypt hash, not cleartext.
|
|
var stored string
|
|
if err := db.QueryRow(`SELECT password FROM users WHERE username = 'alice'`).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isBcryptHash(stored) || stored == "hunter2" {
|
|
t.Errorf("password not hashed: %q", stored)
|
|
}
|
|
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "alice", Password: "wrong"}); err == nil {
|
|
t.Error("login with wrong password must fail")
|
|
}
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "alice"}); err == nil {
|
|
t.Error("login with empty password must fail")
|
|
}
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "nobody", Password: "hunter2"}); err == nil {
|
|
t.Error("login for unknown user must fail")
|
|
}
|
|
|
|
loginResp, err := auth.Login(ctx, LoginRequest{Username: "alice", Password: "hunter2"})
|
|
if err != nil {
|
|
t.Fatalf("Login() error = %v", err)
|
|
}
|
|
if loginResp.User.UserName != "alice" {
|
|
t.Errorf("Login() user = %q, want alice", loginResp.User.UserName)
|
|
}
|
|
|
|
// Duplicate registration should fail.
|
|
if _, err := auth.Register(ctx, RegisterRequest{Username: "alice", Password: "x", Email: "other@example.com"}); err == nil {
|
|
t.Error("expected duplicate username registration to fail")
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_SessionLifecycle(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
loginResp, err := auth.Register(ctx, RegisterRequest{Username: "bob", Password: "p", Email: "bob@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
userCtx, err := auth.Authenticate(authenticatedRequest(loginResp.Token))
|
|
if err != nil {
|
|
t.Fatalf("Authenticate() error = %v", err)
|
|
}
|
|
if userCtx.UserName != "bob" {
|
|
t.Errorf("Authenticate() user = %q, want bob", userCtx.UserName)
|
|
}
|
|
|
|
refreshResp, err := auth.RefreshToken(ctx, loginResp.Token)
|
|
if err != nil {
|
|
t.Fatalf("RefreshToken() error = %v", err)
|
|
}
|
|
if refreshResp.Token == "" || refreshResp.Token == loginResp.Token {
|
|
t.Errorf("RefreshToken() should return a new token, got %q", refreshResp.Token)
|
|
}
|
|
|
|
if err := auth.Logout(ctx, LogoutRequest{Token: refreshResp.Token, UserID: refreshResp.User.UserID}); err != nil {
|
|
t.Fatalf("Logout() error = %v", err)
|
|
}
|
|
|
|
if _, err := auth.RefreshToken(ctx, refreshResp.Token); err == nil {
|
|
t.Error("expected RefreshToken() after logout to fail")
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_PasswordReset(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
if _, err := auth.Register(ctx, RegisterRequest{Username: "carol", Password: "old", Email: "carol@example.com"}); err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
resetResp, err := auth.RequestPasswordReset(ctx, PasswordResetRequest{Email: "carol@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("RequestPasswordReset() error = %v", err)
|
|
}
|
|
if resetResp.Token == "" {
|
|
t.Fatal("expected a non-empty reset token")
|
|
}
|
|
|
|
if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "new"}); err != nil {
|
|
t.Fatalf("CompletePasswordReset() error = %v", err)
|
|
}
|
|
|
|
// Reusing the same token should now fail.
|
|
if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "again"}); err == nil {
|
|
t.Error("expected reusing a consumed reset token to fail")
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_JWTLoginAndLogout(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
jwtAuth := NewJWTAuthenticator("secret", db).WithQueryMode(ModeDirect)
|
|
directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
if _, err := directAuth.Register(ctx, RegisterRequest{Username: "dave", Password: "p", Email: "dave@example.com"}); err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
resp, err := jwtAuth.Login(ctx, LoginRequest{Username: "dave", Password: "p"})
|
|
if err != nil {
|
|
t.Fatalf("JWTAuthenticator.Login() error = %v", err)
|
|
}
|
|
if resp.User.UserName != "dave" {
|
|
t.Errorf("JWT login user = %q, want dave", resp.User.UserName)
|
|
}
|
|
|
|
if err := jwtAuth.Logout(ctx, LogoutRequest{Token: resp.Token, UserID: resp.User.UserID}); err != nil {
|
|
t.Fatalf("JWTAuthenticator.Logout() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
regResp, err := auth.Register(ctx, RegisterRequest{Username: "erin", Password: "p", Email: "erin@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
userID := regResp.User.UserID
|
|
|
|
totp := NewDatabaseTwoFactorProvider(db, nil).WithQueryMode(ModeDirect)
|
|
|
|
if err := totp.Enable2FA(userID, "SECRET123", []string{"code1", "code2"}); err != nil {
|
|
t.Fatalf("Enable2FA() error = %v", err)
|
|
}
|
|
|
|
enabled, err := totp.Get2FAStatus(userID)
|
|
if err != nil {
|
|
t.Fatalf("Get2FAStatus() error = %v", err)
|
|
}
|
|
if !enabled {
|
|
t.Error("expected 2FA to be enabled")
|
|
}
|
|
|
|
secret, err := totp.Get2FASecret(userID)
|
|
if err != nil {
|
|
t.Fatalf("Get2FASecret() error = %v", err)
|
|
}
|
|
if secret != "SECRET123" {
|
|
t.Errorf("Get2FASecret() = %q, want SECRET123", secret)
|
|
}
|
|
|
|
valid, err := totp.ValidateBackupCode(userID, "code1")
|
|
if err != nil {
|
|
t.Fatalf("ValidateBackupCode() error = %v", err)
|
|
}
|
|
if !valid {
|
|
t.Error("expected backup code to be valid")
|
|
}
|
|
|
|
// Reusing the same backup code should fail.
|
|
if _, err := totp.ValidateBackupCode(userID, "code1"); err == nil {
|
|
t.Error("expected reusing a consumed backup code to fail")
|
|
}
|
|
|
|
if err := totp.Disable2FA(userID); err != nil {
|
|
t.Fatalf("Disable2FA() error = %v", err)
|
|
}
|
|
enabled, err = totp.Get2FAStatus(userID)
|
|
if err != nil {
|
|
t.Fatalf("Get2FAStatus() error = %v", err)
|
|
}
|
|
if enabled {
|
|
t.Error("expected 2FA to be disabled")
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
regResp, err := auth.Register(ctx, RegisterRequest{Username: "frank", Password: "p", Email: "frank@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
userID := regResp.User.UserID
|
|
|
|
passkeys := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
|
RPID: "example.com", RPName: "Example", RPOrigin: "https://example.com", QueryMode: ModeDirect,
|
|
})
|
|
|
|
cred, err := passkeys.CompleteRegistration(ctx, userID, PasskeyRegistrationResponse{
|
|
RawID: []byte("credential-1"),
|
|
Response: PasskeyAuthenticatorAttestationResponse{
|
|
AttestationObject: []byte("public-key-bytes"),
|
|
},
|
|
Transports: []string{"internal", "usb"},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("CompleteRegistration() error = %v", err)
|
|
}
|
|
if cred.UserID != userID {
|
|
t.Errorf("CompleteRegistration() UserID = %d, want %d", cred.UserID, userID)
|
|
}
|
|
|
|
creds, err := passkeys.GetCredentials(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetCredentials() error = %v", err)
|
|
}
|
|
if len(creds) != 1 {
|
|
t.Fatalf("expected 1 credential, got %d", len(creds))
|
|
}
|
|
if string(creds[0].CredentialID) != "credential-1" {
|
|
t.Errorf("GetCredentials() CredentialID = %q, want credential-1", creds[0].CredentialID)
|
|
}
|
|
if len(creds[0].Transports) != 2 {
|
|
t.Errorf("expected 2 transports, got %v", creds[0].Transports)
|
|
}
|
|
|
|
credentialIDB64 := base64.StdEncoding.EncodeToString(creds[0].CredentialID)
|
|
|
|
if err := passkeys.UpdateCredentialName(ctx, userID, credentialIDB64, "My Phone"); err != nil {
|
|
t.Fatalf("UpdateCredentialName() error = %v", err)
|
|
}
|
|
|
|
updated, err := passkeys.GetCredentials(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetCredentials() error = %v", err)
|
|
}
|
|
if updated[0].Name != "My Phone" {
|
|
t.Errorf("expected updated name 'My Phone', got %q", updated[0].Name)
|
|
}
|
|
|
|
if err := passkeys.DeleteCredential(ctx, userID, credentialIDB64); err != nil {
|
|
t.Fatalf("DeleteCredential() error = %v", err)
|
|
}
|
|
remaining, err := passkeys.GetCredentials(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetCredentials() error = %v", err)
|
|
}
|
|
if len(remaining) != 0 {
|
|
t.Errorf("expected 0 credentials after delete, got %d", len(remaining))
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_OAuthGetOrCreateUserAndSession(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
userCtx := &UserContext{UserName: "gina", Email: "gina@example.com", Roles: []string{"user"}}
|
|
userID, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google")
|
|
if err != nil {
|
|
t.Fatalf("oauth2GetOrCreateUser() error = %v", err)
|
|
}
|
|
if userID == 0 {
|
|
t.Fatal("expected non-zero user ID")
|
|
}
|
|
|
|
// Calling again with the same email should return the same user, not create a duplicate.
|
|
userID2, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google")
|
|
if err != nil {
|
|
t.Fatalf("oauth2GetOrCreateUser() second call error = %v", err)
|
|
}
|
|
if userID2 != userID {
|
|
t.Errorf("expected same user ID on repeat call, got %d and %d", userID, userID2)
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
regResp, err := auth.Register(ctx, RegisterRequest{Username: "henry", Password: "p", Email: "henry@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
ks := NewDatabaseKeyStore(db, DatabaseKeyStoreOptions{QueryMode: ModeDirect})
|
|
|
|
createResp, err := ks.CreateKey(ctx, CreateKeyRequest{
|
|
UserID: regResp.User.UserID,
|
|
KeyType: KeyTypeGenericAPI,
|
|
Name: "test key",
|
|
Scopes: []string{"read", "write"},
|
|
Meta: map[string]any{"note": "test"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateKey() error = %v", err)
|
|
}
|
|
if createResp.RawKey == "" {
|
|
t.Fatal("expected a non-empty raw key")
|
|
}
|
|
|
|
validated, err := ks.ValidateKey(ctx, createResp.RawKey, KeyTypeGenericAPI)
|
|
if err != nil {
|
|
t.Fatalf("ValidateKey() error = %v", err)
|
|
}
|
|
if validated.UserID != regResp.User.UserID {
|
|
t.Errorf("ValidateKey() UserID = %d, want %d", validated.UserID, regResp.User.UserID)
|
|
}
|
|
if len(validated.Scopes) != 2 {
|
|
t.Errorf("expected 2 scopes, got %v", validated.Scopes)
|
|
}
|
|
|
|
keys, err := ks.GetUserKeys(ctx, regResp.User.UserID, "")
|
|
if err != nil {
|
|
t.Fatalf("GetUserKeys() error = %v", err)
|
|
}
|
|
if len(keys) != 1 {
|
|
t.Fatalf("expected 1 key, got %d", len(keys))
|
|
}
|
|
|
|
if err := ks.DeleteKey(ctx, regResp.User.UserID, keys[0].ID); err != nil {
|
|
t.Fatalf("DeleteKey() error = %v", err)
|
|
}
|
|
|
|
remaining, err := ks.GetUserKeys(ctx, regResp.User.UserID, "")
|
|
if err != nil {
|
|
t.Fatalf("GetUserKeys() error = %v", err)
|
|
}
|
|
if len(remaining) != 0 {
|
|
t.Errorf("expected 0 keys after delete, got %d", len(remaining))
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_OAuthServerClientAndCode(t *testing.T) {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
ctx := context.Background()
|
|
|
|
client := &OAuthServerClient{
|
|
ClientID: "client-1",
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
ClientName: "Example App",
|
|
}
|
|
registered, err := auth.OAuthRegisterClient(ctx, client)
|
|
if err != nil {
|
|
t.Fatalf("OAuthRegisterClient() error = %v", err)
|
|
}
|
|
if len(registered.GrantTypes) != 1 || registered.GrantTypes[0] != "authorization_code" {
|
|
t.Errorf("expected default grant types, got %v", registered.GrantTypes)
|
|
}
|
|
if len(registered.AllowedScopes) != 3 {
|
|
t.Errorf("expected default allowed scopes, got %v", registered.AllowedScopes)
|
|
}
|
|
|
|
fetched, err := auth.OAuthGetClient(ctx, "client-1")
|
|
if err != nil {
|
|
t.Fatalf("OAuthGetClient() error = %v", err)
|
|
}
|
|
if fetched.ClientName != "Example App" {
|
|
t.Errorf("OAuthGetClient() ClientName = %q, want %q", fetched.ClientName, "Example App")
|
|
}
|
|
if len(fetched.RedirectURIs) != 1 || fetched.RedirectURIs[0] != "https://app.example.com/callback" {
|
|
t.Errorf("OAuthGetClient() RedirectURIs = %v", fetched.RedirectURIs)
|
|
}
|
|
|
|
regResp, err := auth.Register(ctx, RegisterRequest{Username: "ivan", Password: "p", Email: "ivan@example.com"})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
if err := auth.OAuthSaveCode(ctx, &OAuthCode{
|
|
Code: "auth-code-2",
|
|
ClientID: "client-1",
|
|
RedirectURI: "https://app.example.com/callback",
|
|
CodeChallenge: "challenge",
|
|
SessionToken: regResp.Token,
|
|
Scopes: []string{"openid", "profile"},
|
|
ExpiresAt: futureTime(),
|
|
}); err != nil {
|
|
t.Fatalf("OAuthSaveCode() error = %v", err)
|
|
}
|
|
|
|
exchanged, err := auth.OAuthExchangeCode(ctx, "auth-code-2")
|
|
if err != nil {
|
|
t.Fatalf("OAuthExchangeCode() error = %v", err)
|
|
}
|
|
if exchanged.ClientID != "client-1" {
|
|
t.Errorf("OAuthExchangeCode() ClientID = %q, want client-1", exchanged.ClientID)
|
|
}
|
|
if len(exchanged.Scopes) != 2 {
|
|
t.Errorf("expected 2 scopes, got %v", exchanged.Scopes)
|
|
}
|
|
|
|
// Code should be single-use.
|
|
if _, err := auth.OAuthExchangeCode(ctx, "auth-code-2"); err == nil {
|
|
t.Error("expected re-exchanging a used code to fail")
|
|
}
|
|
|
|
info, err := auth.OAuthIntrospectToken(ctx, regResp.Token)
|
|
if err != nil {
|
|
t.Fatalf("OAuthIntrospectToken() error = %v", err)
|
|
}
|
|
if !info.Active {
|
|
t.Error("expected token to be active")
|
|
}
|
|
if info.Username != "ivan" {
|
|
t.Errorf("OAuthIntrospectToken() Username = %q, want ivan", info.Username)
|
|
}
|
|
|
|
if err := auth.OAuthRevokeToken(ctx, regResp.Token); err != nil {
|
|
t.Fatalf("OAuthRevokeToken() error = %v", err)
|
|
}
|
|
|
|
info, err = auth.OAuthIntrospectToken(ctx, regResp.Token)
|
|
if err != nil {
|
|
t.Fatalf("OAuthIntrospectToken() after revoke error = %v", err)
|
|
}
|
|
if info.Active {
|
|
t.Error("expected token to be inactive after revoke")
|
|
}
|
|
}
|
|
|
|
func TestDirectMode_LegacyPlaintextUpgradeIsOptIn(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect, UpgradePasswordHash: enabled})
|
|
ctx := context.Background()
|
|
|
|
if _, err := db.Exec(`DELETE FROM users`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at) VALUES ('legacy', 'l@example.com', 'oldpass', 0, '', 1, datetime('now'), datetime('now'))`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "legacy", Password: "nope"}); err == nil {
|
|
t.Fatal("wrong password must fail for legacy row")
|
|
}
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "legacy", Password: "oldpass"}); err != nil {
|
|
t.Fatalf("legacy login failed (upgrade=%v): %v", enabled, err)
|
|
}
|
|
var stored string
|
|
_ = db.QueryRow(`SELECT password FROM users WHERE username = 'legacy'`).Scan(&stored)
|
|
if enabled && !isBcryptHash(stored) {
|
|
t.Fatalf("upgrade enabled but password not upgraded: %q", stored)
|
|
}
|
|
if !enabled && stored != "oldpass" {
|
|
t.Fatalf("upgrade must not happen unless enabled, stored=%q", stored)
|
|
}
|
|
if _, err := auth.Login(ctx, LoginRequest{Username: "legacy", Password: "oldpass"}); err != nil {
|
|
t.Fatalf("second login failed (upgrade=%v): %v", enabled, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyPasswordEdgeCases(t *testing.T) {
|
|
h, _ := hashPassword("pw")
|
|
if ok, _ := verifyPassword(h, "pw"); !ok {
|
|
t.Error("bcrypt match failed")
|
|
}
|
|
if ok, _ := verifyPassword("", "pw"); ok {
|
|
t.Error("empty stored must not match")
|
|
}
|
|
if ok, _ := verifyPassword("pw", ""); ok {
|
|
t.Error("empty supplied must not match")
|
|
}
|
|
if _, err := hashPassword(string(make([]byte, 73))); err == nil {
|
|
t.Error("73-byte password must be rejected")
|
|
}
|
|
}
|