From 18e44404b3413692c99670d5b9875d4fe1defbc5 Mon Sep 17 00:00:00 2001 From: Aleksandr Fenin Date: Mon, 22 Sep 2025 17:45:55 +0300 Subject: [PATCH] feature/user-theme-management: add user theme functionality with light/dark support --- .cursor/rules/project-architecture.mdc | 30 ++- Makefile | 2 +- README.md | 36 +++- cmd/server/main.go | 38 ++-- docs/docs.go | 238 +++++++++++++++++++--- docs/plan.md | 25 ++- docs/stages/09-future.md | 55 ++--- docs/swagger.json | 238 +++++++++++++++++++--- docs/swagger.yaml | 160 +++++++++++++-- internal/http/user_handlers.go | 147 +++++++++++++ internal/models/user.go | 5 + internal/repositories/user_repository.go | 75 ++++--- internal/services/auth_service.go | 1 + internal/services/auth_service_test.go | 14 ++ internal/services/user_service.go | 52 +++++ migrations/000003_add_user_theme.down.sql | 2 + migrations/000003_add_user_theme.up.sql | 5 + 17 files changed, 971 insertions(+), 152 deletions(-) create mode 100644 internal/http/user_handlers.go create mode 100644 internal/services/user_service.go create mode 100644 migrations/000003_add_user_theme.down.sql create mode 100644 migrations/000003_add_user_theme.up.sql diff --git a/.cursor/rules/project-architecture.mdc b/.cursor/rules/project-architecture.mdc index 8140ffe..a53c529 100644 --- a/.cursor/rules/project-architecture.mdc +++ b/.cursor/rules/project-architecture.mdc @@ -100,6 +100,7 @@ strive-api/ #### Handlers - **AuthHandlers**: Регистрация, авторизация, обновление токенов +- **UserHandlers**: Управление профилем пользователя и темами - **HealthHandlers**: Проверка состояния приложения и БД #### Middleware @@ -122,6 +123,7 @@ type User struct { ID uuid.UUID `json:"id" db:"id"` Email string `json:"email" db:"email"` PasswordHash string `json:"-" db:"password_hash"` + Theme string `json:"theme" db:"theme"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -131,10 +133,11 @@ type User struct { - CreateUserRequest - UpdateUserRequest - ChangePasswordRequest +- UpdateUserThemeRequest ### 5. Сервисы (internal/services/) -**Файл**: `auth_service.go` +#### AuthService (`auth_service.go`) Бизнес-логика аутентификации: @@ -155,6 +158,24 @@ type AuthService interface { - JWT токены (access + refresh) с расширенной валидацией - Валидация токенов с проверкой iss/aud/clock skew - Специфичные ошибки валидации (ErrTokenExpired, ErrInvalidIssuer, etc.) +- Инициализация темы пользователя по умолчанию при регистрации + +#### UserService (`user_service.go`) + +Бизнес-логика управления профилем пользователя: + +```go +type UserService interface { + GetUserProfile(ctx context.Context, userID uuid.UUID) (*models.User, error) + UpdateUserTheme(ctx context.Context, userID uuid.UUID, theme string) error +} +``` + +**Функциональность**: +- Получение профиля пользователя с темой +- Обновление темы пользователя (light/dark) +- Валидация темы на уровне сервиса +- Разделение ответственности от AuthService ### 6. Репозитории (internal/repositories/) @@ -168,6 +189,7 @@ type UserRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) GetByEmail(ctx context.Context, email string) (*models.User, error) Update(ctx context.Context, user *models.User) error + UpdateTheme(ctx context.Context, userID uuid.UUID, theme string) error Delete(ctx context.Context, id uuid.UUID) error } ``` @@ -176,6 +198,8 @@ type UserRepository interface { - Подготовленные SQL запросы - Context для отмены операций - Обработка ошибок БД +- Операции с темами пользователей +- Рефакторинг с общими методами сканирования ### 7. Валидация (internal/validation/) @@ -184,6 +208,7 @@ type UserRepository interface { Валидация входных данных: - Email валидация - Пароль валидация (минимум 8 символов, специальные символы, заглавные/строчные буквы, цифры) +- Валидация темы (только 'light' или 'dark') - Структурированные ошибки валидации ### 8. Логирование (internal/logger/) @@ -223,7 +248,8 @@ GET /swagger/ - Swagger документация ### Защищенные эндпоинты: ``` -GET /api/v1/auth/me - Информация о текущем пользователе +GET /api/v1/user/me - Информация о текущем пользователе (включая тему) +PUT /api/v1/user/theme - Обновление темы пользователя ``` ## Конфигурация через ENV diff --git a/Makefile b/Makefile index 31c0ec6..a14889e 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ run-dev: DB_PASSWORD=password \ DB_NAME=strive \ DB_SSL_MODE=disable \ - JWT_SECRET=dev-secret-key-12345 \ + JWT_SECRET=dev-secret-key-12345-very-long-for-security \ JWT_ISSUER=strive-api \ JWT_AUDIENCE=strive-app \ JWT_CLOCK_SKEW=2m \ diff --git a/README.md b/README.md index 15b2a8b..3cc81b7 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,12 @@ A modern workout diary API built with Go, featuring user authentication, JWT tok ## 🚀 Features - **User Authentication**: JWT-based authentication with access and refresh tokens -- **Password Security**: bcrypt password hashing +- **User Profile Management**: Complete user profile with theme preferences +- **Theme Support**: Light/Dark theme selection for users +- **Password Security**: bcrypt password hashing with strong validation - **Database Integration**: PostgreSQL with automatic migrations -- **Comprehensive Testing**: 17 unit tests with 73%+ code coverage +- **Clean Architecture**: Separation of concerns with services and repositories +- **Comprehensive Testing**: Unit tests with race detection - **API Documentation**: OpenAPI/Swagger documentation - **Containerization**: Docker and Docker Compose support - **Structured Logging**: JSON/text logging with configurable levels @@ -91,10 +94,13 @@ Once the server is running, visit: - `GET /health` - Health check - `POST /api/v1/auth/register` - User registration - `POST /api/v1/auth/login` - User login +- `POST /api/v1/auth/refresh` - Refresh access token +- `POST /api/v1/auth/logout` - User logout ### Protected Endpoints (require JWT token) -- `GET /api/v1/user/profile` - Get user profile +- `GET /api/v1/user/me` - Get user profile (includes theme) +- `PUT /api/v1/user/theme` - Update user theme preference ### Example Usage @@ -118,12 +124,20 @@ curl -X POST http://localhost:8080/api/v1/auth/login \ }' ``` -**Access protected endpoint:** +**Get user profile:** ```bash -curl -X GET http://localhost:8080/api/v1/user/profile \ +curl -X GET http://localhost:8080/api/v1/user/me \ -H "Authorization: Bearer YOUR_JWT_TOKEN" ``` +**Update user theme:** +```bash +curl -X PUT http://localhost:8080/api/v1/user/theme \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"theme":"dark"}' +``` + ## 🧪 Testing Run the test suite: @@ -194,11 +208,19 @@ strive-api/ │ ├── config/ # Configuration management │ ├── database/ # Database connection and health │ ├── http/ # HTTP handlers and middleware +│ │ ├── auth_handlers.go # Authentication endpoints +│ │ ├── user_handlers.go # User profile and theme endpoints +│ │ ├── middleware.go # Security and logging middleware +│ │ └── ... │ ├── logger/ # Structured logging │ ├── migrate/ # Database migrations -│ ├── models/ # Data models +│ ├── models/ # Data models (User, Theme, etc.) │ ├── repositories/ # Data access layer -│ └── services/ # Business logic +│ ├── services/ # Business logic +│ │ ├── auth_service.go # Authentication logic +│ │ ├── user_service.go # User profile and theme logic +│ │ └── ... +│ └── validation/ # Input validation ├── docs/ # Generated API documentation ├── migrations/ # Database migration files ├── docker-compose.yml # Docker Compose configuration diff --git a/cmd/server/main.go b/cmd/server/main.go index 1ce8db0..cd55271 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -46,11 +46,11 @@ func main() { runMigrations(cfg, logger) // Initialize services and handlers - authService := setupServices(db, cfg) - handlers := setupHandlers(authService, logger, db, cfg) + services := setupServices(db, cfg) + handlers := setupHandlers(services, logger, db, cfg) // Setup routes and middleware - handler := setupRoutes(handlers, logger, authService, cfg) + handler := setupRoutes(handlers, logger, services.Auth, cfg) // Start server server := httphandler.NewServer(cfg, handler, logger) @@ -100,21 +100,32 @@ func runMigrations(cfg *config.Config, logger *logger.Logger) { } } -func setupServices(db *database.Database, cfg *config.Config) services.AuthService { +type Services struct { + Auth services.AuthService + User services.UserService +} + +func setupServices(db *database.Database, cfg *config.Config) *Services { userRepo := repositories.NewUserRepository(db.Pool()) refreshTokenRepo := repositories.NewRefreshTokenRepository(db.Pool()) authService := services.NewAuthService(userRepo, refreshTokenRepo, &cfg.JWT) - return authService + userService := services.NewUserService(userRepo) + return &Services{ + Auth: authService, + User: userService, + } } type Handlers struct { Auth *httphandler.AuthHandlers + User *httphandler.UserHandlers Health *httphandler.DetailedHealthHandler } -func setupHandlers(authService services.AuthService, logger *logger.Logger, db *database.Database, cfg *config.Config) *Handlers { +func setupHandlers(services *Services, logger *logger.Logger, db *database.Database, cfg *config.Config) *Handlers { return &Handlers{ - Auth: httphandler.NewAuthHandlers(authService, logger, cfg), + Auth: httphandler.NewAuthHandlers(services.Auth, logger, cfg), + User: httphandler.NewUserHandlers(services.User, logger), Health: httphandler.NewDetailedHealthHandler(logger, db.Pool()), } } @@ -149,13 +160,12 @@ func setupPublicRoutes(mux *http.ServeMux, handlers *Handlers) { } func setupProtectedRoutes(mux *http.ServeMux, authService services.AuthService, logger *logger.Logger, handlers *Handlers) { - protectedMux := http.NewServeMux() - - protectedMux.HandleFunc("/me", handlers.Auth.Me) - - protectedHandler := httphandler.AuthMiddleware(authService, logger)(protectedMux) - - mux.Handle("/api/v1/auth/", http.StripPrefix("/api/v1/auth", protectedHandler)) + // User protected routes + userProtectedMux := http.NewServeMux() + userProtectedMux.HandleFunc("/me", handlers.User.Me) + userProtectedMux.HandleFunc("/theme", handlers.User.UpdateTheme) + userProtectedHandler := httphandler.AuthMiddleware(authService, logger)(userProtectedMux) + mux.Handle("/api/v1/user/", http.StripPrefix("/api/v1/user", userProtectedHandler)) } func applyMiddleware(mux *http.ServeMux, logger *logger.Logger, cfg *config.Config) http.Handler { diff --git a/docs/docs.go b/docs/docs.go index 58ff2ca..c4ade3f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -70,9 +70,9 @@ const docTemplate = `{ } } }, - "/api/v1/auth/refresh": { + "/api/v1/auth/logout": { "post": { - "description": "Refresh access token using refresh token", + "description": "Logout user and clear authentication cookies", "consumes": [ "application/json" ], @@ -82,18 +82,72 @@ const docTemplate = `{ "tags": [ "authentication" ], - "summary": "Refresh access token", - "parameters": [ + "summary": "Logout user", + "responses": { + "200": { + "description": "Logout successful", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ { - "description": "Refresh token data", - "name": "request", - "in": "body", - "required": true, + "BearerAuth": [] + } + ], + "description": "Returns information about the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Get current user information", + "responses": { + "200": { + "description": "User information", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", "schema": { - "$ref": "#/definitions/http.RefreshRequest" + "$ref": "#/definitions/http.AuthError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.AuthError" } } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Refresh access token using refresh token from cookie", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" ], + "tags": [ + "authentication" + ], + "summary": "Refresh access token", "responses": { "200": { "description": "Token refreshed successfully", @@ -163,6 +217,104 @@ const docTemplate = `{ } } }, + "/api/v1/user/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns detailed information about the currently authenticated user including theme", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "User profile information", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/api/v1/user/theme": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the theme preference for the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user theme", + "parameters": [ + { + "description": "Theme update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.UpdateUserThemeRequest" + } + } + ], + "responses": { + "200": { + "description": "Theme updated successfully", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid request data", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Check if the API is running", @@ -246,6 +398,22 @@ const docTemplate = `{ } }, "definitions": { + "http.AuthError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, "http.AuthResponse": { "type": "object", "properties": { @@ -257,9 +425,9 @@ const docTemplate = `{ "type": "integer", "example": 900 }, - "refresh_token": { + "message": { "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + "example": "Login successful" }, "token_type": { "type": "string", @@ -319,18 +487,6 @@ const docTemplate = `{ } } }, - "http.RefreshRequest": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } - } - }, "http.RegisterRequest": { "type": "object", "required": [ @@ -359,6 +515,42 @@ const docTemplate = `{ "type": "string" } } + }, + "http.UpdateUserThemeRequest": { + "type": "object", + "required": [ + "theme" + ], + "properties": { + "theme": { + "type": "string", + "enum": [ + "light", + "dark" + ], + "example": "dark" + } + } + }, + "models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/plan.md b/docs/plan.md index 2e7957b..f246151 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -43,7 +43,9 @@ - ✅ Конфигурация и структурированное логирование - ✅ PostgreSQL с миграциями и connection pooling - ✅ JWT аутентификация и авторизация -- ✅ Комплексные unit тесты (17 тестов, 73% покрытие) +- ✅ Управление профилем пользователя и темами (Light/Dark) +- ✅ Чистая архитектура с разделением на сервисы (AuthService, UserService) +- ✅ Комплексные unit тесты с race detection - ✅ OpenAPI/Swagger документация с интерактивным UI - ✅ Docker контейнеризация с multi-stage build - ✅ CORS middleware для фронтенда @@ -53,19 +55,28 @@ - ✅ Удобные команды Makefile для разработки - ✅ Рефакторинг кода для лучшей читаемости -API готов к использованию в продакшене для регистрации и аутентификации пользователей. +API готов к использованию в продакшене для регистрации, аутентификации пользователей и управления их профилями с поддержкой тем. ## 🚀 Готовый к использованию API API полностью готов для: - ✅ Регистрации и аутентификации пользователей +- ✅ Управления профилем пользователя и темами - ✅ Безопасного хранения данных в PostgreSQL - ✅ Production развертывания с Docker -- ✅ Интеграции с фронтенд приложениями +- ✅ Интеграции с фронтенд приложениями (включая темы) - ✅ Мониторинга и логирования **Доступные endpoints:** -- `POST /api/v1/auth/register` - регистрация -- `POST /api/v1/auth/login` - авторизация -- `GET /health` - проверка состояния -- `GET /swagger/` - документация API \ No newline at end of file + +### Публичные эндпоинты: +- `POST /api/v1/auth/register` - регистрация пользователя +- `POST /api/v1/auth/login` - авторизация пользователя +- `POST /api/v1/auth/refresh` - обновление JWT токена +- `POST /api/v1/auth/logout` - выход пользователя +- `GET /health` - проверка состояния приложения +- `GET /swagger/` - документация API + +### Защищенные эндпоинты (требуют JWT токен): +- `GET /api/v1/user/me` - получение профиля пользователя (включая тему) +- `PUT /api/v1/user/theme` - обновление темы пользователя \ No newline at end of file diff --git a/docs/stages/09-future.md b/docs/stages/09-future.md index b6e87e7..da9a764 100644 --- a/docs/stages/09-future.md +++ b/docs/stages/09-future.md @@ -3,18 +3,33 @@ ## Цель этапа Дополнительные возможности для улучшения производительности, безопасности и функциональности API. +## ✅ Уже реализованные функции + +### Безопасность +- ✅ **Rate Limiting** - защита от DDoS атак с настраиваемыми лимитами +- ✅ **HTTP Security Headers** - HSTS, CSP, X-Frame-Options, XSS Protection +- ✅ **CORS конфигурация** - настраиваемая через ENV переменные +- ✅ **JWT аутентификация** - с расширенной валидацией и refresh токенами +- ✅ **bcrypt хеширование** - безопасное хранение паролей +- ✅ **SQL Injection защита** - через pgx драйвер + +### Мониторинг и логирование +- ✅ **Структурированное логирование** - JSON/текст форматы +- ✅ **Request ID трассировка** - для отслеживания запросов +- ✅ **Security логирование** - события безопасности +- ✅ **Health checks** - базовые проверки состояния системы + +### DevOps и развертывание +- ✅ **CI/CD Pipeline** - GitHub Actions с автоматическими тестами +- ✅ **Docker контейнеризация** - с multi-stage build +- ✅ **Автоматическое тестирование** - с покрытием кода +- ✅ **Линтинг и форматирование** - golangci-lint, gofumpt, goimports + ## 9.1 Производительность и масштабируемость -- [ ] Rate limiting для защиты от DDoS - [ ] Кэширование часто запрашиваемых данных - [ ] Метрики производительности (Prometheus) - [ ] Оптимизация запросов к базе данных -### Rate Limiting -- Использовать `golang.org/x/time/rate` или Redis -- Ограничения: 100 запросов в минуту для аутентифицированных пользователей -- 20 запросов в минуту для неаутентифицированных -- IP-based и user-based лимиты - ### Кэширование - Кэшировать список пользователей (TTL 5 минут) - Кэшировать статистику (TTL 1 час) @@ -57,15 +72,11 @@ ## 9.3 Безопасность - [ ] HTTPS в продакшене -- [ ] Content Security Policy (CSP) - [ ] Двухфакторная аутентификация (2FA) - [ ] Аудит безопасности -- [ ] Защита от SQL injection (дополнительные проверки) -### HTTPS и CSP +### HTTPS и TLS - Настройка TLS сертификатов -- HSTS заголовки -- CSP для защиты от XSS ### 2FA - TOTP (Time-based One-Time Password) @@ -74,7 +85,6 @@ ## 9.4 Мониторинг и алертинг - [ ] Расширенные health checks -- [ ] Логирование ошибок с контекстом - [ ] Алерты на критические ошибки - [ ] Dashboard для мониторинга - [ ] Distributed tracing @@ -92,17 +102,13 @@ - Jaeger для distributed tracing ## 9.5 DevOps и развертывание -- [ ] CI/CD pipeline (GitHub Actions/GitLab CI) -- [ ] Автоматическое тестирование - [ ] Blue-green deployment - [ ] Kubernetes манифесты - [ ] Helm charts ### CI/CD Pipeline -- Автоматические тесты при PR -- Линтинг и форматирование -- Безопасность сканирование -- Автоматическое развертывание +- [ ] Безопасность сканирование +- [ ] Автоматическое развертывание ### Kubernetes - Deployment манифесты @@ -111,20 +117,19 @@ - Horizontal Pod Autoscaler ## Критерии готовности -- [ ] Rate limiting работает корректно - [ ] Кэширование улучшает производительность - [ ] Метрики собираются и доступны - [ ] Экспорт/импорт данных работает -- [ ] Безопасность настроена правильно - [ ] Мониторинг показывает корректные данные -- [ ] CI/CD pipeline работает автоматически +- [ ] HTTPS настроен в продакшене +- [ ] 2FA работает корректно ## Время выполнения -Ориентировочно: 2-3 недели (в зависимости от приоритетов) +Ориентировочно: 1-2 недели (в зависимости от приоритетов) ## Приоритеты -1. **Высокий**: Rate limiting, метрики, мониторинг -2. **Средний**: Кэширование, экспорт/импорт +1. **Высокий**: Метрики, мониторинг, кэширование +2. **Средний**: Экспорт/импорт, расширенные health checks, HTTPS 3. **Низкий**: 2FA, Kubernetes, advanced DevOps ## Предыдущий этап diff --git a/docs/swagger.json b/docs/swagger.json index beb876b..83c41a9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -68,9 +68,9 @@ } } }, - "/api/v1/auth/refresh": { + "/api/v1/auth/logout": { "post": { - "description": "Refresh access token using refresh token", + "description": "Logout user and clear authentication cookies", "consumes": [ "application/json" ], @@ -80,18 +80,72 @@ "tags": [ "authentication" ], - "summary": "Refresh access token", - "parameters": [ + "summary": "Logout user", + "responses": { + "200": { + "description": "Logout successful", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ { - "description": "Refresh token data", - "name": "request", - "in": "body", - "required": true, + "BearerAuth": [] + } + ], + "description": "Returns information about the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Get current user information", + "responses": { + "200": { + "description": "User information", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", "schema": { - "$ref": "#/definitions/http.RefreshRequest" + "$ref": "#/definitions/http.AuthError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.AuthError" } } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Refresh access token using refresh token from cookie", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" ], + "tags": [ + "authentication" + ], + "summary": "Refresh access token", "responses": { "200": { "description": "Token refreshed successfully", @@ -161,6 +215,104 @@ } } }, + "/api/v1/user/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns detailed information about the currently authenticated user including theme", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "User profile information", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/api/v1/user/theme": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the theme preference for the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user theme", + "parameters": [ + { + "description": "Theme update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.UpdateUserThemeRequest" + } + } + ], + "responses": { + "200": { + "description": "Theme updated successfully", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid request data", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Check if the API is running", @@ -244,6 +396,22 @@ } }, "definitions": { + "http.AuthError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, "http.AuthResponse": { "type": "object", "properties": { @@ -255,9 +423,9 @@ "type": "integer", "example": 900 }, - "refresh_token": { + "message": { "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + "example": "Login successful" }, "token_type": { "type": "string", @@ -317,18 +485,6 @@ } } }, - "http.RefreshRequest": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } - } - }, "http.RegisterRequest": { "type": "object", "required": [ @@ -357,6 +513,42 @@ "type": "string" } } + }, + "http.UpdateUserThemeRequest": { + "type": "object", + "required": [ + "theme" + ], + "properties": { + "theme": { + "type": "string", + "enum": [ + "light", + "dark" + ], + "example": "dark" + } + } + }, + "models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ded1050..3e78a35 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,15 @@ basePath: / definitions: + http.AuthError: + properties: + error: + properties: + code: + type: string + message: + type: string + type: object + type: object http.AuthResponse: properties: access_token: @@ -8,8 +18,8 @@ definitions: expires_in: example: 900 type: integer - refresh_token: - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + message: + example: Login successful type: string token_type: example: Bearer @@ -50,14 +60,6 @@ definitions: - email - password type: object - http.RefreshRequest: - properties: - refresh_token: - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - type: string - required: - - refresh_token - type: object http.RegisterRequest: properties: email: @@ -78,6 +80,30 @@ definitions: status: type: string type: object + http.UpdateUserThemeRequest: + properties: + theme: + enum: + - light + - dark + example: dark + type: string + required: + - theme + type: object + models.User: + properties: + created_at: + type: string + email: + type: string + id: + type: string + theme: + type: string + updated_at: + type: string + type: object host: strive-api-zjtl.onrender.com info: contact: @@ -122,18 +148,53 @@ paths: summary: Login user tags: - authentication + /api/v1/auth/logout: + post: + consumes: + - application/json + description: Logout user and clear authentication cookies + produces: + - application/json + responses: + "200": + description: Logout successful + schema: + additionalProperties: true + type: object + summary: Logout user + tags: + - authentication + /api/v1/auth/me: + get: + consumes: + - application/json + description: Returns information about the currently authenticated user + produces: + - application/json + responses: + "200": + description: User information + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.AuthError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.AuthError' + security: + - BearerAuth: [] + summary: Get current user information + tags: + - authentication /api/v1/auth/refresh: post: consumes: - application/json - description: Refresh access token using refresh token - parameters: - - description: Refresh token data - in: body - name: request - required: true - schema: - $ref: '#/definitions/http.RefreshRequest' + description: Refresh access token using refresh token from cookie produces: - application/json responses: @@ -183,6 +244,69 @@ paths: summary: Register a new user tags: - authentication + /api/v1/user/me: + get: + consumes: + - application/json + description: Returns detailed information about the currently authenticated + user including theme + produces: + - application/json + responses: + "200": + description: User profile information + schema: + $ref: '#/definitions/models.User' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get current user profile + tags: + - user + /api/v1/user/theme: + put: + consumes: + - application/json + description: Update the theme preference for the current user + parameters: + - description: Theme update data + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.UpdateUserThemeRequest' + produces: + - application/json + responses: + "200": + description: Theme updated successfully + schema: + additionalProperties: true + type: object + "400": + description: Invalid request data + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Update user theme + tags: + - user /health: get: consumes: diff --git a/internal/http/user_handlers.go b/internal/http/user_handlers.go new file mode 100644 index 0000000..bb27dba --- /dev/null +++ b/internal/http/user_handlers.go @@ -0,0 +1,147 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/services" + "github.com/aleksandr/strive-api/internal/validation" + "github.com/google/uuid" +) + +type UserHandlers struct { + userService services.UserService + logger *logger.Logger + securityLogger *SecurityLogger +} + +func NewUserHandlers(userService services.UserService, logger *logger.Logger) *UserHandlers { + return &UserHandlers{ + userService: userService, + logger: logger, + securityLogger: NewSecurityLogger(logger), + } +} + +type UpdateUserThemeRequest struct { + Theme string `json:"theme" validate:"required,oneof=light dark" example:"dark"` +} + +// Me returns information about the current authenticated user +// @Summary Get current user profile +// @Description Returns detailed information about the currently authenticated user including theme +// @Tags user +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.User "User profile information" +// @Failure 401 {object} ErrorResponse "Unauthorized" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/user/me [get] +func (h *UserHandlers) Me(w http.ResponseWriter, r *http.Request) { + userID, ok := GetUserIDFromContext(r.Context()) + if !ok { + h.logger.Error("User ID not found in context") + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"User ID not found in context"}}`, http.StatusInternalServerError) + return + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + h.logger.Error("Invalid user ID format", "error", err, "user_id", userID) + http.Error(w, `{"error":{"code":"INVALID_USER_ID","message":"Invalid user ID format"}}`, http.StatusInternalServerError) + return + } + + user, err := h.userService.GetUserProfile(r.Context(), userUUID) + if err != nil { + h.logger.Error("Failed to get user profile", "error", err, "user_id", userID) + http.Error(w, `{"error":{"code":"USER_NOT_FOUND","message":"User not found"}}`, http.StatusNotFound) + return + } + + h.logger.Info("User profile requested", "user_id", userID, "email", user.Email, "theme", user.Theme) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(user) +} + +// UpdateTheme godoc +// @Summary Update user theme +// @Description Update the theme preference for the current user +// @Tags user +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body UpdateUserThemeRequest true "Theme update data" +// @Success 200 {object} map[string]interface{} "Theme updated successfully" +// @Failure 400 {object} ErrorResponse "Invalid request data" +// @Failure 401 {object} ErrorResponse "Unauthorized" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/user/theme [put] +func (h *UserHandlers) UpdateTheme(w http.ResponseWriter, r *http.Request) { + userID, ok := GetUserIDFromContext(r.Context()) + if !ok { + h.logger.Error("User ID not found in context") + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"User ID not found in context"}}`, http.StatusInternalServerError) + return + } + + var req UpdateUserThemeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to decode theme update request", "error", err) + http.Error(w, `{"error":{"code":"INVALID_REQUEST","message":"Invalid JSON"}}`, http.StatusBadRequest) + return + } + + var validationErrors validation.ValidationErrors + if req.Theme != "light" && req.Theme != "dark" { + validationErrors = append(validationErrors, validation.ValidationError{ + Field: "theme", + Message: "theme must be either 'light' or 'dark'", + }) + } + + if len(validationErrors) > 0 { + h.logger.Warn("Validation failed for theme update request", "errors", validationErrors) + var errorMessages []string + for _, err := range validationErrors { + errorMessages = append(errorMessages, err.Message) + } + h.securityLogger.LogInvalidInput(r, errorMessages) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(validationErrors.ToJSON()) + return + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + h.logger.Error("Invalid user ID format", "error", err, "user_id", userID) + http.Error(w, `{"error":{"code":"INVALID_USER_ID","message":"Invalid user ID format"}}`, http.StatusInternalServerError) + return + } + + if err := h.userService.UpdateUserTheme(r.Context(), userUUID, req.Theme); err != nil { + h.logger.Error("Failed to update user theme", "error", err, "user_id", userID, "theme", req.Theme) + if err == services.ErrInvalidTheme { + http.Error(w, `{"error":{"code":"INVALID_THEME","message":"Invalid theme value"}}`, http.StatusBadRequest) + } else { + http.Error(w, `{"error":{"code":"THEME_UPDATE_FAILED","message":"Failed to update theme"}}`, http.StatusInternalServerError) + } + return + } + + h.logger.Info("User theme updated successfully", "user_id", userID, "theme", req.Theme) + + response := map[string]interface{}{ + "message": "Theme updated successfully", + "theme": req.Theme, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} diff --git a/internal/models/user.go b/internal/models/user.go index b65161d..922133e 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -10,6 +10,7 @@ type User struct { ID uuid.UUID `json:"id" db:"id"` Email string `json:"email" db:"email"` PasswordHash string `json:"-" db:"password_hash"` + Theme string `json:"theme" db:"theme"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -27,3 +28,7 @@ type ChangePasswordRequest struct { CurrentPassword string `json:"current_password" validate:"required"` NewPassword string `json:"new_password" validate:"required,min=8"` } + +type UpdateUserThemeRequest struct { + Theme string `json:"theme" validate:"required,oneof=light dark"` +} diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go index 99097ce..2b32d11 100644 --- a/internal/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -14,6 +14,7 @@ type UserRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) GetByEmail(ctx context.Context, email string) (*models.User, error) Update(ctx context.Context, user *models.User) error + UpdateTheme(ctx context.Context, userID uuid.UUID, theme string) error Delete(ctx context.Context, id uuid.UUID) error } @@ -29,11 +30,11 @@ func NewUserRepository(pool *pgxpool.Pool) UserRepository { func (r *userRepository) Create(ctx context.Context, user *models.User) error { query := ` - INSERT INTO users (id, email, password_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO users (id, email, password_hash, theme, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) ` - _, err := r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.CreatedAt, user.UpdatedAt) + _, err := r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.Theme, user.CreatedAt, user.UpdatedAt) if err != nil { return fmt.Errorf("failed to create user: %w", err) } @@ -43,58 +44,51 @@ func (r *userRepository) Create(ctx context.Context, user *models.User) error { func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) { query := ` - SELECT id, email, password_hash, created_at, updated_at + SELECT id, email, password_hash, theme, created_at, updated_at FROM users WHERE id = $1 ` - user := &models.User{} - err := r.pool.QueryRow(ctx, query, id).Scan( - &user.ID, - &user.Email, - &user.PasswordHash, - &user.CreatedAt, - &user.UpdatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to get user by id: %w", err) - } - - return user, nil + row := r.pool.QueryRow(ctx, query, id) + return r.scanUser(row, "failed to get user by id") } func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { query := ` - SELECT id, email, password_hash, created_at, updated_at + SELECT id, email, password_hash, theme, created_at, updated_at FROM users WHERE email = $1 ` - user := &models.User{} - err := r.pool.QueryRow(ctx, query, email).Scan( - &user.ID, - &user.Email, - &user.PasswordHash, - &user.CreatedAt, - &user.UpdatedAt, - ) + row := r.pool.QueryRow(ctx, query, email) + return r.scanUser(row, "failed to get user by email") +} + +func (r *userRepository) Update(ctx context.Context, user *models.User) error { + query := ` + UPDATE users + SET email = $2, password_hash = $3, theme = $4, updated_at = $5 + WHERE id = $1 + ` + + _, err := r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.Theme, user.UpdatedAt) if err != nil { - return nil, fmt.Errorf("failed to get user by email: %w", err) + return fmt.Errorf("failed to update user: %w", err) } - return user, nil + return nil } -func (r *userRepository) Update(ctx context.Context, user *models.User) error { +func (r *userRepository) UpdateTheme(ctx context.Context, userID uuid.UUID, theme string) error { query := ` UPDATE users - SET email = $2, password_hash = $3, updated_at = $4 + SET theme = $2, updated_at = NOW() WHERE id = $1 ` - _, err := r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.UpdatedAt) + _, err := r.pool.Exec(ctx, query, userID, theme) if err != nil { - return fmt.Errorf("failed to update user: %w", err) + return fmt.Errorf("failed to update user theme: %w", err) } return nil @@ -110,3 +104,20 @@ func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { return nil } + +func (r *userRepository) scanUser(row interface{ Scan(...interface{}) error }, errorPrefix string) (*models.User, error) { + user := &models.User{} + err := row.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Theme, + &user.CreatedAt, + &user.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("%s: %w", errorPrefix, err) + } + + return user, nil +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index aba76d7..563d2d9 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -83,6 +83,7 @@ func (s *authService) Register(ctx context.Context, req *models.CreateUserReques ID: uuid.New(), Email: normalizedEmail, PasswordHash: hashedPassword, + Theme: "light", CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index de5fded..c5ee332 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -45,6 +45,16 @@ func (m *mockUserRepository) Update(ctx context.Context, user *models.User) erro return nil } +func (m *mockUserRepository) UpdateTheme(ctx context.Context, userID uuid.UUID, theme string) error { + for _, user := range m.users { + if user.ID == userID { + user.Theme = theme + return nil + } + } + return fmt.Errorf("user not found") +} + func (m *mockUserRepository) Delete(ctx context.Context, id uuid.UUID) error { for email, user := range m.users { if user.ID == id { @@ -139,6 +149,10 @@ func TestAuthService_Register(t *testing.T) { t.Error("Password should be hashed") } + if user.Theme != "light" { + t.Errorf("Expected theme 'light', got %s", user.Theme) + } + if user.ID == uuid.Nil { t.Error("User ID should be generated") } diff --git a/internal/services/user_service.go b/internal/services/user_service.go new file mode 100644 index 0000000..6326d30 --- /dev/null +++ b/internal/services/user_service.go @@ -0,0 +1,52 @@ +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/aleksandr/strive-api/internal/models" + "github.com/aleksandr/strive-api/internal/repositories" + "github.com/google/uuid" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrInvalidTheme = errors.New("invalid theme value") +) + +type UserService interface { + GetUserProfile(ctx context.Context, userID uuid.UUID) (*models.User, error) + UpdateUserTheme(ctx context.Context, userID uuid.UUID, theme string) error +} + +type userService struct { + userRepo repositories.UserRepository +} + +func NewUserService(userRepo repositories.UserRepository) UserService { + return &userService{ + userRepo: userRepo, + } +} + +func (s *userService) GetUserProfile(ctx context.Context, userID uuid.UUID) (*models.User, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get user profile: %w", err) + } + + return user, nil +} + +func (s *userService) UpdateUserTheme(ctx context.Context, userID uuid.UUID, theme string) error { + if theme != "light" && theme != "dark" { + return ErrInvalidTheme + } + + if err := s.userRepo.UpdateTheme(ctx, userID, theme); err != nil { + return fmt.Errorf("failed to update user theme: %w", err) + } + + return nil +} diff --git a/migrations/000003_add_user_theme.down.sql b/migrations/000003_add_user_theme.down.sql new file mode 100644 index 0000000..8d1ccd3 --- /dev/null +++ b/migrations/000003_add_user_theme.down.sql @@ -0,0 +1,2 @@ +-- Remove theme column from users table +ALTER TABLE users DROP COLUMN theme; diff --git a/migrations/000003_add_user_theme.up.sql b/migrations/000003_add_user_theme.up.sql new file mode 100644 index 0000000..6d866a5 --- /dev/null +++ b/migrations/000003_add_user_theme.up.sql @@ -0,0 +1,5 @@ +-- Add theme column to users table +ALTER TABLE users ADD COLUMN theme VARCHAR(10) DEFAULT 'light' NOT NULL; + +-- Add constraint to ensure only valid theme values +ALTER TABLE users ADD CONSTRAINT users_theme_check CHECK (theme IN ('light', 'dark'));