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 }