328 lines
9.1 KiB
Go
328 lines
9.1 KiB
Go
package service
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"chess/internal/server/storage"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lixenwraith/auth"
|
|
)
|
|
|
|
var (
|
|
ErrStorageDisabled = errors.New("storage disabled")
|
|
ErrStorageUnavailable = errors.New("storage unavailable")
|
|
ErrAtCapacity = errors.New("at capacity")
|
|
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
|
)
|
|
|
|
// User represents a registered user account
|
|
type User struct {
|
|
UserID string
|
|
Username string
|
|
Email string
|
|
AccountType string
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
// CreateUser creates new user with registration limits enforcement
|
|
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
|
user, _, err := s.createUser(username, email, password, permanent, false)
|
|
return user, err
|
|
}
|
|
|
|
// RegisterUser creates the account and its initial session in one SQLite
|
|
// transaction, so a successful registration always returns a usable account.
|
|
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
|
return s.createUser(username, email, password, permanent, true)
|
|
}
|
|
|
|
func (s *Service) createUser(
|
|
username, email, password string,
|
|
permanent, withSession bool,
|
|
) (*User, string, error) {
|
|
s.userMu.Lock()
|
|
defer s.userMu.Unlock()
|
|
|
|
if s.store == nil {
|
|
return nil, "", ErrStorageDisabled
|
|
}
|
|
|
|
// Determine account type
|
|
accountType := "temp"
|
|
var expiresAt *time.Time
|
|
|
|
if permanent {
|
|
accountType = "permanent"
|
|
} else {
|
|
expiry := time.Now().UTC().Add(TempUserTTL)
|
|
expiresAt = &expiry
|
|
}
|
|
|
|
// Hash password
|
|
passwordHash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Generate unique user ID
|
|
userID, err := s.generateUniqueUserID()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
|
}
|
|
|
|
// Create user record
|
|
user := &User{
|
|
UserID: userID,
|
|
Username: username,
|
|
Email: email,
|
|
AccountType: accountType,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
record := storage.UserRecord{
|
|
UserID: userID,
|
|
Username: strings.ToLower(username),
|
|
Email: strings.ToLower(email),
|
|
PasswordHash: passwordHash,
|
|
AccountType: accountType,
|
|
CreatedAt: user.CreatedAt,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
var sessionID string
|
|
var session *storage.SessionRecord
|
|
if withSession {
|
|
sessionID = uuid.New().String()
|
|
session = &storage.SessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: userID,
|
|
CreatedAt: user.CreatedAt,
|
|
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
|
}
|
|
}
|
|
limits := storage.UserLimits{
|
|
MaxUsers: MaxUsers,
|
|
PermanentSlots: PermanentSlots,
|
|
}
|
|
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
|
switch {
|
|
case errors.Is(err, storage.ErrUserAlreadyExists):
|
|
return nil, "", fmt.Errorf("username or email already exists: %w", err)
|
|
case errors.Is(err, storage.ErrPermanentCapacity):
|
|
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
|
case errors.Is(err, storage.ErrUserCapacity):
|
|
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
|
default:
|
|
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
}
|
|
slog.Debug("user created",
|
|
"user_id", userID,
|
|
"account_type", accountType,
|
|
"initial_session", withSession,
|
|
)
|
|
|
|
return user, sessionID, nil
|
|
}
|
|
|
|
// AuthenticateUser verifies credentials and creates a new session
|
|
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
|
if s.store == nil {
|
|
return nil, "", ErrStorageDisabled
|
|
}
|
|
|
|
var userRecord *storage.UserRecord
|
|
var err error
|
|
|
|
// Check if identifier looks like email
|
|
if strings.Contains(identifier, "@") {
|
|
userRecord, err = s.store.GetUserByEmail(identifier)
|
|
} else {
|
|
userRecord, err = s.store.GetUserByUsername(identifier)
|
|
}
|
|
|
|
if err != nil {
|
|
auth.HashPassword(password) // Timing attack prevention
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
return nil, "", fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Verify password
|
|
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
|
return nil, "", fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Check if temp user expired
|
|
if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil {
|
|
if time.Now().UTC().After(*userRecord.ExpiresAt) {
|
|
return nil, "", fmt.Errorf("account expired")
|
|
}
|
|
}
|
|
|
|
// Create new session (invalidates any existing session)
|
|
sessionID := uuid.New().String()
|
|
sessionRecord := storage.SessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: userRecord.UserID,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
|
}
|
|
|
|
if err := s.store.CreateSession(sessionRecord); err != nil {
|
|
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
|
|
|
// Update last login
|
|
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
|
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
|
}
|
|
|
|
return &User{
|
|
UserID: userRecord.UserID,
|
|
Username: userRecord.Username,
|
|
Email: userRecord.Email,
|
|
AccountType: userRecord.AccountType,
|
|
CreatedAt: userRecord.CreatedAt,
|
|
ExpiresAt: userRecord.ExpiresAt,
|
|
}, sessionID, nil
|
|
}
|
|
|
|
// ValidateSession checks if a session is valid
|
|
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
|
if s.store == nil {
|
|
return false, ErrStorageDisabled
|
|
}
|
|
valid, err := s.store.IsSessionValid(sessionID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
return valid, nil
|
|
}
|
|
|
|
// InvalidateSession removes a session (logout)
|
|
func (s *Service) InvalidateSession(sessionID string) error {
|
|
if s.store == nil {
|
|
return ErrStorageDisabled
|
|
}
|
|
if err := s.store.DeleteSession(sessionID); err != nil {
|
|
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetUserByID retrieves user information by user ID
|
|
func (s *Service) GetUserByID(userID string) (*User, error) {
|
|
if s.store == nil {
|
|
return nil, ErrStorageDisabled
|
|
}
|
|
|
|
userRecord, err := s.store.GetUserByID(userID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("user not found")
|
|
}
|
|
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
|
|
return &User{
|
|
UserID: userRecord.UserID,
|
|
Username: userRecord.Username,
|
|
Email: userRecord.Email,
|
|
AccountType: userRecord.AccountType,
|
|
CreatedAt: userRecord.CreatedAt,
|
|
ExpiresAt: userRecord.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// GenerateUserToken creates a JWT token for the specified user with session ID
|
|
func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) {
|
|
user, err := s.GetUserByID(userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
claims := map[string]any{
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
"session_id": sessionID,
|
|
}
|
|
|
|
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
|
|
}
|
|
|
|
// ValidateToken verifies JWT token and session validity
|
|
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
|
userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if s.store == nil {
|
|
return "", nil, ErrStorageDisabled
|
|
}
|
|
sessionID, ok := claims["session_id"].(string)
|
|
if !ok || sessionID == "" {
|
|
return "", nil, fmt.Errorf("token has no persisted session")
|
|
}
|
|
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
if !valid {
|
|
return "", nil, fmt.Errorf("session invalidated")
|
|
}
|
|
|
|
return userID, claims, nil
|
|
}
|
|
|
|
// generateUniqueUserID creates a unique user ID with collision detection
|
|
func (s *Service) generateUniqueUserID() (string, error) {
|
|
const maxAttempts = 10
|
|
|
|
for i := 0; i < maxAttempts; i++ {
|
|
id := uuid.New().String()
|
|
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
|
return id, nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("failed to generate unique user ID")
|
|
}
|
|
|
|
// CreateUserSession creates a session for a trusted internal caller without
|
|
// re-authenticating. Public registration uses RegisterUser so account and
|
|
// initial session creation remain atomic.
|
|
func (s *Service) CreateUserSession(userID string) (string, error) {
|
|
if s.store == nil {
|
|
return "", ErrStorageDisabled
|
|
}
|
|
|
|
sessionID := uuid.New().String()
|
|
sessionRecord := storage.SessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: userID,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
|
}
|
|
|
|
if err := s.store.CreateSession(sessionRecord); err != nil {
|
|
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
slog.Debug("user session created", "user_id", userID)
|
|
|
|
return sessionID, nil
|
|
}
|