feat: initial commit — backend API + student cabinet frontend
- 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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Package auth handles user authentication: login and token refresh.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the auth domain.
|
||||
// It only parses requests and writes responses — no business logic here.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new auth Handler backed by the given service.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// loginRequest is the expected JSON body for POST /api/v1/auth/login.
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// refreshRequest is the expected JSON body for POST /api/v1/auth/refresh.
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// accessTokenResponse is the JSON body returned by a successful refresh.
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
// Accepts {"email": "...", "password": "..."}. Returns a token pair on success.
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Password == "" {
|
||||
response.Error(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Login(r.Context(), LoginRequest{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
slog.Error("handler.Login", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, pair)
|
||||
}
|
||||
|
||||
// Refresh handles POST /api/v1/auth/refresh.
|
||||
// Accepts {"refresh_token": "..."}. Returns a new access token on success.
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired refresh token")
|
||||
return
|
||||
}
|
||||
slog.Error("handler.Refresh", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, accessTokenResponse{AccessToken: accessToken})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims are the JWT payload fields used by this service.
|
||||
// Both access and refresh tokens use this struct; the Type field distinguishes them.
|
||||
// Every token includes a unique JWTID (jti) for future revocation support.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims // carries sub (user_id), exp, iat, jti
|
||||
Role string `json:"role,omitempty"` // populated only in access tokens
|
||||
Type string `json:"type"` // "access" or "refresh"
|
||||
}
|
||||
|
||||
// JWTManager generates and validates JWT tokens.
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
// NewJWTManager creates a JWTManager with the given HMAC secret and TTL durations.
|
||||
func NewJWTManager(secret string, accessTTL, refreshTTL time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secret: []byte(secret),
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a signed HS256 access token for the given user.
|
||||
// Claims include: sub (user_id), role, jti (unique ID), iat, exp.
|
||||
func (m *JWTManager) GenerateAccessToken(userID, role string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateAccessToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.accessTTL)),
|
||||
},
|
||||
Role: role,
|
||||
Type: "access",
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateAccessToken: sign: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a signed HS256 refresh token for the given user.
|
||||
// Claims include: sub (user_id), jti (unique ID), iat, exp.
|
||||
// The role is intentionally omitted — it is always re-fetched from the DB on use.
|
||||
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateRefreshToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.refreshTTL)),
|
||||
},
|
||||
Type: "refresh",
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateRefreshToken: sign: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// ParseToken parses and cryptographically validates a JWT string.
|
||||
// It verifies the HMAC signature and token expiry but does NOT check the Type field —
|
||||
// callers are responsible for asserting the expected type ("access" or "refresh").
|
||||
func (m *JWTManager) ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// newJTI generates a cryptographically random UUID v4 string for use as a JWT ID.
|
||||
func newJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Set version 4 and variant bits per RFC 4122
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested user does not exist in the database.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// UserRecord is the minimal user row fetched from the database during authentication.
|
||||
type UserRecord struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
}
|
||||
|
||||
// UserRepository defines the database operations the auth service depends on.
|
||||
// Defined as an interface so unit tests can inject a mock without a real database.
|
||||
type UserRepository interface {
|
||||
// GetUserByEmail returns the user row for the given email address.
|
||||
// Returns ErrNotFound if no user exists with that email.
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||
|
||||
// GetUserByID returns the user row for the given primary key.
|
||||
// Returns ErrNotFound if the user has been deleted since the token was issued.
|
||||
GetUserByID(ctx context.Context, id string) (*UserRecord, error)
|
||||
}
|
||||
|
||||
// Repository is the PostgreSQL-backed implementation of UserRepository.
|
||||
type Repository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new PostgreSQL-backed auth Repository.
|
||||
func NewRepository(db *pgxpool.Pool) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// GetUserByEmail fetches the user row needed for password verification.
|
||||
// Returns ErrNotFound if no user exists with that email.
|
||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||
var u UserRecord
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, role FROM users WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetUserByEmail: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserByID fetches the user row needed when refreshing a token.
|
||||
// The role is re-read from the DB so that admin role changes take effect on the next refresh.
|
||||
// Returns ErrNotFound if the user has been deleted since the token was issued.
|
||||
func (r *Repository) GetUserByID(ctx context.Context, id string) (*UserRecord, error) {
|
||||
var u UserRecord
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, role FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetUserByID: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cu-points/backend/internal/auth"
|
||||
)
|
||||
|
||||
// mockRepo is a test double for UserRepository.
|
||||
// Populate user and/or repoErr before each test case.
|
||||
type mockRepo struct {
|
||||
user *auth.UserRecord
|
||||
repoErr error
|
||||
}
|
||||
|
||||
func (m *mockRepo) GetUserByEmail(_ context.Context, _ string) (*auth.UserRecord, error) {
|
||||
return m.user, m.repoErr
|
||||
}
|
||||
|
||||
func (m *mockRepo) GetUserByID(_ context.Context, _ string) (*auth.UserRecord, error) {
|
||||
return m.user, m.repoErr
|
||||
}
|
||||
|
||||
// hashPassword hashes the given plain-text password using bcrypt minimum cost for speed.
|
||||
func hashPassword(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword: %v", err)
|
||||
}
|
||||
return string(h)
|
||||
}
|
||||
|
||||
// newTestService builds a Service wired to the given mock repo.
|
||||
func newTestService(repo auth.UserRepository) *auth.Service {
|
||||
jwtMgr := auth.NewJWTManager(
|
||||
"test-secret-minimum-32-characters-long",
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
return auth.NewService(repo, jwtMgr)
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "a5b66288-4a97-410b-9e30-a7cf61cdabab",
|
||||
Email: "student@cu.ru",
|
||||
PasswordHash: hashPassword(t, "password123"),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
svc := newTestService(repo)
|
||||
|
||||
pair, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "student@cu.ru",
|
||||
Password: "password123",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if pair.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_WrongPassword(t *testing.T) {
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "a5b66288-4a97-410b-9e30-a7cf61cdabab",
|
||||
Email: "student@cu.ru",
|
||||
PasswordHash: hashPassword(t, "password123"),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
svc := newTestService(repo)
|
||||
|
||||
_, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "student@cu.ru",
|
||||
Password: "wrongpassword",
|
||||
})
|
||||
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_UserNotFound(t *testing.T) {
|
||||
repo := &mockRepo{repoErr: auth.ErrNotFound}
|
||||
svc := newTestService(repo)
|
||||
|
||||
_, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "nobody@cu.ru",
|
||||
Password: "password123",
|
||||
})
|
||||
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user