- Go backend: auth (JWT), points earn/spend, QR token generation, partners, admin grant/stats endpoints with chi router - Next.js 14 frontend: login, student dashboard, transaction history, QR display, partners list - PostgreSQL migrations (4 tables), Redis cache, Docker Compose - CORS middleware, role-based route protection, Zustand auth store Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
3.8 KiB
Go
119 lines
3.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// ErrInvalidCredentials is returned for both an unknown email and a wrong password.
|
|
// Using a single sentinel prevents callers from distinguishing the two cases,
|
|
// which would otherwise allow email enumeration.
|
|
var ErrInvalidCredentials = errors.New("invalid email or password")
|
|
|
|
// Service contains business logic for authentication.
|
|
// All password and token operations live here; the handler only parses HTTP.
|
|
type Service struct {
|
|
repo UserRepository
|
|
jwt *JWTManager
|
|
}
|
|
|
|
// NewService creates a new auth Service with the given repository and JWT manager.
|
|
func NewService(repo UserRepository, jwt *JWTManager) *Service {
|
|
return &Service{repo: repo, jwt: jwt}
|
|
}
|
|
|
|
// LoginRequest holds credentials submitted by the user on the login form.
|
|
type LoginRequest struct {
|
|
Email string
|
|
Password string
|
|
}
|
|
|
|
// TokenPair holds the access and refresh tokens returned after a successful login.
|
|
type TokenPair struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
|
|
// Login validates credentials and returns a JWT token pair on success.
|
|
// Returns ErrInvalidCredentials for both an unknown email and a wrong password
|
|
// so callers cannot distinguish between the two cases (anti-enumeration).
|
|
func (s *Service) Login(ctx context.Context, req LoginRequest) (*TokenPair, error) {
|
|
user, err := s.repo.GetUserByEmail(ctx, req.Email)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
// Run a dummy bcrypt comparison so that response time is constant
|
|
// regardless of whether the email exists in the database.
|
|
bcrypt.CompareHashAndPassword([]byte("$2a$10$dummyhashpadding000000000000000000000000000000000000000"), []byte(req.Password)) //nolint:errcheck
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return nil, fmt.Errorf("service.Login: %w", err)
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
accessToken, err := s.jwt.GenerateAccessToken(user.ID, user.Role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service.Login: %w", err)
|
|
}
|
|
|
|
refreshToken, err := s.jwt.GenerateRefreshToken(user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service.Login: %w", err)
|
|
}
|
|
|
|
slog.Info("user logged in", "user_id", user.ID, "role", user.Role)
|
|
|
|
return &TokenPair{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
}, nil
|
|
}
|
|
|
|
// Refresh validates a refresh token and returns a new access token.
|
|
// The user's role is re-fetched from the database so that role changes take effect immediately
|
|
// rather than persisting until the old refresh token expires.
|
|
func (s *Service) Refresh(ctx context.Context, refreshToken string) (string, error) {
|
|
claims, err := s.jwt.ParseToken(refreshToken)
|
|
if err != nil {
|
|
return "", ErrInvalidCredentials
|
|
}
|
|
if claims.Type != "refresh" {
|
|
return "", ErrInvalidCredentials
|
|
}
|
|
|
|
user, err := s.repo.GetUserByID(ctx, claims.Subject)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return "", ErrInvalidCredentials
|
|
}
|
|
return "", fmt.Errorf("service.Refresh: %w", err)
|
|
}
|
|
|
|
accessToken, err := s.jwt.GenerateAccessToken(user.ID, user.Role)
|
|
if err != nil {
|
|
return "", fmt.Errorf("service.Refresh: %w", err)
|
|
}
|
|
|
|
return accessToken, nil
|
|
}
|
|
|
|
// ValidateToken parses an access token and returns its claims.
|
|
// Returns ErrInvalidCredentials if the token is invalid, expired, or not an access token.
|
|
// Used by other services that need to inspect token claims (e.g. extracting user_id).
|
|
func (s *Service) ValidateToken(token string) (*Claims, error) {
|
|
claims, err := s.jwt.ParseToken(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service.ValidateToken: %w", err)
|
|
}
|
|
if claims.Type != "access" {
|
|
return nil, fmt.Errorf("service.ValidateToken: %w", ErrInvalidCredentials)
|
|
}
|
|
return claims, nil
|
|
}
|