first commit
This commit is contained in:
142
go-api/internal/auth/users.go
Normal file
142
go-api/internal/auth/users.go
Normal file
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user