Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
443 lines
14 KiB
Go
443 lines
14 KiB
Go
// Package userstore is a PostgreSQL-backed user account store, ported from
|
|
// archivmail's internal/userstore pattern. LDAP and TOTP are intentionally
|
|
// left out of this initial scaffold (can be re-added later following the
|
|
// same pattern archivmail uses) — archivdms starts with local password auth
|
|
// only.
|
|
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
RoleUser = "user"
|
|
RoleDomainAdmin = "domain_admin"
|
|
RoleSuperAdmin = "superadmin"
|
|
|
|
// AuthSourceLocal / AuthSourceLDAP are the values of users.auth_source.
|
|
AuthSourceLocal = "local"
|
|
AuthSourceLDAP = "ldap"
|
|
|
|
bcryptCost = 12
|
|
|
|
// dummyBcryptHash burns bcrypt time on "user not found" so that the login
|
|
// endpoint does not leak identifier existence via timing (ported from
|
|
// archivmail's VerifyLogin hardening).
|
|
dummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa"
|
|
)
|
|
|
|
// User represents a user account.
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Active bool `json:"active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
TenantID *int64 `json:"tenant_id,omitempty"`
|
|
}
|
|
|
|
// CreateUserRequest holds parameters for creating a new user.
|
|
type CreateUserRequest struct {
|
|
Username string
|
|
Email string
|
|
Password string
|
|
Role string
|
|
TenantID *int64
|
|
}
|
|
|
|
// UpdateUserRequest holds optional fields for updating a user.
|
|
type UpdateUserRequest struct {
|
|
Email *string
|
|
Role *string
|
|
Active *bool
|
|
Password *string
|
|
}
|
|
|
|
// Store is a PostgreSQL-backed user store.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// New connects to PostgreSQL using the given DSN and initialises the schema.
|
|
func New(dsn string) (*Store, error) {
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: connect: %w", err)
|
|
}
|
|
s := &Store{pool: pool}
|
|
if err := s.initSchema(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("userstore: init schema: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) initSchema(ctx context.Context) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username VARCHAR(100) UNIQUE NOT NULL,
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
|
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_login_at TIMESTAMPTZ,
|
|
tenant_id BIGINT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS token_blacklist (
|
|
jti VARCHAR(255) PRIMARY KEY,
|
|
expires_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users (tenant_id);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// Close closes the underlying connection pool.
|
|
func (s *Store) Close() error {
|
|
s.pool.Close()
|
|
return nil
|
|
}
|
|
|
|
// Create inserts a new local user with a bcrypt-hashed password.
|
|
func (s *Store) Create(req CreateUserRequest) (*User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
|
|
}
|
|
ctx := context.Background()
|
|
var id int64
|
|
err = s.pool.QueryRow(ctx,
|
|
`INSERT INTO users (username, email, password_hash, role, active, created_at, tenant_id)
|
|
VALUES ($1, $2, $3, $4, true, NOW(), $5) RETURNING id`,
|
|
req.Username, req.Email, string(hash), req.Role, req.TenantID,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: create: %w", err)
|
|
}
|
|
return s.GetByID(id)
|
|
}
|
|
|
|
// GetByID retrieves a user by their numeric ID.
|
|
func (s *Store) GetByID(id int64) (*User, error) {
|
|
ctx := context.Background()
|
|
row := s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id FROM users WHERE id = $1`, id)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetByUsername retrieves a user by their username.
|
|
func (s *Store) GetByUsername(username string) (*User, error) {
|
|
ctx := context.Background()
|
|
row := s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id FROM users WHERE username = $1`, username)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetByEmail retrieves a user by email address.
|
|
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id FROM users WHERE email = $1`, email)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// VerifyLogin checks credentials by email (falling back to username for
|
|
// non-tenant users) and returns the user on success.
|
|
func (s *Store) VerifyLogin(ctx context.Context, identifier, password string) (*User, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id, password_hash FROM users WHERE email = $1`,
|
|
identifier,
|
|
)
|
|
u, hash, err := scanUserWithHash(row)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("userstore: verify login (email): %w", err)
|
|
}
|
|
row = s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id, password_hash
|
|
FROM users WHERE username = $1 AND tenant_id IS NULL`,
|
|
identifier,
|
|
)
|
|
u, hash, err = scanUserWithHash(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
|
return nil, errors.New("userstore: user not found")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: verify login (username): %w", err)
|
|
}
|
|
}
|
|
if !u.Active {
|
|
return nil, errors.New("userstore: account disabled")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
|
return nil, errors.New("userstore: wrong password")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// ErrUserNotFound is returned by FindForLogin when no matching account exists.
|
|
var ErrUserNotFound = errors.New("userstore: user not found")
|
|
|
|
// LoginRecord is the minimal set of fields the login flow needs: the user, the
|
|
// bcrypt hash (empty for LDAP accounts) and the auth_source discriminator.
|
|
type LoginRecord struct {
|
|
User *User
|
|
Hash string
|
|
AuthSource string
|
|
}
|
|
|
|
// FindForLogin resolves a login identifier (email, or username for a
|
|
// tenant-less account) to a LoginRecord, returning ErrUserNotFound when no
|
|
// account matches. It performs the SAME lookup precedence as VerifyLogin but,
|
|
// crucially, does not itself verify the password — password/LDAP verification
|
|
// is orchestrated by the auth manager based on AuthSource.
|
|
func (s *Store) FindForLogin(ctx context.Context, identifier string) (*LoginRecord, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id, password_hash, auth_source
|
|
FROM users WHERE email = $1`, identifier)
|
|
rec, err := scanLoginRecord(row)
|
|
if err == nil {
|
|
return rec, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("userstore: find for login (email): %w", err)
|
|
}
|
|
row = s.pool.QueryRow(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id, password_hash, auth_source
|
|
FROM users WHERE username = $1 AND tenant_id IS NULL`, identifier)
|
|
rec, err = scanLoginRecord(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: find for login (username): %w", err)
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// BurnPasswordTiming performs a throwaway bcrypt comparison to keep the "user
|
|
// not found" / "LDAP disabled" paths timing-indistinguishable from a real
|
|
// local password check (see dummyBcryptHash).
|
|
func (s *Store) BurnPasswordTiming(password string) {
|
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
|
}
|
|
|
|
// CompareLocalPassword verifies a plaintext password against a bcrypt hash.
|
|
func CompareLocalPassword(hash, password string) error {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
}
|
|
|
|
// LDAPUserRequest holds attributes for JIT-provisioning an LDAP user.
|
|
type LDAPUserRequest struct {
|
|
Username string
|
|
Email string
|
|
Role string
|
|
LdapUID string
|
|
TenantID *int64
|
|
}
|
|
|
|
// CreateLDAPUser inserts a new LDAP-backed user (auth_source='ldap',
|
|
// empty password_hash). Used by just-in-time provisioning on first LDAP login.
|
|
func (s *Store) CreateLDAPUser(ctx context.Context, req LDAPUserRequest) (*User, error) {
|
|
var id int64
|
|
err := s.pool.QueryRow(ctx,
|
|
`INSERT INTO users (username, email, password_hash, role, active, created_at, tenant_id,
|
|
auth_source, ldap_uid, ldap_synced_at)
|
|
VALUES ($1, $2, '', $3, true, NOW(), $4, 'ldap', $5, NOW()) RETURNING id`,
|
|
req.Username, req.Email, req.Role, req.TenantID, req.LdapUID,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: create ldap user: %w", err)
|
|
}
|
|
return s.GetByID(id)
|
|
}
|
|
|
|
// SyncLDAPUser re-synchronises the mutable attributes of an existing LDAP user
|
|
// on each login (email, display role — downgrades included). auth_source and
|
|
// ldap_uid are never changed here.
|
|
func (s *Store) SyncLDAPUser(ctx context.Context, id int64, email, role string) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`UPDATE users SET email = $1, role = $2, ldap_synced_at = NOW()
|
|
WHERE id = $3 AND auth_source = 'ldap'`,
|
|
email, role, id)
|
|
if err != nil {
|
|
return fmt.Errorf("userstore: sync ldap user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Update applies a partial update to a user record.
|
|
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
|
|
ctx := context.Background()
|
|
if req.Email != nil {
|
|
if _, err := s.pool.Exec(ctx, `UPDATE users SET email = $1 WHERE id = $2`, *req.Email, id); err != nil {
|
|
return nil, fmt.Errorf("userstore: update email: %w", err)
|
|
}
|
|
}
|
|
if req.Role != nil {
|
|
if _, err := s.pool.Exec(ctx, `UPDATE users SET role = $1 WHERE id = $2`, *req.Role, id); err != nil {
|
|
return nil, fmt.Errorf("userstore: update role: %w", err)
|
|
}
|
|
}
|
|
if req.Active != nil {
|
|
if _, err := s.pool.Exec(ctx, `UPDATE users SET active = $1 WHERE id = $2`, *req.Active, id); err != nil {
|
|
return nil, fmt.Errorf("userstore: update active: %w", err)
|
|
}
|
|
}
|
|
if req.Password != nil {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcryptCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
|
|
}
|
|
if _, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), id); err != nil {
|
|
return nil, fmt.Errorf("userstore: update password: %w", err)
|
|
}
|
|
}
|
|
return s.GetByID(id)
|
|
}
|
|
|
|
// Delete removes a user by ID.
|
|
func (s *Store) Delete(id int64) error {
|
|
ctx := context.Background()
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("userstore: delete: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("userstore: user %d not found", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns all users, optionally filtered by role. Pass role="" to list all.
|
|
func (s *Store) List(role string) ([]*User, error) {
|
|
ctx := context.Background()
|
|
var rows pgx.Rows
|
|
var err error
|
|
if role == "" {
|
|
rows, err = s.pool.Query(ctx, `SELECT id, username, email, role, active, created_at, tenant_id FROM users ORDER BY id`)
|
|
} else {
|
|
rows, err = s.pool.Query(ctx, `SELECT id, username, email, role, active, created_at, tenant_id FROM users WHERE role = $1 ORDER BY id`, role)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
users := make([]*User, 0)
|
|
for rows.Next() {
|
|
u, err := scanUserRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
// ListByTenant returns all users belonging to a specific tenant.
|
|
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT id, username, email, role, active, created_at, tenant_id FROM users WHERE tenant_id = $1 ORDER BY id`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: list by tenant: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
users := make([]*User, 0)
|
|
for rows.Next() {
|
|
u, err := scanUserRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
// BlacklistToken adds a JWT ID to the token blacklist.
|
|
func (s *Store) BlacklistToken(jti string, expires time.Time) error {
|
|
ctx := context.Background()
|
|
_, err := s.pool.Exec(ctx,
|
|
`INSERT INTO token_blacklist (jti, expires_at) VALUES ($1, $2)
|
|
ON CONFLICT (jti) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
|
|
jti, expires.UTC(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// IsBlacklisted returns true if the given JTI is in the blacklist.
|
|
func (s *Store) IsBlacklisted(jti string) (bool, error) {
|
|
ctx := context.Background()
|
|
var count int
|
|
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM token_blacklist WHERE jti = $1`, jti).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
// UpdateLastLogin sets last_login_at to now for the given user.
|
|
func (s *Store) UpdateLastLogin(id int64) error {
|
|
ctx := context.Background()
|
|
_, err := s.pool.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// AdminCount returns the number of active privileged users.
|
|
func (s *Store) AdminCount() (int, error) {
|
|
ctx := context.Background()
|
|
var count int
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM users WHERE role IN ('domain_admin','superadmin') AND active = true`).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func scanUser(row pgx.Row) (*User, error) {
|
|
var u User
|
|
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Active, &u.CreatedAt, &u.TenantID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("userstore: not found")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userstore: scan: %w", err)
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func scanUserWithHash(row pgx.Row) (*User, string, error) {
|
|
var u User
|
|
var hash string
|
|
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Active, &u.CreatedAt, &u.TenantID, &hash)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return &u, hash, nil
|
|
}
|
|
|
|
func scanLoginRecord(row pgx.Row) (*LoginRecord, error) {
|
|
var u User
|
|
var hash, authSource string
|
|
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Active, &u.CreatedAt, &u.TenantID, &hash, &authSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &LoginRecord{User: &u, Hash: hash, AuthSource: authSource}, nil
|
|
}
|
|
|
|
func scanUserRow(rows pgx.Rows) (*User, error) {
|
|
var u User
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Active, &u.CreatedAt, &u.TenantID); err != nil {
|
|
return nil, fmt.Errorf("userstore: scan row: %w", err)
|
|
}
|
|
return &u, nil
|
|
}
|