mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-07-08 11:47:38 +00:00
eee83f9dc6
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 24s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 59s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 58s
Build , Vet Test, and Lint / Build (push) Successful in 4m37s
Build , Vet Test, and Lint / Lint Code (push) Failing after 5m35s
* Introduced QueryMode to select between stored procedure and direct SQL execution. * Implemented dbCapability to probe for stored procedure existence. * Added table names configuration for direct SQL operations. * Updated DatabaseTwoFactorProvider to support query mode and table names. * Implemented direct SQL methods mirroring stored procedures for TOTP operations. * Added tests for query mode logic and table names validation.
474 lines
17 KiB
Go
474 lines
17 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Direct-mode implementations for DatabaseAuthenticator and JWTAuthenticator.
|
|
// These mirror the plpgsql bodies in database_schema.sql using plain
|
|
// parameterized SQL against the configured TableNames, so they work on
|
|
// SQLite, MySQL, or Postgres without the resolvespec_* functions installed.
|
|
//
|
|
// Password verification is intentionally not implemented here: the stored
|
|
// procedures never verify the password hash either (see the TODOs in
|
|
// database_schema.sql), so Direct mode matches that behavior exactly rather
|
|
// than introducing a mismatch between modes.
|
|
|
|
var (
|
|
errUsernameExists = errors.New("Username already exists")
|
|
errEmailExists = errors.New("Email already exists")
|
|
)
|
|
|
|
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
var userID int
|
|
var email, roles, programUserTable sql.NullString
|
|
var userLevel, programUserID sql.NullInt64
|
|
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
|
`SELECT id, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE username = ? AND is_active = ?`,
|
|
a.tableNames.Users))
|
|
return db.QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("Invalid credentials")
|
|
}
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
|
|
sessionToken, err := generateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
now := time.Now()
|
|
expiresAt := now.Add(24 * time.Hour)
|
|
ipAddress, userAgent := claimStrings(req.Claims)
|
|
|
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
a.tableNames.UserSessions))
|
|
if _, err := db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
|
|
return err
|
|
}
|
|
updateQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
|
|
_, err := db.ExecContext(ctx, updateQuery, now, userID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
|
|
userCtx := &UserContext{
|
|
UserID: userID,
|
|
UserName: req.Username,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
Roles: parseRoles(roles.String),
|
|
SessionID: sessionToken,
|
|
ProgramUserID: int(programUserID.Int64),
|
|
ProgramUserTable: programUserTable.String,
|
|
}
|
|
|
|
return &LoginResponse{
|
|
Token: sessionToken,
|
|
User: userCtx,
|
|
ExpiresIn: 86400,
|
|
}, nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
|
if req.Username == "" {
|
|
return nil, fmt.Errorf("Username is required")
|
|
}
|
|
if req.Email == "" {
|
|
return nil, fmt.Errorf("Email is required")
|
|
}
|
|
if req.Password == "" {
|
|
return nil, fmt.Errorf("Password is required")
|
|
}
|
|
|
|
rolesStr := strings.Join(req.Roles, ",")
|
|
now := time.Now()
|
|
ipAddress, userAgent := claimStrings(req.Claims)
|
|
|
|
var userID int64
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
var count int
|
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE username = ?`, a.tableNames.Users))
|
|
if err := db.QueryRowContext(ctx, checkQuery, req.Username).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errUsernameExists
|
|
}
|
|
checkQuery2 := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE email = ?`, a.tableNames.Users))
|
|
if err := db.QueryRowContext(ctx, checkQuery2, req.Email).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errEmailExists
|
|
}
|
|
|
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
|
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
a.tableNames.Users))
|
|
res, err := db.ExecContext(ctx, insertQuery, req.Username, req.Email, req.Password, req.UserLevel, rolesStr, true, now, now, 0, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err = res.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, errUsernameExists) {
|
|
return nil, errUsernameExists
|
|
}
|
|
if errors.Is(err, errEmailExists) {
|
|
return nil, errEmailExists
|
|
}
|
|
return nil, fmt.Errorf("register query failed: %w", err)
|
|
}
|
|
|
|
sessionToken, err := generateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
expiresAt := now.Add(24 * time.Hour)
|
|
|
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
insertSession := rewritePlaceholders(db, fmt.Sprintf(
|
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
a.tableNames.UserSessions))
|
|
if _, err := db.ExecContext(ctx, insertSession, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
|
|
return err
|
|
}
|
|
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
|
|
_, err := db.ExecContext(ctx, updUser, now, userID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("register query failed: %w", err)
|
|
}
|
|
|
|
userCtx := &UserContext{
|
|
UserID: int(userID),
|
|
UserName: req.Username,
|
|
Email: req.Email,
|
|
UserLevel: req.UserLevel,
|
|
Roles: parseRoles(rolesStr),
|
|
SessionID: sessionToken,
|
|
ProgramUserID: 0,
|
|
ProgramUserTable: "",
|
|
}
|
|
|
|
return &LoginResponse{
|
|
Token: sessionToken,
|
|
User: userCtx,
|
|
ExpiresIn: 86400,
|
|
}, nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequest) error {
|
|
token := req.Token
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
token = strings.TrimPrefix(token, "bearer ")
|
|
|
|
var rows int64
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ? AND user_id = ?`, a.tableNames.UserSessions))
|
|
res, err := db.ExecContext(ctx, query, token, req.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err = res.RowsAffected()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("logout query failed: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("Session not found")
|
|
}
|
|
|
|
if req.Token != "" {
|
|
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
|
|
_ = a.cache.Delete(ctx, cacheKey)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string) (*UserContext, error) {
|
|
var userID int
|
|
var username, email, roles, programUserTable sql.NullString
|
|
var userLevel, programUserID sql.NullInt64
|
|
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
|
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, u.program_user_id, u.program_user_table
|
|
FROM %s s JOIN %s u ON s.user_id = u.id
|
|
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
|
a.tableNames.UserSessions, a.tableNames.Users))
|
|
return db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("Invalid or expired session")
|
|
}
|
|
return nil, fmt.Errorf("session query failed: %w", err)
|
|
}
|
|
|
|
return &UserContext{
|
|
UserID: userID,
|
|
UserName: username.String,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
SessionID: token,
|
|
Roles: parseRoles(roles.String),
|
|
ProgramUserID: int(programUserID.Int64),
|
|
ProgramUserTable: programUserTable.String,
|
|
}, nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) updateSessionActivityDirect(ctx context.Context, sessionToken string) error {
|
|
return a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_activity_at = ? WHERE session_token = ? AND expires_at > ?`, a.tableNames.UserSessions))
|
|
_, err := db.ExecContext(ctx, query, time.Now(), sessionToken, time.Now())
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken string) (*LoginResponse, error) {
|
|
var userID int
|
|
var username, email, roles, ipAddress, userAgent, programUserTable sql.NullString
|
|
var userLevel, programUserID sql.NullInt64
|
|
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
|
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent, u.program_user_id, u.program_user_table
|
|
FROM %s s JOIN %s u ON s.user_id = u.id
|
|
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
|
a.tableNames.UserSessions, a.tableNames.Users))
|
|
return db.QueryRowContext(ctx, query, oldToken, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &ipAddress, &userAgent, &programUserID, &programUserTable)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("Invalid or expired refresh token")
|
|
}
|
|
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
|
}
|
|
|
|
newToken, err := generateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
now := time.Now()
|
|
expiresAt := now.Add(24 * time.Hour)
|
|
|
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
a.tableNames.UserSessions))
|
|
if _, err := db.ExecContext(ctx, insertQuery, newToken, userID, expiresAt, ipAddress.String, userAgent.String, now, now); err != nil {
|
|
return err
|
|
}
|
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
|
|
_, err := db.ExecContext(ctx, delQuery, oldToken)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh token generation failed: %w", err)
|
|
}
|
|
|
|
userCtx := &UserContext{
|
|
UserID: userID,
|
|
UserName: username.String,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
SessionID: newToken,
|
|
Roles: parseRoles(roles.String),
|
|
ProgramUserID: int(programUserID.Int64),
|
|
ProgramUserTable: programUserTable.String,
|
|
}
|
|
|
|
return &LoginResponse{
|
|
Token: newToken,
|
|
User: userCtx,
|
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) requestPasswordResetDirect(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
|
|
if req.Email == "" && req.Username == "" {
|
|
return nil, fmt.Errorf("email or username is required")
|
|
}
|
|
|
|
var userID int
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
var query string
|
|
var arg string
|
|
if req.Email != "" {
|
|
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ? AND is_active = ?`, a.tableNames.Users))
|
|
arg = req.Email
|
|
} else {
|
|
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
|
|
arg = req.Username
|
|
}
|
|
return db.QueryRowContext(ctx, query, arg, true).Scan(&userID)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// Return generic success even when user not found to avoid user enumeration.
|
|
return &PasswordResetResponse{Token: "", ExpiresIn: 0}, nil
|
|
}
|
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
|
}
|
|
|
|
rawBytes := make([]byte, 32)
|
|
if _, err := rand.Read(rawBytes); err != nil {
|
|
return nil, fmt.Errorf("failed to generate reset token: %w", err)
|
|
}
|
|
rawToken := hex.EncodeToString(rawBytes)
|
|
hash := sha256.Sum256([]byte(rawToken))
|
|
tokenHash := hex.EncodeToString(hash[:])
|
|
now := time.Now()
|
|
expiresAt := now.Add(1 * time.Hour)
|
|
|
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND used = ?`, a.tableNames.UserPasswordResets))
|
|
if _, err := db.ExecContext(ctx, delQuery, userID, false); err != nil {
|
|
return err
|
|
}
|
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, token_hash, expires_at, created_at, used) VALUES (?, ?, ?, ?, ?)`, a.tableNames.UserPasswordResets))
|
|
_, err := db.ExecContext(ctx, insQuery, userID, tokenHash, expiresAt, now, false)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
|
}
|
|
|
|
return &PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) completePasswordResetDirect(ctx context.Context, req PasswordResetCompleteRequest) error {
|
|
if req.Token == "" {
|
|
return fmt.Errorf("token is required")
|
|
}
|
|
if req.NewPassword == "" {
|
|
return fmt.Errorf("new_password is required")
|
|
}
|
|
|
|
hash := sha256.Sum256([]byte(req.Token))
|
|
tokenHash := hex.EncodeToString(hash[:])
|
|
|
|
var resetID, userID int
|
|
var expiresAt time.Time
|
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, user_id, expires_at FROM %s WHERE token_hash = ? AND used = ?`, a.tableNames.UserPasswordResets))
|
|
return db.QueryRowContext(ctx, query, tokenHash, false).Scan(&resetID, &userID, &expiresAt)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("invalid or expired token")
|
|
}
|
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
|
}
|
|
if !expiresAt.After(time.Now()) {
|
|
return fmt.Errorf("invalid or expired token")
|
|
}
|
|
|
|
now := time.Now()
|
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
|
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET password = ?, updated_at = ? WHERE id = ?`, a.tableNames.Users))
|
|
if _, err := db.ExecContext(ctx, updUser, req.NewPassword, now, userID); err != nil {
|
|
return err
|
|
}
|
|
delSessions := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, a.tableNames.UserSessions))
|
|
if _, err := db.ExecContext(ctx, delSessions, userID); err != nil {
|
|
return err
|
|
}
|
|
updReset := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, a.tableNames.UserPasswordResets))
|
|
_, err := db.ExecContext(ctx, updReset, true, now, resetID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// claimStrings extracts ip_address/user_agent from a request's Claims map, mirroring
|
|
// p_request->'claims'->>'ip_address' / 'user_agent' in the plpgsql procedures.
|
|
func claimStrings(claims map[string]any) (ipAddress, userAgent string) {
|
|
if claims == nil {
|
|
return "", ""
|
|
}
|
|
if v, ok := claims["ip_address"].(string); ok {
|
|
ipAddress = v
|
|
}
|
|
if v, ok := claims["user_agent"].(string); ok {
|
|
userAgent = v
|
|
}
|
|
return ipAddress, userAgent
|
|
}
|
|
|
|
// jwtLoginDirect mirrors resolvespec_jwt_login.
|
|
func (a *JWTAuthenticator) jwtLoginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
var userID int
|
|
var email, roles sql.NullString
|
|
var userLevel sql.NullInt64
|
|
|
|
runQuery := func() error {
|
|
query := rewritePlaceholders(a.getDB(), fmt.Sprintf(`SELECT id, email, user_level, roles FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
|
|
return a.getDB().QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles)
|
|
}
|
|
err := runQuery()
|
|
if isDBClosed(err) {
|
|
if reconnErr := a.reconnectDB(); reconnErr == nil {
|
|
err = runQuery()
|
|
}
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
tokenString := fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix())
|
|
|
|
return &LoginResponse{
|
|
Token: tokenString,
|
|
User: &UserContext{
|
|
UserID: userID,
|
|
UserName: req.Username,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
Roles: parseRoles(roles.String),
|
|
},
|
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// jwtLogoutDirect mirrors resolvespec_jwt_logout (adds token to the blacklist table).
|
|
func (a *JWTAuthenticator) jwtLogoutDirect(ctx context.Context, req LogoutRequest) error {
|
|
db := a.getDB()
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
query := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, a.tableNames.TokenBlacklist))
|
|
_, err := db.ExecContext(ctx, query, req.Token, req.UserID, expiresAt, time.Now())
|
|
if err != nil {
|
|
return fmt.Errorf("logout query failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|