// Package service contains business logic services. package service import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "regexp" "strings" "time" "github.com/google/uuid" "github.com/michael/flowreader/internal/domain" "golang.org/x/crypto/argon2" ) // Common errors var ( ErrInvalidEmail = errors.New("invalid email format") ErrPasswordTooShort = errors.New("password must be at least 8 characters") ErrEmailAlreadyExists = errors.New("email already registered") ErrUserNotFound = errors.New("user not found") ErrInvalidCredentials = errors.New("invalid credentials") ) // Argon2 parameters (OWASP recommended) const ( argon2Time = 1 argon2Memory = 64 * 1024 // 64MB argon2Threads = 4 argon2KeyLen = 32 saltLength = 16 tokenLength = 32 sessionDuration = 7 * 24 * time.Hour // 7 days ) // AuthService handles user authentication business logic. type AuthService struct { userRepo domain.UserRepository sessionRepo domain.SessionRepository } // NewAuthService creates a new authentication service. func NewAuthService(userRepo domain.UserRepository, sessionRepo domain.SessionRepository) *AuthService { return &AuthService{ userRepo: userRepo, sessionRepo: sessionRepo, } } // RegisterRequest contains the data needed to register a new user. type RegisterRequest struct { Email string `json:"email"` Password string `json:"password"` } // RegisterResponse contains the registered user data. type RegisterResponse struct { ID uuid.UUID `json:"id"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` } // LoginRequest contains the data needed to log in. type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` UserAgent string `json:"-"` IPAddress string `json:"-"` } // LoginResponse contains the session result. The token itself is intentionally // NOT serialized to JSON: it is delivered only via the HttpOnly session cookie // so it remains inaccessible to client-side scripts. type LoginResponse struct { Token string `json:"-"` ExpiresAt time.Time `json:"expires_at"` User UserInfo `json:"user"` } // UserInfo contains basic user information. type UserInfo struct { ID uuid.UUID `json:"id"` Email string `json:"email"` IsAdmin bool `json:"is_admin"` } // Register creates a new user account. func (s *AuthService) Register(req RegisterRequest) (*RegisterResponse, error) { // Validate email format if !isValidEmail(req.Email) { return nil, ErrInvalidEmail } // Validate password length if len(req.Password) < 8 { return nil, ErrPasswordTooShort } // Check if email already exists exists, err := s.userRepo.Exists(req.Email) if err != nil { return nil, fmt.Errorf("checking email: %w", err) } if exists { return nil, ErrEmailAlreadyExists } // Hash password with Argon2id passwordHash, err := hashPassword(req.Password) if err != nil { return nil, fmt.Errorf("hashing password: %w", err) } // Create user now := time.Now() user := &domain.User{ ID: uuid.New(), Email: req.Email, PasswordHash: passwordHash, IsAdmin: false, CreatedAt: now, UpdatedAt: now, } if err := s.userRepo.Create(user); err != nil { return nil, fmt.Errorf("creating user: %w", err) } return &RegisterResponse{ ID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt, }, nil } // Login authenticates a user and creates a session. func (s *AuthService) Login(req LoginRequest) (*LoginResponse, error) { // Find user by email user, err := s.userRepo.GetByEmail(req.Email) if err != nil { return nil, fmt.Errorf("finding user: %w", err) } if user == nil { return nil, ErrInvalidCredentials } // Verify password if !verifyPassword(req.Password, user.PasswordHash) { return nil, ErrInvalidCredentials } // Generate session token token, err := generateToken() if err != nil { return nil, fmt.Errorf("generating token: %w", err) } // Create session now := time.Now() session := &domain.Session{ ID: uuid.New(), UserID: user.ID, Token: token, ExpiresAt: now.Add(sessionDuration), CreatedAt: now, UserAgent: req.UserAgent, IPAddress: req.IPAddress, } if err := s.sessionRepo.Create(session); err != nil { return nil, fmt.Errorf("creating session: %w", err) } return &LoginResponse{ Token: token, ExpiresAt: session.ExpiresAt, User: UserInfo{ ID: user.ID, Email: user.Email, IsAdmin: user.IsAdmin, }, }, nil } // Logout invalidates a session. func (s *AuthService) Logout(token string) error { return s.sessionRepo.Delete(token) } // GetUserByToken retrieves the user associated with a session token. func (s *AuthService) GetUserByToken(token string) (*domain.User, error) { session, err := s.sessionRepo.GetByToken(token) if err != nil { return nil, fmt.Errorf("getting session: %w", err) } if session == nil { return nil, nil // Invalid or expired session } user, err := s.userRepo.GetByID(session.UserID) if err != nil { return nil, fmt.Errorf("getting user: %w", err) } return user, nil } // hashPassword creates an Argon2id hash of the password. func hashPassword(password string) (string, error) { salt := make([]byte, saltLength) if _, err := rand.Read(salt); err != nil { return "", err } hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) // Encode salt and hash together encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argon2Memory, argon2Time, argon2Threads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ) return encoded, nil } // verifyPassword checks if the password matches the hash. func verifyPassword(password, encodedHash string) bool { // Parse the encoded hash parts := strings.Split(encodedHash, "$") if len(parts) != 6 { return false } var memory, time uint32 var threads uint8 _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) if err != nil { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false } expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false } // Compute hash with same parameters computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(expectedHash))) // Constant-time comparison return subtle.ConstantTimeCompare(expectedHash, computedHash) == 1 } // generateToken creates a cryptographically secure random token. func generateToken() (string, error) { bytes := make([]byte, tokenLength) if _, err := rand.Read(bytes); err != nil { return "", err } return base64.URLEncoding.EncodeToString(bytes), nil } // isValidEmail checks if the email has a valid format. func isValidEmail(email string) bool { emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) return emailRegex.MatchString(email) }