Added comprehensive user management system: - User registration with validation (3-50 char username, 6+ char password) - JWT-based authentication with bcrypt password hashing - Admin authentication with master password - Password reset workflow with admin flagging - PostgreSQL repository implementation - SQLite repository for testing - Unified authentication service interface API Endpoints: - POST /api/v1/auth/register - User registration - POST /api/v1/auth/login - User/admin authentication - POST /api/v1/auth/password-reset/request - Request password reset - POST /api/v1/auth/password-reset/complete - Complete password reset - POST /api/v1/auth/validate - JWT token validation Security Features: - Password hashing with bcrypt - JWT token generation and validation - Admin claims in JWT tokens - Configurable token expiration - Input validation for all endpoints Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
226 lines
6.2 KiB
Go
226 lines
6.2 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"dance-lessons-coach/pkg/config"
|
|
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// SQLiteRepository implements UserRepository using SQLite
|
|
type SQLiteRepository struct {
|
|
db *gorm.DB
|
|
dbPath string
|
|
config *config.Config
|
|
spanPrefix string
|
|
}
|
|
|
|
// NewSQLiteRepository creates a new SQLite repository
|
|
func NewSQLiteRepository(dbPath string, config *config.Config) (*SQLiteRepository, error) {
|
|
repo := &SQLiteRepository{
|
|
dbPath: dbPath,
|
|
config: config,
|
|
spanPrefix: "user.repo.",
|
|
}
|
|
|
|
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 {
|
|
// Create telemetry span
|
|
ctx, span := r.createSpan(ctx, "create_user")
|
|
if span != nil {
|
|
defer span.End()
|
|
}
|
|
|
|
result := r.db.WithContext(ctx).Create(user)
|
|
if result.Error != nil {
|
|
if span != nil {
|
|
span.RecordError(result.Error)
|
|
}
|
|
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) {
|
|
// Create telemetry span
|
|
ctx, span := r.createSpan(ctx, "get_user_by_username")
|
|
if span != nil {
|
|
defer span.End()
|
|
span.SetAttributes(attribute.String("username", username))
|
|
}
|
|
|
|
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
|
|
}
|
|
if span != nil {
|
|
span.RecordError(result.Error)
|
|
}
|
|
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()
|
|
}
|
|
|
|
// CheckDatabaseHealth checks if the database is healthy and responsive
|
|
func (r *SQLiteRepository) CheckDatabaseHealth(ctx context.Context) error {
|
|
// Simple query to test database connectivity
|
|
var count int64
|
|
result := r.db.WithContext(ctx).Model(&User{}).Count(&count)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("database health check failed: %w", result.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createSpan creates a new telemetry span if persistence telemetry is enabled
|
|
func (r *SQLiteRepository) createSpan(ctx context.Context, operation string) (context.Context, trace.Span) {
|
|
if r.config == nil || !r.config.GetPersistenceTelemetryEnabled() {
|
|
return ctx, trace.SpanFromContext(ctx)
|
|
}
|
|
|
|
// Create a new span with the operation name
|
|
spanName := r.spanPrefix + operation
|
|
tr := otel.Tracer("user-repository")
|
|
return tr.Start(ctx, spanName)
|
|
}
|