szabadpalya/api/internal/store/auth.go

93 lines
2.7 KiB
Go

package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/szabadpalya/api/internal/store/dbgen"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
// UpsertUser finds or creates the account behind an email address. Requesting
// a magic link is what creates an account; there is no separate signup.
func (s *Store) UpsertUser(ctx context.Context, email string) (*User, error) {
r, err := s.q.UpsertUser(ctx, dbgen.UpsertUserParams{ID: NewID(), Email: email})
if err != nil {
return nil, err
}
return &User{ID: r.ID, Email: r.Email, Name: r.Name, IsAdmin: r.IsAdmin}, nil
}
// CreateVerification stores a hashed magic-link token. The plaintext never
// reaches the database, so a dump cannot be replayed into a login.
func (s *Store) CreateVerification(ctx context.Context, identifier, hashed string, expiresAt time.Time) error {
return s.q.CreateVerification(ctx, dbgen.CreateVerificationParams{
ID: NewID(),
Identifier: identifier,
Value: hashed,
ExpiresAt: expiresAt,
})
}
// ConsumeVerification redeems a token exactly once. The single UPDATE is what
// makes it single-use: a second request finds consumed_at already set and gets
// no rows back, even if both arrive at the same instant.
func (s *Store) ConsumeVerification(ctx context.Context, hashed string) (string, error) {
identifier, err := s.q.ConsumeVerification(ctx, hashed)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return identifier, err
}
func (s *Store) CreateSession(ctx context.Context, userID, hashed string, expiresAt time.Time, ip, ua string) error {
return s.q.CreateSession(ctx, dbgen.CreateSessionParams{
ID: NewID(),
Token: hashed,
ExpiresAt: expiresAt,
UserID: userID,
IpAddress: nullable(ip),
UserAgent: nullable(ua),
})
}
func (s *Store) UserBySessionToken(ctx context.Context, hashed string) (*User, error) {
r, err := s.q.UserBySessionToken(ctx, hashed)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &User{ID: r.ID, Email: r.Email, Name: r.Name, IsAdmin: r.IsAdmin}, nil
}
func (s *Store) DeleteSession(ctx context.Context, hashed string) error {
return s.q.DeleteSession(ctx, hashed)
}
// PurgeExpiredAuth keeps the two token tables from growing without bound.
// Expired rows are already refused on read, so this is housekeeping only.
func (s *Store) PurgeExpiredAuth(ctx context.Context) error {
if err := s.q.PurgeExpiredSessions(ctx); err != nil {
return err
}
return s.q.PurgeExpiredVerifications(ctx)
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}