129 lines
4.3 KiB
Go
129 lines
4.3 KiB
Go
// Package postgres implements IAM's domain repository interfaces against
|
|
// the iam database (see services/iam/migrations/0001_init.sql). This is
|
|
// a one-for-one transplant of the monolith's internal/db/users.go, split
|
|
// across files by aggregate instead of living in one package shared
|
|
// with every other table in the old schema.
|
|
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"enkly/pkg/pgkit"
|
|
"enkly/services/iam/internal/domain"
|
|
)
|
|
|
|
const userColumns = `id, email, password_hash, email_verified, created_at`
|
|
|
|
func scanUser(row interface{ Scan(...any) error }) (*domain.User, error) {
|
|
var u domain.User
|
|
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.EmailVerified, &u.CreatedAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
// UserRepo implements domain.UserRepository.
|
|
type UserRepo struct {
|
|
DB *pgkit.DB
|
|
}
|
|
|
|
func NewUserRepo(db *pgkit.DB) *UserRepo { return &UserRepo{DB: db} }
|
|
|
|
// Create inserts a new, unverified account. email must already be
|
|
// normalized (trimmed + lower-cased) by the application layer;
|
|
// passwordHash is the output of authsupport.HashPassword.
|
|
func (r *UserRepo) Create(ctx context.Context, email, passwordHash string) (*domain.User, error) {
|
|
u, err := scanUser(r.DB.QueryRowContext(ctx,
|
|
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING `+userColumns,
|
|
email, passwordHash))
|
|
if err != nil {
|
|
if pgkit.IsUniqueViolation(err) {
|
|
return nil, domain.ErrEmailTaken
|
|
}
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
return scanUser(r.DB.QueryRowContext(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE email = $1`, email))
|
|
}
|
|
|
|
func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
|
|
return scanUser(r.DB.QueryRowContext(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE id = $1`, id))
|
|
}
|
|
|
|
func (r *UserRepo) MarkEmailVerified(ctx context.Context, userID int64) error {
|
|
_, err := r.DB.ExecContext(ctx, `UPDATE users SET email_verified = true WHERE id = $1`, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *UserRepo) UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error {
|
|
_, err := r.DB.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash)
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Sessions
|
|
// ---------------------------------------------------------------------
|
|
|
|
// SessionRepo implements domain.SessionRepository.
|
|
type SessionRepo struct {
|
|
DB *pgkit.DB
|
|
}
|
|
|
|
func NewSessionRepo(db *pgkit.DB) *SessionRepo { return &SessionRepo{DB: db} }
|
|
|
|
func (r *SessionRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
|
|
_, err := r.DB.ExecContext(ctx,
|
|
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
|
userID, tokenHash, expiresAt)
|
|
return err
|
|
}
|
|
|
|
func (r *SessionRepo) GetUserByTokenHash(ctx context.Context, tokenHash string) (*domain.User, error) {
|
|
return scanUser(r.DB.QueryRowContext(ctx, `
|
|
SELECT u.id, u.email, u.password_hash, u.email_verified, u.created_at
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = $1 AND s.expires_at > now()`, tokenHash))
|
|
}
|
|
|
|
func (r *SessionRepo) Delete(ctx context.Context, tokenHash string) error {
|
|
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash)
|
|
return err
|
|
}
|
|
|
|
func (r *SessionRepo) DeleteAllForUser(ctx context.Context, userID int64) error {
|
|
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID)
|
|
return err
|
|
}
|
|
|
|
// CleanupExpired deletes expired sessions and used/expired verification
|
|
// and password-reset tokens, mirroring internal/db.DB.CleanupExpired —
|
|
// run periodically by cmd/server's cleanup loop (see main.go).
|
|
func CleanupExpired(ctx context.Context, db *pgkit.DB) (int64, error) {
|
|
var total int64
|
|
for _, q := range []string{
|
|
`DELETE FROM sessions WHERE expires_at < now()`,
|
|
`DELETE FROM email_verification_codes WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
|
|
`DELETE FROM password_reset_tokens WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
|
|
} {
|
|
res, err := db.ExecContext(ctx, q)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
total += n
|
|
}
|
|
return total, nil
|
|
}
|