mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
528 lines
17 KiB
Go
528 lines
17 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/glebarez/go-sqlite"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/ddl"
|
|
)
|
|
|
|
// directConfig forces the direct (table) backend for every operation.
|
|
var directConfig = lookup.Config{Mode: lookup.ModeDirect}
|
|
|
|
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 (lookup/ddl/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("sqlite", ":memory:")
|
|
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() })
|
|
|
|
schema, err := ddl.SQL("sqlite")
|
|
if err != nil {
|
|
t.Fatalf("failed to read schema: %v", err)
|
|
}
|
|
if _, err := db.Exec(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{Lookup: directConfig})
|
|
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{Lookup: directConfig})
|
|
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{Lookup: directConfig})
|
|
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).WithLookup(directConfig)
|
|
directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
|
|
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{Lookup: directConfig})
|
|
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).WithLookup(directConfig)
|
|
|
|
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{Lookup: directConfig})
|
|
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", Lookup: directConfig,
|
|
})
|
|
|
|
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{Lookup: directConfig})
|
|
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{Lookup: directConfig})
|
|
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{Lookup: directConfig})
|
|
|
|
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{Lookup: directConfig})
|
|
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{Lookup: directConfig, 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 isBcryptHash(s string) bool {
|
|
return strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$")
|
|
}
|