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:
emil
2026-05-01 10:03:27 +03:00
co-authored by Claude Sonnet 4.6
commit 50b3c4198a
80 changed files with 10579 additions and 0 deletions
+95
View File
@@ -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})
}
+117
View File
@@ -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
}
+78
View File
@@ -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
}
+118
View File
@@ -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
}
+109
View File
@@ -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)
}
}