mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-18 12:48:07 +02:00
feat(story-1.4): add session-based authentication with login/logout
This commit is contained in:
1 parent
e186a4dd87
commit
d67e169a43
8 files changed
+446
-13
No files matched your search
+151
-8
@@ -3,10 +3,12 @@ package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -25,21 +27,27 @@ var (
|
||||
|
||||
// Argon2 parameters (OWASP recommended)
|
||||
const (
|
||||
argon2Time = 1
|
||||
argon2Memory = 64 * 1024 // 64MB
|
||||
argon2Threads = 4
|
||||
argon2KeyLen = 32
|
||||
saltLength = 16
|
||||
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
|
||||
userRepo domain.UserRepository
|
||||
sessionRepo domain.SessionRepository
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service.
|
||||
func NewAuthService(userRepo domain.UserRepository) *AuthService {
|
||||
return &AuthService{userRepo: userRepo}
|
||||
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.
|
||||
@@ -55,6 +63,28 @@ type RegisterResponse struct {
|
||||
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 token.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
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
|
||||
@@ -104,6 +134,78 @@ func (s *AuthService) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
}, 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)
|
||||
@@ -126,6 +228,47 @@ func hashPassword(password string) (string, error) {
|
||||
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,}$`)
|
||||
|
||||
Reference in new issue
Block a user