Files
web/auth/auth.go
T
gamertan 3a4b6db9b8
verify / verify (push) Successful in 2m55s
feat: publish Gamertan Web Foundations preview source
Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root.

Developed with material AI assistance under maintainer review.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 15:05:40 -04:00

225 lines
7.2 KiB
Go

// SPDX-License-Identifier: MPL-2.0
// Package auth defines storage-neutral users, credentials, opaque sessions,
// permissions, and audit events. Applications retain authorization policy.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
var (
ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active")
ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
)
type User struct {
ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time
}
type Principal struct {
User User
Roles []string
Permissions map[string]bool
}
func (principal Principal) Has(permission string) bool { return principal.Permissions[permission] }
type Session struct {
Digest [32]byte
UserID string
CreatedAt, ExpiresAt, LastSeenAt time.Time
}
type AuditEvent struct {
ID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
CreatedAt time.Time
}
type PolicySeed struct {
Roles map[string]string
Permissions map[string]string
RolePermissions map[string][]string
}
type Repository interface {
CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error)
UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
TouchSession(context.Context, [32]byte, time.Time) error
DeleteSession(context.Context, [32]byte) error
RevokeUserSessions(context.Context, string) error
SeedPolicy(context.Context, PolicySeed) error
GrantRole(context.Context, string, string, time.Time) error
AppendAudit(context.Context, AuditEvent) error
}
type Service struct {
repository Repository
random io.Reader
now func() time.Time
touchInterval time.Duration
}
type Options struct {
Random io.Reader
Now func() time.Time
TouchInterval time.Duration
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("auth: repository is required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.TouchInterval == 0 {
options.TouchInterval = 5 * time.Minute
}
if options.TouchInterval < time.Minute || options.TouchInterval > time.Hour {
return nil, errors.New("auth: invalid session touch interval")
}
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
}
type CreateUser struct{ Username, Email, DisplayName, Password string }
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username)
email := strings.TrimSpace(input.Email)
displayName := strings.TrimSpace(input.DisplayName)
if !identifierPattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 {
return User{}, errors.New("auth: invalid user")
}
hash, err := HashPasswordWithRandom(input.Password, service.random)
if err != nil {
return User{}, err
}
id, err := randomToken(service.random, 18)
if err != nil {
return User{}, err
}
now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err
}
return user, nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")
}
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return "", Principal{}, ErrInvalidCredentials
}
if user.Status != "active" {
return "", Principal{}, ErrInactiveUser
}
token, err := randomToken(service.random, 32)
if err != nil {
return "", Principal{}, err
}
now := service.now().UTC()
digest := sha256.Sum256([]byte(token))
if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil {
return "", Principal{}, err
}
_ = service.repository.UpdateLastLogin(ctx, user.ID, now)
principal, _, err := service.repository.PrincipalBySession(ctx, digest, now)
if err != nil {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err
}
return token, principal, nil
}
func (service *Service) Session(ctx context.Context, token string) (Principal, error) {
if len(token) < 32 || len(token) > 128 {
return Principal{}, ErrSessionNotFound
}
digest := sha256.Sum256([]byte(token))
now := service.now().UTC()
principal, session, err := service.repository.PrincipalBySession(ctx, digest, now)
if errors.Is(err, ErrSessionNotFound) {
return Principal{}, ErrSessionNotFound
}
if err != nil {
return Principal{}, fmt.Errorf("auth: load session: %w", err)
}
if principal.User.Status != "active" {
_ = service.repository.DeleteSession(ctx, digest)
return Principal{}, ErrInactiveUser
}
if now.Sub(session.LastSeenAt) >= service.touchInterval {
_ = service.repository.TouchSession(ctx, digest, now)
}
principal.Roles = sortedUnique(principal.Roles)
if principal.Permissions == nil {
principal.Permissions = map[string]bool{}
}
return principal, nil
}
func (service *Service) RevokeSession(ctx context.Context, token string) error {
digest := sha256.Sum256([]byte(token))
return service.repository.DeleteSession(ctx, digest)
}
func (service *Service) RevokeUserSessions(ctx context.Context, userID string) error {
return service.repository.RevokeUserSessions(ctx, userID)
}
func (service *Service) Repository() Repository { return service.repository }
func randomToken(random io.Reader, bytes int) (string, error) {
value := make([]byte, bytes)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("auth: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func sortedUnique(values []string) []string {
set := make(map[string]struct{}, len(values))
for _, value := range values {
if value != "" {
set[value] = struct{}{}
}
}
result := make([]string, 0, len(set))
for value := range set {
result = append(result, value)
}
sort.Strings(result)
return result
}