📝 docs: add comprehensive SOLID analysis and code review findings
- Documented SOLID principle violations across codebase - Identified security best practice improvements needed - Analyzed performance optimization opportunities - Added detailed refactoring recommendations - Updated ADR-0018 with JWT secret rotation reference - Enabled gitea-client skill for programmer agent This commit captures the current state analysis before implementing improvements.
This commit is contained in:
224
pkg/user/api/auth_handler.go
Normal file
224
pkg/user/api/auth_handler.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related HTTP requests
|
||||
type AuthHandler struct {
|
||||
authService user.AuthService
|
||||
userRepo user.UserRepository
|
||||
passwordResetService user.PasswordResetService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new authentication handler
|
||||
func NewAuthHandler(authService user.AuthService, userRepo user.UserRepository) *AuthHandler {
|
||||
passwordResetService := user.NewPasswordResetService(userRepo, authService.(*user.AuthServiceImpl))
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userRepo: userRepo,
|
||||
passwordResetService: passwordResetService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authentication routes
|
||||
func (h *AuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/login", h.handleLogin)
|
||||
router.Post("/admin/login", h.handleAdminLogin)
|
||||
router.Post("/register", h.handleRegister)
|
||||
router.Post("/password-reset/request", h.handlePasswordResetRequest)
|
||||
router.Post("/password-reset/complete", h.handlePasswordResetComplete)
|
||||
}
|
||||
|
||||
// LoginRequest represents a login request
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// handleLogin handles user login requests
|
||||
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
user, err := h.authService.Authenticate(ctx, req.Username, req.Password)
|
||||
if err != nil {
|
||||
log.Trace().Ctx(ctx).Err(err).Str("username", req.Username).Msg("Authentication failed")
|
||||
http.Error(w, `{"error":"invalid_credentials","message":"Invalid username or password"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.authService.GenerateJWT(ctx, user)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to generate JWT token")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to generate authentication token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(LoginResponse{Token: token})
|
||||
}
|
||||
|
||||
// handleAdminLogin handles admin login requests
|
||||
func (h *AuthHandler) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate admin
|
||||
adminUser, err := h.authService.AdminAuthenticate(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Trace().Ctx(ctx).Err(err).Msg("Admin authentication failed")
|
||||
http.Error(w, `{"error":"invalid_credentials","message":"Invalid admin credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.authService.GenerateJWT(ctx, adminUser)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to generate JWT token for admin")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to generate authentication token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(LoginResponse{Token: token})
|
||||
}
|
||||
|
||||
// RegisterRequest represents a user registration request
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// handleRegister handles user registration requests
|
||||
func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
exists, err := h.userRepo.UserExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to check if user exists")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, `{"error":"user_exists","message":"Username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := h.authService.HashPassword(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
newUser := &user.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := h.userRepo.CreateUser(ctx, newUser); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to create user")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User registered successfully"})
|
||||
}
|
||||
|
||||
// PasswordResetRequest represents a password reset request
|
||||
type PasswordResetRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
}
|
||||
|
||||
// handlePasswordResetRequest handles password reset requests
|
||||
func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
if err := h.passwordResetService.RequestPasswordReset(ctx, req.Username); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to request password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process password reset request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset allowed, user can now reset password"})
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest represents a password reset completion request
|
||||
type PasswordResetCompleteRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// handlePasswordResetComplete handles password reset completion requests
|
||||
func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Complete password reset
|
||||
if err := h.passwordResetService.CompletePasswordReset(ctx, req.Username, req.NewPassword); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to complete password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to complete password reset"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset completed successfully"})
|
||||
}
|
||||
198
pkg/user/auth_service.go
Normal file
198
pkg/user/auth_service.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// JWTConfig holds JWT configuration
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
ExpirationTime time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// AuthServiceImpl implements the AuthService interface
|
||||
type AuthServiceImpl struct {
|
||||
repo UserRepository
|
||||
jwtConfig JWTConfig
|
||||
masterPassword string
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(repo UserRepository, jwtConfig JWTConfig, masterPassword string) *AuthServiceImpl {
|
||||
return &AuthServiceImpl{
|
||||
repo: repo,
|
||||
jwtConfig: jwtConfig,
|
||||
masterPassword: masterPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with username and password
|
||||
func (s *AuthServiceImpl) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||
user, err := s.repo.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
user.LastLogin = &now
|
||||
if err := s.repo.UpdateUser(ctx, user); err != nil {
|
||||
// Don't fail authentication if we can't update last login
|
||||
// Just log it and continue
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GenerateJWT generates a JWT token for the given user
|
||||
func (s *AuthServiceImpl) GenerateJWT(ctx context.Context, user *User) (string, error) {
|
||||
// Create the claims
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"name": user.Username,
|
||||
"admin": user.IsAdmin,
|
||||
"exp": time.Now().Add(s.jwtConfig.ExpirationTime).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": s.jwtConfig.Issuer,
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign and get the complete encoded token as a string
|
||||
tokenString, err := token.SignedString([]byte(s.jwtConfig.Secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the user
|
||||
func (s *AuthServiceImpl) ValidateJWT(ctx context.Context, tokenString string) (*User, error) {
|
||||
// Parse the token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(s.jwtConfig.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is valid
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid JWT token")
|
||||
}
|
||||
|
||||
// Get claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid JWT claims")
|
||||
}
|
||||
|
||||
// Get user ID from claims
|
||||
userIDFloat, ok := claims["sub"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid user ID in JWT")
|
||||
}
|
||||
|
||||
userID := uint(userIDFloat)
|
||||
|
||||
// Get user from repository
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user from JWT: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
func (s *AuthServiceImpl) HashPassword(ctx context.Context, password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// AdminAuthenticate authenticates an admin user with master password
|
||||
func (s *AuthServiceImpl) AdminAuthenticate(ctx context.Context, masterPassword string) (*User, error) {
|
||||
// Check if master password matches
|
||||
if masterPassword != s.masterPassword {
|
||||
return nil, errors.New("invalid admin credentials")
|
||||
}
|
||||
|
||||
// Create a virtual admin user (not persisted)
|
||||
adminUser := &User{
|
||||
ID: 0, // Special ID for admin
|
||||
Username: "admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
return adminUser, nil
|
||||
}
|
||||
|
||||
// PasswordResetServiceImpl implements the PasswordResetService interface
|
||||
type PasswordResetServiceImpl struct {
|
||||
repo UserRepository
|
||||
auth *AuthServiceImpl
|
||||
}
|
||||
|
||||
// NewPasswordResetService creates a new password reset service
|
||||
func NewPasswordResetService(repo UserRepository, auth *AuthServiceImpl) *PasswordResetServiceImpl {
|
||||
return &PasswordResetServiceImpl{
|
||||
repo: repo,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
// RequestPasswordReset requests a password reset for a user
|
||||
func (s *PasswordResetServiceImpl) RequestPasswordReset(ctx context.Context, username string) error {
|
||||
// Check if user exists
|
||||
exists, err := s.repo.UserExists(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// Allow password reset
|
||||
return s.repo.AllowPasswordReset(ctx, username)
|
||||
}
|
||||
|
||||
// CompletePasswordReset completes the password reset process
|
||||
func (s *PasswordResetServiceImpl) CompletePasswordReset(ctx context.Context, username, newPassword string) error {
|
||||
// Hash the new password
|
||||
hashedPassword, err := s.auth.HashPassword(ctx, newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
// Complete the password reset
|
||||
return s.repo.CompletePasswordReset(ctx, username, hashedPassword)
|
||||
}
|
||||
174
pkg/user/sqlite_repository.go
Normal file
174
pkg/user/sqlite_repository.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// SQLiteRepository implements UserRepository using SQLite
|
||||
type SQLiteRepository struct {
|
||||
db *gorm.DB
|
||||
dbPath string
|
||||
}
|
||||
|
||||
// NewSQLiteRepository creates a new SQLite repository
|
||||
func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
|
||||
repo := &SQLiteRepository{
|
||||
dbPath: dbPath,
|
||||
}
|
||||
|
||||
if err := repo.initializeDatabase(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// initializeDatabase sets up the SQLite database and runs migrations
|
||||
func (r *SQLiteRepository) initializeDatabase() error {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(r.dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure GORM logger to use standard log
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
|
||||
var err error
|
||||
r.db, err = gorm.Open(sqlite.Open(r.dbPath), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate the User model
|
||||
if err := r.db.AutoMigrate(&User{}); err != nil {
|
||||
return fmt.Errorf("failed to auto-migrate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the database
|
||||
func (r *SQLiteRepository) CreateUser(ctx context.Context, user *User) error {
|
||||
result := r.db.WithContext(ctx).Create(user)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to create user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by username
|
||||
func (r *SQLiteRepository) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
var user User
|
||||
result := r.db.WithContext(ctx).Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user by username: %w", result.Error)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (r *SQLiteRepository) GetUserByID(ctx context.Context, id uint) (*User, error) {
|
||||
var user User
|
||||
result := r.db.WithContext(ctx).First(&user, id)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user by ID: %w", result.Error)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user in the database
|
||||
func (r *SQLiteRepository) UpdateUser(ctx context.Context, user *User) error {
|
||||
result := r.db.WithContext(ctx).Save(user)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to update user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user from the database
|
||||
func (r *SQLiteRepository) DeleteUser(ctx context.Context, id uint) error {
|
||||
result := r.db.WithContext(ctx).Delete(&User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowPasswordReset flags a user for password reset
|
||||
func (r *SQLiteRepository) AllowPasswordReset(ctx context.Context, username string) error {
|
||||
user, err := r.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user for password reset: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
user.AllowPasswordReset = true
|
||||
return r.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// CompletePasswordReset completes the password reset process
|
||||
func (r *SQLiteRepository) CompletePasswordReset(ctx context.Context, username, newPasswordHash string) error {
|
||||
user, err := r.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user for password reset completion: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
if !user.AllowPasswordReset {
|
||||
return fmt.Errorf("password reset not allowed for user: %s", username)
|
||||
}
|
||||
|
||||
user.PasswordHash = newPasswordHash
|
||||
user.AllowPasswordReset = false
|
||||
return r.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// UserExists checks if a user exists by username
|
||||
func (r *SQLiteRepository) UserExists(ctx context.Context, username string) (bool, error) {
|
||||
var count int64
|
||||
result := r.db.WithContext(ctx).Model(&User{}).Where("username = ?", username).Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("failed to check if user exists: %w", result.Error)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (r *SQLiteRepository) Close() error {
|
||||
sqlDB, err := r.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database connection: %w", err)
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
48
pkg/user/user.go
Normal file
48
pkg/user/user.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
||||
Username string `json:"username" gorm:"unique;not null" validate:"required,min=3,max=50"`
|
||||
PasswordHash string `json:"-" gorm:"not null"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CurrentGoal *string `json:"current_goal,omitempty"`
|
||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
||||
AllowPasswordReset bool `json:"allow_password_reset" gorm:"default:false"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
// UserRepository defines the interface for user persistence
|
||||
type UserRepository interface {
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
||||
GetUserByID(ctx context.Context, id uint) (*User, error)
|
||||
UpdateUser(ctx context.Context, user *User) error
|
||||
DeleteUser(ctx context.Context, id uint) error
|
||||
AllowPasswordReset(ctx context.Context, username string) error
|
||||
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
||||
UserExists(ctx context.Context, username string) (bool, error)
|
||||
}
|
||||
|
||||
// AuthService defines the interface for authentication
|
||||
type AuthService interface {
|
||||
Authenticate(ctx context.Context, username, password string) (*User, error)
|
||||
GenerateJWT(ctx context.Context, user *User) (string, error)
|
||||
ValidateJWT(ctx context.Context, token string) (*User, error)
|
||||
HashPassword(ctx context.Context, password string) (string, error)
|
||||
AdminAuthenticate(ctx context.Context, masterPassword string) (*User, error)
|
||||
}
|
||||
|
||||
// PasswordResetService defines the interface for password reset workflow
|
||||
type PasswordResetService interface {
|
||||
RequestPasswordReset(ctx context.Context, username string) error
|
||||
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
||||
}
|
||||
222
pkg/user/user_test.go
Normal file
222
pkg/user/user_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSQLiteRepository(t *testing.T) {
|
||||
t.Run("CRUD operations", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
repo, err := NewSQLiteRepository(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test CreateUser
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
PasswordHash: "hashedpassword",
|
||||
Description: ptrString("Test user"),
|
||||
CurrentGoal: ptrString("Learn to dance"),
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
|
||||
// Test GetUserByUsername
|
||||
retrievedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, "testuser", retrievedUser.Username)
|
||||
|
||||
// Test UserExists
|
||||
exists, err := repo.UserExists(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Test UpdateUser
|
||||
retrievedUser.Description = ptrString("Updated description")
|
||||
err = repo.UpdateUser(ctx, retrievedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update
|
||||
updatedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated description", *updatedUser.Description)
|
||||
|
||||
// Test AllowPasswordReset
|
||||
err = repo.AllowPasswordReset(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password reset flag
|
||||
userWithReset, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userWithReset.AllowPasswordReset)
|
||||
|
||||
// Test CompletePasswordReset
|
||||
err = repo.CompletePasswordReset(ctx, "testuser", "newhashedpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password reset completion
|
||||
userAfterReset, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "newhashedpassword", userAfterReset.PasswordHash)
|
||||
assert.False(t, userAfterReset.AllowPasswordReset)
|
||||
|
||||
// Test DeleteUser
|
||||
err = repo.DeleteUser(ctx, userAfterReset.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
deletedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deletedUser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService(t *testing.T) {
|
||||
t.Run("Password hashing and authentication", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_auth_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
repo, err := NewSQLiteRepository(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create auth service
|
||||
jwtConfig := JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpirationTime: time.Hour,
|
||||
Issuer: "test-issuer",
|
||||
}
|
||||
authService := NewAuthService(repo, jwtConfig, "admin123")
|
||||
|
||||
// Test password hashing
|
||||
password := "testpassword123"
|
||||
hashedPassword, err := authService.HashPassword(ctx, password)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hashedPassword)
|
||||
|
||||
// Create a test user
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
PasswordHash: hashedPassword,
|
||||
}
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test successful authentication
|
||||
authenticatedUser, err := authService.Authenticate(ctx, "testuser", password)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authenticatedUser)
|
||||
assert.Equal(t, "testuser", authenticatedUser.Username)
|
||||
|
||||
// Test failed authentication with wrong password
|
||||
_, err = authService.Authenticate(ctx, "testuser", "wrongpassword")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid credentials", err.Error())
|
||||
|
||||
// Test JWT generation
|
||||
token, err := authService.GenerateJWT(ctx, authenticatedUser)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// Test JWT validation
|
||||
validatedUser, err := authService.ValidateJWT(ctx, token)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, validatedUser)
|
||||
assert.Equal(t, authenticatedUser.ID, validatedUser.ID)
|
||||
|
||||
// Test admin authentication
|
||||
adminUser, err := authService.AdminAuthenticate(ctx, "admin123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, adminUser)
|
||||
assert.True(t, adminUser.IsAdmin)
|
||||
assert.Equal(t, "admin", adminUser.Username)
|
||||
|
||||
// Test failed admin authentication
|
||||
_, err = authService.AdminAuthenticate(ctx, "wrongadminpassword")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid admin credentials", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetService(t *testing.T) {
|
||||
t.Run("Password reset workflow", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_reset_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
repo, err := NewSQLiteRepository(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create auth service
|
||||
jwtConfig := JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpirationTime: time.Hour,
|
||||
Issuer: "test-issuer",
|
||||
}
|
||||
authService := NewAuthService(repo, jwtConfig, "admin123")
|
||||
passwordResetService := NewPasswordResetService(repo, authService)
|
||||
|
||||
// Create a test user
|
||||
password := "oldpassword123"
|
||||
hashedPassword, err := authService.HashPassword(ctx, password)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := &User{
|
||||
Username: "resetuser",
|
||||
PasswordHash: hashedPassword,
|
||||
}
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test password reset request
|
||||
err = passwordResetService.RequestPasswordReset(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user is flagged for reset
|
||||
userAfterRequest, err := repo.GetUserByUsername(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userAfterRequest.AllowPasswordReset)
|
||||
|
||||
// Test password reset completion
|
||||
newPassword := "newpassword123"
|
||||
err = passwordResetService.CompletePasswordReset(ctx, "resetuser", newPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password was updated and reset flag was cleared
|
||||
userAfterReset, err := repo.GetUserByUsername(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, userAfterReset.AllowPasswordReset)
|
||||
|
||||
// Verify new password works by authenticating with the new password
|
||||
authenticatedUser, err := authService.Authenticate(ctx, "resetuser", newPassword)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authenticatedUser)
|
||||
assert.Equal(t, "resetuser", authenticatedUser.Username)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create string pointers
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user