143 lines
5.2 KiB
Go
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
|
|
}
|