Files
krow_backend/go-api/internal/auth/users.go
2026-08-24 13:06:29 +05:30

143 lines
5.2 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrUserNotFound means no user matched. Callers authenticating someone must
// answer this identically to a wrong password — see the note on Credentials.
var ErrUserNotFound = errors.New("auth: user not found")
// StatusActive is the only users.status that may hold a session. The column's
// CHECK constraint (migration 000001) permits 'active' and 'suspended'.
const StatusActive = "active"
// User is the part of a users row that authentication needs.
//
// Note what is absent: preferences, timestamps, legacy ids. This is not a
// general user model — the resource layer already has one — it is the set of
// facts required to answer "may this person hold a session, and whose data do
// they see".
type User struct {
ID string
OrgID string
Email string
FullName string
Role string
AccountType string
Status string
// PasswordHash is empty when the user has never had a password set.
// migration 000001 leaves the column nullable and NULL, and the seeded
// demo user is in exactly that state until `setpassword` is run.
PasswordHash string
}
// IsActive reports whether this user may authenticate or hold a session.
func (u User) IsActive() bool { return u.Status == StatusActive }
// CanAuthenticate reports whether a password check is even possible. A user
// with no password hash cannot sign in, and must be refused in the same way
// and with the same timing as a wrong password.
func (u User) CanAuthenticate() bool { return u.IsActive() && u.PasswordHash != "" }
// UserStore is the read side of authentication.
//
// Deliberately read-only apart from MarkLoggedIn: creating and editing users is
// the resource layer's job, and nothing in the sign-in path should be able to
// write a role, a status or an organization.
type UserStore interface {
// FindByEmail resolves the login identifier. Email is citext and globally
// unique (migration 000004), so this returns at most one row.
FindByEmail(ctx context.Context, email string) (User, error)
// FindByID resolves the user behind a session on every request.
FindByID(ctx context.Context, id string) (User, error)
// MarkLoggedIn records a successful sign-in.
MarkLoggedIn(ctx context.Context, id string, at time.Time) error
}
// PGUserStore reads users from PostgreSQL.
type PGUserStore struct {
db Querier
}
// NewPGUserStore builds the store over a pool or a transaction.
func NewPGUserStore(db Querier) *PGUserStore { return &PGUserStore{db: db} }
var _ UserStore = (*PGUserStore)(nil)
// userColumns is the projection both lookups share.
//
// password_hash is COALESCEd to the empty string rather than scanned into a
// *string: a NULL hash and an empty hash mean the same thing here — no password
// is set — and collapsing them at the edge means no caller has to remember to
// nil-check before handing the value to VerifyPassword.
const userColumns = `id::text, org_id::text, email::text, full_name,
role, account_type, status, COALESCE(password_hash, '')`
func scanUser(row pgx.Row) (User, error) {
var u User
err := row.Scan(&u.ID, &u.OrgID, &u.Email, &u.FullName,
&u.Role, &u.AccountType, &u.Status, &u.PasswordHash)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, err
}
return u, nil
}
// FindByEmail looks a user up by their login identifier.
//
// The comparison is against the citext column, so it is case-insensitive:
// "Demo@Krow.app" finds the same row as "demo@krow.app", which is what a person
// typing their own address at a login form expects. Trimming is the caller's
// job and is done in the handler, where the raw input is.
func (s *PGUserStore) FindByEmail(ctx context.Context, email string) (User, error) {
if email == "" {
return User{}, ErrUserNotFound
}
const q = `SELECT ` + userColumns + ` FROM users WHERE email = $1::citext`
u, err := scanUser(s.db.QueryRow(ctx, q, email))
if err != nil && !errors.Is(err, ErrUserNotFound) {
return User{}, fmt.Errorf("auth: find user by email: %w", err)
}
return u, err
}
// FindByID resolves the user behind a session.
//
// This runs on every authenticated request, which is why status is read here
// rather than cached in the session row: suspending an account must take effect
// on the next request, not whenever the session happens to expire.
func (s *PGUserStore) FindByID(ctx context.Context, id string) (User, error) {
if id == "" {
return User{}, ErrUserNotFound
}
const q = `SELECT ` + userColumns + ` FROM users WHERE id = $1::uuid`
u, err := scanUser(s.db.QueryRow(ctx, q, id))
if err != nil && !errors.Is(err, ErrUserNotFound) {
return User{}, fmt.Errorf("auth: find user by id: %w", err)
}
return u, err
}
// MarkLoggedIn stamps last_login_at.
//
// Deliberately not part of the transaction that creates the session: a failure
// to record the timestamp is a lost diagnostic, not a reason to refuse a
// sign-in that has already succeeded on its merits.
func (s *PGUserStore) MarkLoggedIn(ctx context.Context, id string, at time.Time) error {
const q = `UPDATE users SET last_login_at = $2::timestamptz WHERE id = $1::uuid`
if _, err := s.db.Exec(ctx, q, id, at); err != nil {
return fmt.Errorf("auth: mark logged in: %w", err)
}
return nil
}