mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-05 13:01:58 +00:00
refactor(security): move all database access into pkg/security/lookup
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
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package procedure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
)
|
||||
|
||||
// Auth implements lookup.AuthStore with stored procedures.
|
||||
type Auth struct {
|
||||
run Runner
|
||||
procs lookup.ProcNames
|
||||
}
|
||||
|
||||
var _ lookup.AuthStore = (*Auth)(nil)
|
||||
|
||||
// NewAuth creates the procedure-backed AuthStore.
|
||||
func NewAuth(run Runner, procs lookup.ProcNames) *Auth { return &Auth{run: run, procs: procs} }
|
||||
|
||||
// callData runs "SELECT p_success, p_error, p_data::text FROM proc($1::jsonb)".
|
||||
func (a *Auth) callData(ctx context.Context, proc, queryErrOp string, arg any) (sql.NullString, error) {
|
||||
var success bool
|
||||
var errorMsg, dataJSON sql.NullString
|
||||
err := a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_data::text FROM %s($1::jsonb)`, proc) //nolint:gosec // G201: identifier comes from validated config
|
||||
return db.QueryRowContext(ctx, query, arg).Scan(&success, &errorMsg, &dataJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return sql.NullString{}, fmt.Errorf("%s query failed: %w", queryErrOp, err)
|
||||
}
|
||||
if !success {
|
||||
return sql.NullString{}, failure(errorMsg, queryErrOp+" failed")
|
||||
}
|
||||
return dataJSON, nil
|
||||
}
|
||||
|
||||
// Login implements lookup.AuthStore.
|
||||
func (a *Auth) Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
||||
reqJSON, err := json.Marshal(req) //nolint:gosec // G117: intentional: field must be serialized
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal login request: %w", err)
|
||||
}
|
||||
data, err := a.callData(ctx, a.procs.Login, "login", string(reqJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sectypes.LoginResponse
|
||||
if err := json.Unmarshal([]byte(data.String), &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse login response: %w", err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Register implements lookup.AuthStore.
|
||||
func (a *Auth) Register(ctx context.Context, req sectypes.RegisterRequest) (*sectypes.LoginResponse, error) {
|
||||
reqJSON, err := json.Marshal(req) //nolint:gosec // G117: intentional: field must be serialized
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal register request: %w", err)
|
||||
}
|
||||
var success bool
|
||||
var errorMsg, dataJSON sql.NullString
|
||||
err = a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_data::text FROM %s($1::jsonb)`, a.procs.Register)
|
||||
return db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg, &dataJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, failure(errorMsg, "registration failed")
|
||||
}
|
||||
var response sectypes.LoginResponse
|
||||
if err := json.Unmarshal([]byte(dataJSON.String), &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse register response: %w", err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Logout implements lookup.AuthStore.
|
||||
func (a *Auth) Logout(ctx context.Context, req sectypes.LogoutRequest) error {
|
||||
reqJSON, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal logout request: %w", err)
|
||||
}
|
||||
_, err = a.callData(ctx, a.procs.Logout, "logout", string(reqJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
// Session implements lookup.AuthStore.
|
||||
func (a *Auth) Session(ctx context.Context, token, reference string) (*sectypes.UserContext, error) {
|
||||
var success bool
|
||||
var errorMsg, userJSON sql.NullString
|
||||
err := a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user::text FROM %s($1, $2)`, a.procs.Session) //nolint:gosec // G701: identifier comes from trusted config, values are bound parameters
|
||||
return db.QueryRowContext(ctx, query, token, reference).Scan(&success, &errorMsg, &userJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, failure(errorMsg, "invalid or expired session")
|
||||
}
|
||||
if !userJSON.Valid {
|
||||
return nil, fmt.Errorf("no user data in session")
|
||||
}
|
||||
var user sectypes.UserContext
|
||||
if err := json.Unmarshal([]byte(userJSON.String), &user); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// TouchSession implements lookup.AuthStore.
|
||||
func (a *Auth) TouchSession(ctx context.Context, token string, user *sectypes.UserContext) error {
|
||||
userJSON, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var success bool
|
||||
var errorMsg, updatedUserJSON sql.NullString
|
||||
return a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user::text FROM %s($1, $2::jsonb)`, a.procs.SessionUpdate)
|
||||
return db.QueryRowContext(ctx, query, token, string(userJSON)).Scan(&success, &errorMsg, &updatedUserJSON) //nolint:gosec // G701: identifier comes from trusted config, values are bound parameters
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh implements lookup.AuthStore.
|
||||
func (a *Auth) Refresh(ctx context.Context, refreshToken string) (*sectypes.LoginResponse, error) {
|
||||
// Get the current session to pass to refresh.
|
||||
var success bool
|
||||
var errorMsg, userJSON sql.NullString
|
||||
err := a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user::text FROM %s($1, $2)`, a.procs.Session)
|
||||
return db.QueryRowContext(ctx, query, refreshToken, "refresh").Scan(&success, &errorMsg, &userJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, failure(errorMsg, "invalid refresh token")
|
||||
}
|
||||
|
||||
var newSuccess bool
|
||||
var newErrorMsg, newUserJSON sql.NullString
|
||||
err = a.run.Run(func(db *sql.DB) error {
|
||||
refreshQuery := fmt.Sprintf(`SELECT p_success, p_error, p_user::text FROM %s($1, $2::jsonb)`, a.procs.RefreshToken)
|
||||
return db.QueryRowContext(ctx, refreshQuery, refreshToken, userJSON).Scan(&newSuccess, &newErrorMsg, &newUserJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token generation failed: %w", err)
|
||||
}
|
||||
if !newSuccess {
|
||||
return nil, failure(newErrorMsg, "failed to refresh token")
|
||||
}
|
||||
|
||||
var userCtx sectypes.UserContext
|
||||
if err := json.Unmarshal([]byte(newUserJSON.String), &userCtx); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
||||
}
|
||||
|
||||
// A resolvespec_refresh_token implementation that issues its own rotating
|
||||
// refresh token (independent of the access/session token) returns it
|
||||
// under claims.refresh_token, since UserContext has no dedicated field
|
||||
// for it. Surface that into LoginResponse.RefreshToken so callers don't
|
||||
// need to reach into User.Claims themselves. claims.expires_in
|
||||
// (seconds) similarly overrides the default access-token ExpiresIn when
|
||||
// the procedure provides a real value. Implementations that don't set
|
||||
// these claims keep today's behavior unchanged (empty RefreshToken,
|
||||
// 24h ExpiresIn default).
|
||||
resp := §ypes.LoginResponse{
|
||||
Token: userCtx.SessionID, // New session token from stored procedure
|
||||
User: &userCtx,
|
||||
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||
}
|
||||
if rt, ok := userCtx.Claims["refresh_token"].(string); ok && rt != "" {
|
||||
resp.RefreshToken = rt
|
||||
}
|
||||
if expiresIn, ok := userCtx.Claims["expires_in"].(float64); ok && expiresIn > 0 {
|
||||
resp.ExpiresIn = int64(expiresIn)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// LoginAPIKey implements lookup.AuthStore. Unknown, expired and inactive keys all return
|
||||
// lookup.ErrInvalidAPIKey; the raw key is never logged.
|
||||
func (a *Auth) LoginAPIKey(ctx context.Context, rawKey string, claims map[string]any) (*sectypes.LoginResponse, error) {
|
||||
if rawKey == "" {
|
||||
return nil, lookup.ErrInvalidAPIKey
|
||||
}
|
||||
reqJSON, err := json.Marshal(map[string]any{"api_key": rawKey, "claims": claims})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal api key login request: %w", err)
|
||||
}
|
||||
var success bool
|
||||
var errorMsg, dataJSON sql.NullString
|
||||
err = a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_data::text FROM %s($1::jsonb)`, a.procs.LoginAPIKey)
|
||||
return db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg, &dataJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("api key login query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, lookup.ErrInvalidAPIKey
|
||||
}
|
||||
var response sectypes.LoginResponse
|
||||
if err := json.Unmarshal([]byte(dataJSON.String), &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse api key login response: %w", err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// JWTLogin implements lookup.AuthStore. The password is verified inside the procedure;
|
||||
// the hash is never returned. The token is a placeholder until JWT signing is wired in.
|
||||
func (a *Auth) JWTLogin(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var userJSON []byte
|
||||
err := a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user FROM %s($1, $2)`, a.procs.JWTLogin)
|
||||
return db.QueryRowContext(ctx, query, req.Username, req.Password).Scan(&success, &errorMsg, &userJSON)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, failure(errorMsg, "invalid credentials")
|
||||
}
|
||||
var user struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
UserLevel int `json:"user_level"`
|
||||
Roles string `json:"roles"`
|
||||
}
|
||||
if err := json.Unmarshal(userJSON, &user); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user data: %w", err)
|
||||
}
|
||||
roles := []string{}
|
||||
if user.Roles != "" {
|
||||
roles = strings.Split(user.Roles, ",")
|
||||
}
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
return §ypes.LoginResponse{
|
||||
Token: fmt.Sprintf("token_%d_%d", user.ID, expiresAt.Unix()),
|
||||
User: §ypes.UserContext{
|
||||
UserID: user.ID,
|
||||
UserName: user.Username,
|
||||
Email: user.Email,
|
||||
UserLevel: user.UserLevel,
|
||||
Roles: roles,
|
||||
},
|
||||
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JWTLogout implements lookup.AuthStore.
|
||||
func (a *Auth) JWTLogout(ctx context.Context, req sectypes.LogoutRequest) error {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
err := a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2)`, a.procs.JWTLogout)
|
||||
return db.QueryRowContext(ctx, query, req.Token, req.UserID).Scan(&success, &errorMsg)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("logout query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return failure(errorMsg, "logout failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetRequest implements lookup.AuthStore.
|
||||
func (a *Auth) ResetRequest(ctx context.Context, req sectypes.PasswordResetRequest) (*sectypes.PasswordResetResponse, error) {
|
||||
reqJSON, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal password reset request: %w", err)
|
||||
}
|
||||
data, err := a.callData(ctx, a.procs.PasswordResetRequest, "password reset request", string(reqJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sectypes.PasswordResetResponse
|
||||
if data.Valid && data.String != "" {
|
||||
if err := json.Unmarshal([]byte(data.String), &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse password reset response: %w", err)
|
||||
}
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ResetComplete implements lookup.AuthStore.
|
||||
func (a *Auth) ResetComplete(ctx context.Context, req sectypes.PasswordResetCompleteRequest) error {
|
||||
reqJSON, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal password reset complete request: %w", err)
|
||||
}
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
err = a.run.Run(func(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1::jsonb)`, a.procs.PasswordResetComplete)
|
||||
return db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("password reset complete query failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return failure(errorMsg, "password reset failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user