feat: implement user authentication system with JWT and PostgreSQL

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>
This commit is contained in:
2026-04-09 00:25:43 +02:00
parent 69e7c44eb2
commit 52a4ce4139
12 changed files with 1723 additions and 9 deletions

237
pkg/user/user_test.go Normal file
View File

@@ -0,0 +1,237 @@
package user
import (
"context"
"os"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestConfig creates a test configuration with telemetry disabled
func createTestConfig() *config.Config {
return &config.Config{
Telemetry: config.TelemetryConfig{
Enabled: false,
Persistence: config.PersistenceTelemetryConfig{
Enabled: false,
},
},
}
}
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)
cfg := createTestConfig()
repo, err := NewSQLiteRepository(dbPath, cfg)
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)
cfg := createTestConfig()
repo, err := NewSQLiteRepository(dbPath, cfg)
require.NoError(t, err)
defer repo.Close()
ctx := context.Background()
// Create user service
jwtConfig := JWTConfig{
Secret: "test-secret",
ExpirationTime: time.Hour,
Issuer: "test-issuer",
}
userService := NewUserService(repo, jwtConfig, "admin123")
// Test password hashing
password := "testpassword123"
hashedPassword, err := userService.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 := userService.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 = userService.Authenticate(ctx, "testuser", "wrongpassword")
assert.Error(t, err)
assert.Equal(t, "invalid credentials", err.Error())
// Test JWT generation
token, err := userService.GenerateJWT(ctx, authenticatedUser)
require.NoError(t, err)
assert.NotEmpty(t, token)
// Test JWT validation
validatedUser, err := userService.ValidateJWT(ctx, token)
require.NoError(t, err)
assert.NotNil(t, validatedUser)
assert.Equal(t, authenticatedUser.ID, validatedUser.ID)
// Test admin authentication
adminUser, err := userService.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 = userService.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)
cfg := createTestConfig()
repo, err := NewSQLiteRepository(dbPath, cfg)
require.NoError(t, err)
defer repo.Close()
ctx := context.Background()
// Create user service
jwtConfig := JWTConfig{
Secret: "test-secret",
ExpirationTime: time.Hour,
Issuer: "test-issuer",
}
userService := NewUserService(repo, jwtConfig, "admin123")
// Create a test user
password := "oldpassword123"
hashedPassword, err := userService.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 = userService.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 = userService.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 := userService.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
}