szabadpalya/api/internal/store/auth.go

99 lines
3.2 KiB
Go

package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
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) {
var u User
err := s.pool.QueryRow(ctx, `
INSERT INTO "User" ("id","email","emailVerified")
VALUES ($1,$2,true)
ON CONFLICT ("email") DO UPDATE
SET "emailVerified" = true, "updatedAt" = now()
RETURNING "id","email","name","isAdmin"`,
NewID(), email).Scan(&u.ID, &u.Email, &u.Name, &u.IsAdmin)
return &u, err
}
// 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 {
_, err := s.pool.Exec(ctx, `
INSERT INTO "Verification" ("id","identifier","value","expiresAt")
VALUES ($1,$2,$3,$4)`, NewID(), identifier, hashed, expiresAt)
return err
}
// ConsumeVerification redeems a token exactly once. The single UPDATE is what
// makes it single-use: a second request finds consumedAt 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) {
var identifier string
err := s.pool.QueryRow(ctx, `
UPDATE "Verification" SET "consumedAt" = now(), "updatedAt" = now()
WHERE "value" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now()
RETURNING "identifier"`, hashed).Scan(&identifier)
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 {
_, err := s.pool.Exec(ctx, `
INSERT INTO "Session" ("id","token","expiresAt","userId","ipAddress","userAgent")
VALUES ($1,$2,$3,$4,$5,$6)`,
NewID(), hashed, expiresAt, userID, nullable(ip), nullable(ua))
return err
}
func (s *Store) UserBySessionToken(ctx context.Context, hashed string) (*User, error) {
var u User
err := s.pool.QueryRow(ctx, `
SELECT u."id", u."email", u."name", u."isAdmin"
FROM "Session" s JOIN "User" u ON u."id" = s."userId"
WHERE s."token" = $1 AND s."expiresAt" > now()`, hashed).Scan(
&u.ID, &u.Email, &u.Name, &u.IsAdmin)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
func (s *Store) DeleteSession(ctx context.Context, hashed string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM "Session" WHERE "token" = $1`, hashed)
return err
}
// 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.pool.Exec(ctx, `DELETE FROM "Session" WHERE "expiresAt" < now()`); err != nil {
return err
}
_, err := s.pool.Exec(ctx,
`DELETE FROM "Verification" WHERE "expiresAt" < now() - interval '1 day'`)
return err
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}