package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) // ------------------------------------------------------------------ // Domain types // ------------------------------------------------------------------ // User represents an authenticated user belonging to a tenant. type User struct { ID string `json:"id"` TenantID string `json:"tenant_id"` TenantSlug string `json:"tenant_slug"` Username string `json:"username"` PasswordHash string `json:"-"` Role string `json:"role"` CreatedAt time.Time `json:"created_at"` } // Session represents an authenticated session token. type Session struct { ID string `json:"id"` UserID string `json:"user_id"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } // ------------------------------------------------------------------ // AuthStore // ------------------------------------------------------------------ // AuthStore handles user authentication and session management. type AuthStore struct{ pool *pgxpool.Pool } // NewAuthStore creates a new AuthStore backed by pool. func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} } // GetUserByUsername returns the user with the given username or pgx.ErrNoRows. // TenantSlug is populated via LEFT JOIN on tenants. func (s *AuthStore) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := s.pool.QueryRow(ctx, `select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at from users u left join tenants t on t.id = u.tenant_id where u.username = $1`, username) return scanUserWithSlug(row) } // CreateSession inserts a new session for userID with the given TTL and returns the session. func (s *AuthStore) CreateSession(ctx context.Context, userID string, ttl time.Duration) (*Session, error) { expiresAt := time.Now().Add(ttl) row := s.pool.QueryRow(ctx, `insert into sessions(user_id, expires_at) values($1, $2) returning id, user_id, created_at, expires_at`, userID, expiresAt) return scanSession(row) } // GetSessionUser returns the user associated with sessionID if the session is still valid. // Returns pgx.ErrNoRows when the session does not exist or has expired. // TenantSlug is populated via JOIN on tenants. func (s *AuthStore) GetSessionUser(ctx context.Context, sessionID string) (*User, error) { row := s.pool.QueryRow(ctx, `select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at from sessions se join users u on u.id = se.user_id left join tenants t on t.id = u.tenant_id where se.id = $1 and se.expires_at > now()`, sessionID) return scanUserWithSlug(row) } // DeleteSession removes the session with the given ID. func (s *AuthStore) DeleteSession(ctx context.Context, sessionID string) error { _, err := s.pool.Exec(ctx, `delete from sessions where id = $1`, sessionID) return err } // CleanExpiredSessions removes all sessions whose expires_at is in the past. func (s *AuthStore) CleanExpiredSessions(ctx context.Context) error { _, err := s.pool.Exec(ctx, `delete from sessions where expires_at <= now()`) return err } // VerifyPassword checks if the provided password matches the hashed password for a user. func (s *AuthStore) VerifyPassword(ctx context.Context, userID, password string) (bool, error) { var passwordHash string err := s.pool.QueryRow(ctx, `select password_hash from users where id = $1`, userID). Scan(&passwordHash) if err != nil { if err == pgx.ErrNoRows { return false, nil } return false, fmt.Errorf("auth: get password hash: %w", err) } err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) return err == nil, nil } // EnsureAdminUser creates an 'admin' user for the tenant identified by tenantSlug // if no user with username 'admin' already exists. The password is hashed with bcrypt. // bcrypt cost factor 12 is used (minimum recommended for production). func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error { // Check whether 'admin' user already exists for this tenant. var exists bool err := s.pool.QueryRow(ctx, `select exists(select 1 from users where username = $1)`, "admin", ).Scan(&exists) if err != nil { return fmt.Errorf("auth: check admin user: %w", err) } if exists { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return fmt.Errorf("auth: hash password: %w", err) } _, err = s.pool.Exec(ctx, `insert into users(tenant_id, username, password_hash, role) values( (select id from tenants where slug = $1), 'admin', $2, 'admin' )`, tenantSlug, string(hash)) if err != nil { return fmt.Errorf("auth: create admin user: %w", err) } return nil } // ------------------------------------------------------------------ // scan helpers // ------------------------------------------------------------------ func scanUser(row interface { Scan(dest ...any) error }) (*User, error) { var u User err := row.Scan(&u.ID, &u.TenantID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) if err != nil { if err == pgx.ErrNoRows { return nil, pgx.ErrNoRows } return nil, fmt.Errorf("scan user: %w", err) } return &u, nil } // scanUserWithSlug scans a row that includes tenant_slug as the third column. func scanUserWithSlug(row interface { Scan(dest ...any) error }) (*User, error) { var u User err := row.Scan(&u.ID, &u.TenantID, &u.TenantSlug, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) if err != nil { if err == pgx.ErrNoRows { return nil, pgx.ErrNoRows } return nil, fmt.Errorf("scan user: %w", err) } return &u, nil } func scanSession(row interface { Scan(dest ...any) error }) (*Session, error) { var se Session err := row.Scan(&se.ID, &se.UserID, &se.CreatedAt, &se.ExpiresAt) if err != nil { if err == pgx.ErrNoRows { return nil, pgx.ErrNoRows } return nil, fmt.Errorf("scan session: %w", err) } return &se, nil }