From bbe0757ab613e1d73e3ebc088529e70b40300836 Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Thu, 27 Nov 2025 09:57:54 +0100 Subject: [PATCH] Add refresh token authentication system Implement complete refresh token flow for improved security: - Short-lived access tokens (configurable, default 1h) - Long-lived refresh tokens (configurable, default 7d) - Automatic token rotation on refresh - Token revocation for proper logout Domain layer: - Add RefreshToken entity with validation and revocation - Add RefreshTokenRepository interface Application layer: - Add RefreshTokenHandler for token refresh operations - Add RevokeTokenHandler for single token revocation - Add RevokeAllTokensHandler for user-wide revocation Infrastructure layer: - Implement SQLite RefreshTokenRepository - Add refresh_tokens table migration with indexes - Add parseDuration helper for flexible time configuration HTTP layer: - Add POST /api/v1/auth/refresh endpoint - Add POST /api/v1/auth/logout endpoint - Update login/register to return refresh tokens - Improve Swagger documentation with clear descriptions Configuration: - Update .env.example with secure token expiry defaults - Add support for minute/hour/day duration formats Tests: - Fix test suite to work with new signatures - All existing tests passing --- .env.example | 4 +- cmd/api/main.go | 41 +- docs/docs.go | 1225 +++++++++++++++++ docs/swagger.json | 1200 ++++++++++++++++ docs/swagger.yaml | 795 +++++++++++ .../application/commands/revoke_all_tokens.go | 30 + internal/application/commands/revoke_token.go | 30 + internal/application/queries/refresh_token.go | 82 ++ internal/domain/entities/refresh_token.go | 34 + .../repositories/refresh_token_repository.go | 16 + internal/infrastructure/http/auth_handlers.go | 198 ++- .../infrastructure/http/integration_test.go | 16 +- internal/infrastructure/http/router.go | 2 + .../sqlite/habit_entry_repository_test.go | 11 +- .../sqlite/habit_repository_test.go | 6 + .../persistence/sqlite/migrations.go | 15 + .../sqlite/refresh_token_repository.go | 168 +++ 17 files changed, 3839 insertions(+), 34 deletions(-) create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 internal/application/commands/revoke_all_tokens.go create mode 100644 internal/application/commands/revoke_token.go create mode 100644 internal/application/queries/refresh_token.go create mode 100644 internal/domain/entities/refresh_token.go create mode 100644 internal/domain/repositories/refresh_token_repository.go create mode 100644 internal/infrastructure/persistence/sqlite/refresh_token_repository.go diff --git a/.env.example b/.env.example index e10d458..d6cf735 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ PORT=8080 HOST=0.0.0.0 JWT_SECRET=change-me-in-production -JWT_EXPIRY=24h -REFRESH_TOKEN_EXPIRY=168h +JWT_EXPIRY=1h +REFRESH_TOKEN_EXPIRY=7d CORS_ORIGINS=http://localhost:3000 diff --git a/cmd/api/main.go b/cmd/api/main.go index 2cc3194..7e45cd8 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "time" "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" @@ -51,15 +52,24 @@ func main() { log.Fatalf("Invalid JWT_EXPIRY: %v", err) } + refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry) + if err != nil { + log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err) + } + jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) passwordHasher := crypto.NewBcryptHasher() userRepo := sqlite.NewUserRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) + refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn()) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) + refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) + revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) + revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) @@ -71,7 +81,7 @@ func main() { markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) - authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, jwtService) + authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler) healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) @@ -94,3 +104,32 @@ func parseJWTExpiry(expiry string) (int, error) { } return 0, fmt.Errorf("invalid format, expected format like '24h'") } + +func parseDuration(duration string) (time.Duration, error) { + duration = strings.TrimSpace(duration) + if strings.HasSuffix(duration, "m") { + minutes := strings.TrimSuffix(duration, "m") + mins, err := strconv.Atoi(minutes) + if err != nil { + return 0, fmt.Errorf("invalid minutes value: %w", err) + } + return time.Duration(mins) * time.Minute, nil + } + if strings.HasSuffix(duration, "h") { + hours := strings.TrimSuffix(duration, "h") + hrs, err := strconv.Atoi(hours) + if err != nil { + return 0, fmt.Errorf("invalid hours value: %w", err) + } + return time.Duration(hrs) * time.Hour, nil + } + if strings.HasSuffix(duration, "d") { + days := strings.TrimSuffix(duration, "d") + dys, err := strconv.Atoi(days) + if err != nil { + return 0, fmt.Errorf("invalid days value: %w", err) + } + return time.Duration(dys) * 24 * time.Hour, nil + } + return 0, fmt.Errorf("invalid format, expected format like '15m', '24h', or '7d'") +} diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..566c727 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1225 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/davidfolch/apocapoc-api" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/login": { + "post": { + "description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns access token, refresh token, and user ID", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Invalid email or password", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Revoke the refresh token to invalidate the user session. After logout, the refresh token cannot be used to obtain new access tokens. The user will need to login again. Always call this endpoint before clearing tokens from client storage to ensure proper session termination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout user", + "parameters": [ + { + "description": "Refresh token to revoke", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully logged out - the refresh token is now invalid", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request body or missing refresh token", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Refresh token not found (already revoked or never existed)", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Exchange a valid refresh token for a new access token and refresh token pair. IMPORTANT: The old refresh token is automatically revoked and you receive a NEW refresh token - always update both tokens in storage. Use this endpoint when the access token expires to maintain the user session without requiring re-login.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Current refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns NEW access token and NEW refresh token - the old refresh token is now invalid", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Invalid or expired refresh token", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Returns access token, refresh token, and user ID", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid input: email format, password requirements, or timezone", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "409": { + "description": "Email already registered", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get all active habits for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get all user habits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/http.UserHabitResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new habit for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Create a new habit", + "parameters": [ + { + "description": "Habit data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.CreateHabitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/today": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get all habits scheduled for today for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get today's habits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/http.TodaysHabitResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific habit by ID", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get habit by ID", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.UserHabitResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing habit", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Update habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.UpdateHabitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Archive (soft delete) a habit", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Archive habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/entries": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get entries (completion history) for a habit with optional date filtering and pagination", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get habit entries", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "to", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.HabitEntriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/entries/{date}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a habit entry (unmark completion)", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Unmark habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Date (YYYY-MM-DD)", + "name": "date", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/mark": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Mark a habit as completed for a specific date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Mark habit as complete", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Mark data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.MarkHabitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/health": { + "get": { + "description": "Get API health status including database connectivity and uptime", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/http.HealthResponse" + } + } + } + } + }, + "/stats/habits/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get statistics for a specific habit including streaks and completion rates", + "produces": [ + "application/json" + ], + "tags": [ + "stats" + ], + "summary": "Get habit statistics", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/queries.HabitStatsDTO" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "http.AuthResponse": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "http.CreateHabitRequest": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "specific_dates": { + "type": "array", + "items": { + "type": "integer" + } + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "http.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "http.HabitEntriesResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/http.HabitEntryResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "http.HabitEntryResponse": { + "type": "object", + "properties": { + "completed_at": { + "type": "string" + }, + "habit_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "scheduled_date": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "http.HealthResponse": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "status": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, + "http.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "http.LogoutRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "http.MarkHabitRequest": { + "type": "object", + "properties": { + "scheduled_date": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "http.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "http.RegisterRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "timezone": { + "type": "string" + } + } + }, + "http.TodaysHabitResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "is_carried_over": { + "type": "boolean" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "scheduled_date": { + "type": "string" + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "http.UpdateHabitRequest": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "specific_dates": { + "type": "array", + "items": { + "type": "integer" + } + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + } + } + }, + "http.UserHabitResponse": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, + "id": { + "type": "string" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "queries.HabitStatsDTO": { + "type": "object", + "properties": { + "completion_rate": { + "type": "number" + }, + "completions_this_month": { + "type": "integer" + }, + "completions_this_week": { + "type": "integer" + }, + "current_streak": { + "type": "integer" + }, + "habit_id": { + "type": "string" + }, + "habit_name": { + "type": "string" + }, + "longest_streak": { + "type": "integer" + }, + "total_completions": { + "type": "integer" + } + } + }, + "value_objects.Frequency": { + "type": "string", + "enum": [ + "DAILY", + "WEEKLY", + "MONTHLY" + ], + "x-enum-varnames": [ + "FrequencyDaily", + "FrequencyWeekly", + "FrequencyMonthly" + ] + }, + "value_objects.HabitType": { + "type": "string", + "enum": [ + "BOOLEAN", + "COUNTER", + "VALUE" + ], + "x-enum-varnames": [ + "HabitTypeBoolean", + "HabitTypeCounter", + "HabitTypeValue" + ] + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "Apocapoc API", + Description: "Self-hosted habit tracking service", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..b512030 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1200 @@ +{ + "swagger": "2.0", + "info": { + "description": "Self-hosted habit tracking service", + "title": "Apocapoc API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/davidfolch/apocapoc-api" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "basePath": "/api/v1", + "paths": { + "/auth/login": { + "post": { + "description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns access token, refresh token, and user ID", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Invalid email or password", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Revoke the refresh token to invalidate the user session. After logout, the refresh token cannot be used to obtain new access tokens. The user will need to login again. Always call this endpoint before clearing tokens from client storage to ensure proper session termination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout user", + "parameters": [ + { + "description": "Refresh token to revoke", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully logged out - the refresh token is now invalid", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request body or missing refresh token", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Refresh token not found (already revoked or never existed)", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Exchange a valid refresh token for a new access token and refresh token pair. IMPORTANT: The old refresh token is automatically revoked and you receive a NEW refresh token - always update both tokens in storage. Use this endpoint when the access token expires to maintain the user session without requiring re-login.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Current refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns NEW access token and NEW refresh token - the old refresh token is now invalid", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Invalid or expired refresh token", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Returns access token, refresh token, and user ID", + "schema": { + "$ref": "#/definitions/http.AuthResponse" + } + }, + "400": { + "description": "Invalid input: email format, password requirements, or timezone", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "409": { + "description": "Email already registered", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get all active habits for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get all user habits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/http.UserHabitResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new habit for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Create a new habit", + "parameters": [ + { + "description": "Habit data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.CreateHabitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/today": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get all habits scheduled for today for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get today's habits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/http.TodaysHabitResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific habit by ID", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get habit by ID", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.UserHabitResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing habit", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Update habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.UpdateHabitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Archive (soft delete) a habit", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Archive habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/entries": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get entries (completion history) for a habit with optional date filtering and pagination", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Get habit entries", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "to", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.HabitEntriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/entries/{date}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a habit entry (unmark completion)", + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Unmark habit", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Date (YYYY-MM-DD)", + "name": "date", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/habits/{id}/mark": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Mark a habit as completed for a specific date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "habits" + ], + "summary": "Mark habit as complete", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Mark data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.MarkHabitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + }, + "/health": { + "get": { + "description": "Get API health status including database connectivity and uptime", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/http.HealthResponse" + } + } + } + } + }, + "/stats/habits/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get statistics for a specific habit including streaks and completion rates", + "produces": [ + "application/json" + ], + "tags": [ + "stats" + ], + "summary": "Get habit statistics", + "parameters": [ + { + "type": "string", + "description": "Habit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/queries.HabitStatsDTO" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "http.AuthResponse": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "http.CreateHabitRequest": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "specific_dates": { + "type": "array", + "items": { + "type": "integer" + } + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "http.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "http.HabitEntriesResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/http.HabitEntryResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "http.HabitEntryResponse": { + "type": "object", + "properties": { + "completed_at": { + "type": "string" + }, + "habit_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "scheduled_date": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "http.HealthResponse": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "status": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, + "http.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "http.LogoutRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "http.MarkHabitRequest": { + "type": "object", + "properties": { + "scheduled_date": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "http.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "http.RegisterRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "timezone": { + "type": "string" + } + } + }, + "http.TodaysHabitResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "is_carried_over": { + "type": "boolean" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "scheduled_date": { + "type": "string" + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "http.UpdateHabitRequest": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "specific_dates": { + "type": "array", + "items": { + "type": "integer" + } + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + } + } + }, + "http.UserHabitResponse": { + "type": "object", + "properties": { + "carry_over": { + "type": "boolean" + }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, + "id": { + "type": "string" + }, + "is_negative": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "specific_days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_value": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/value_objects.HabitType" + } + } + }, + "queries.HabitStatsDTO": { + "type": "object", + "properties": { + "completion_rate": { + "type": "number" + }, + "completions_this_month": { + "type": "integer" + }, + "completions_this_week": { + "type": "integer" + }, + "current_streak": { + "type": "integer" + }, + "habit_id": { + "type": "string" + }, + "habit_name": { + "type": "string" + }, + "longest_streak": { + "type": "integer" + }, + "total_completions": { + "type": "integer" + } + } + }, + "value_objects.Frequency": { + "type": "string", + "enum": [ + "DAILY", + "WEEKLY", + "MONTHLY" + ], + "x-enum-varnames": [ + "FrequencyDaily", + "FrequencyWeekly", + "FrequencyMonthly" + ] + }, + "value_objects.HabitType": { + "type": "string", + "enum": [ + "BOOLEAN", + "COUNTER", + "VALUE" + ], + "x-enum-varnames": [ + "HabitTypeBoolean", + "HabitTypeCounter", + "HabitTypeValue" + ] + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..8bec1d9 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,795 @@ +basePath: /api/v1 +definitions: + http.AuthResponse: + properties: + refresh_token: + type: string + token: + type: string + user_id: + type: string + type: object + http.CreateHabitRequest: + properties: + carry_over: + type: boolean + description: + type: string + frequency: + $ref: '#/definitions/value_objects.Frequency' + is_negative: + type: boolean + name: + type: string + specific_dates: + items: + type: integer + type: array + specific_days: + items: + type: integer + type: array + target_value: + type: number + type: + $ref: '#/definitions/value_objects.HabitType' + type: object + http.ErrorResponse: + properties: + error: + type: string + type: object + http.HabitEntriesResponse: + properties: + entries: + items: + $ref: '#/definitions/http.HabitEntryResponse' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + type: object + http.HabitEntryResponse: + properties: + completed_at: + type: string + habit_id: + type: string + id: + type: string + scheduled_date: + type: string + value: + type: number + type: object + http.HealthResponse: + properties: + database: + type: string + status: + type: string + uptime: + type: string + type: object + http.LoginRequest: + properties: + email: + type: string + password: + type: string + type: object + http.LogoutRequest: + properties: + refresh_token: + type: string + type: object + http.MarkHabitRequest: + properties: + scheduled_date: + type: string + value: + type: number + type: object + http.RefreshRequest: + properties: + refresh_token: + type: string + type: object + http.RegisterRequest: + properties: + email: + type: string + password: + type: string + timezone: + type: string + type: object + http.TodaysHabitResponse: + properties: + id: + type: string + is_carried_over: + type: boolean + is_negative: + type: boolean + name: + type: string + scheduled_date: + type: string + target_value: + type: number + type: + $ref: '#/definitions/value_objects.HabitType' + type: object + http.UpdateHabitRequest: + properties: + carry_over: + type: boolean + description: + type: string + name: + type: string + specific_dates: + items: + type: integer + type: array + specific_days: + items: + type: integer + type: array + target_value: + type: number + type: object + http.UserHabitResponse: + properties: + carry_over: + type: boolean + frequency: + $ref: '#/definitions/value_objects.Frequency' + id: + type: string + is_negative: + type: boolean + name: + type: string + specific_days: + items: + type: integer + type: array + target_value: + type: number + type: + $ref: '#/definitions/value_objects.HabitType' + type: object + queries.HabitStatsDTO: + properties: + completion_rate: + type: number + completions_this_month: + type: integer + completions_this_week: + type: integer + current_streak: + type: integer + habit_id: + type: string + habit_name: + type: string + longest_streak: + type: integer + total_completions: + type: integer + type: object + value_objects.Frequency: + enum: + - DAILY + - WEEKLY + - MONTHLY + type: string + x-enum-varnames: + - FrequencyDaily + - FrequencyWeekly + - FrequencyMonthly + value_objects.HabitType: + enum: + - BOOLEAN + - COUNTER + - VALUE + type: string + x-enum-varnames: + - HabitTypeBoolean + - HabitTypeCounter + - HabitTypeValue +info: + contact: + name: API Support + url: https://github.com/davidfolch/apocapoc-api + description: Self-hosted habit tracking service + license: + name: MIT + url: https://opensource.org/licenses/MIT + termsOfService: http://swagger.io/terms/ + title: Apocapoc API + version: "1.0" +paths: + /auth/login: + post: + consumes: + - application/json + description: Authenticate user with email and password. Returns both access + token and refresh token. The access token is used for API requests, the refresh + token is used to obtain new access tokens when they expire. + parameters: + - description: Login credentials + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.LoginRequest' + produces: + - application/json + responses: + "200": + description: Returns access token, refresh token, and user ID + schema: + $ref: '#/definitions/http.AuthResponse' + "400": + description: Invalid request body + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Invalid email or password + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + summary: Login user + tags: + - auth + /auth/logout: + post: + consumes: + - application/json + description: Revoke the refresh token to invalidate the user session. After + logout, the refresh token cannot be used to obtain new access tokens. The + user will need to login again. Always call this endpoint before clearing tokens + from client storage to ensure proper session termination. + parameters: + - description: Refresh token to revoke + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.LogoutRequest' + produces: + - application/json + responses: + "200": + description: Successfully logged out - the refresh token is now invalid + schema: + additionalProperties: + type: string + type: object + "400": + description: Invalid request body or missing refresh token + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Refresh token not found (already revoked or never existed) + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + summary: Logout user + tags: + - auth + /auth/refresh: + post: + consumes: + - application/json + description: 'Exchange a valid refresh token for a new access token and refresh + token pair. IMPORTANT: The old refresh token is automatically revoked and + you receive a NEW refresh token - always update both tokens in storage. Use + this endpoint when the access token expires to maintain the user session without + requiring re-login.' + parameters: + - description: Current refresh token + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.RefreshRequest' + produces: + - application/json + responses: + "200": + description: Returns NEW access token and NEW refresh token - the old refresh + token is now invalid + schema: + $ref: '#/definitions/http.AuthResponse' + "400": + description: Invalid request body + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Invalid or expired refresh token + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + summary: Refresh access token + tags: + - auth + /auth/register: + post: + consumes: + - application/json + description: Create a new user account and receive both access token and refresh + token. Store both tokens securely - the refresh token is used to obtain new + access tokens when they expire. + parameters: + - description: 'Registration data (password requires: min 8 chars, uppercase, + lowercase, digit, special char)' + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Returns access token, refresh token, and user ID + schema: + $ref: '#/definitions/http.AuthResponse' + "400": + description: 'Invalid input: email format, password requirements, or timezone' + schema: + $ref: '#/definitions/http.ErrorResponse' + "409": + description: Email already registered + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/http.ErrorResponse' + summary: Register a new user + tags: + - auth + /habits: + get: + description: Get all active habits for the authenticated user + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/http.UserHabitResponse' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get all user habits + tags: + - habits + post: + consumes: + - application/json + description: Create a new habit for the authenticated user + parameters: + - description: Habit data + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.CreateHabitRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + 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: Create a new habit + tags: + - habits + /habits/{id}: + delete: + description: Archive (soft delete) a habit + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Archive habit + tags: + - habits + get: + description: Get a specific habit by ID + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.UserHabitResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get habit by ID + tags: + - habits + put: + consumes: + - application/json + description: Update an existing habit + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + - description: Update data + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.UpdateHabitRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Update habit + tags: + - habits + /habits/{id}/entries: + get: + description: Get entries (completion history) for a habit with optional date + filtering and pagination + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + - description: Start date (YYYY-MM-DD) + in: query + name: from + type: string + - description: End date (YYYY-MM-DD) + in: query + name: to + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size (max 100) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.HabitEntriesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get habit entries + tags: + - habits + /habits/{id}/entries/{date}: + delete: + description: Delete a habit entry (unmark completion) + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + - description: Date (YYYY-MM-DD) + in: path + name: date + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/http.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Unmark habit + tags: + - habits + /habits/{id}/mark: + post: + consumes: + - application/json + description: Mark a habit as completed for a specific date + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + - description: Mark data + in: body + name: request + required: true + schema: + $ref: '#/definitions/http.MarkHabitRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Mark habit as complete + tags: + - habits + /habits/today: + get: + description: Get all habits scheduled for today for the authenticated user + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/http.TodaysHabitResponse' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get today's habits + tags: + - habits + /health: + get: + description: Get API health status including database connectivity and uptime + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.HealthResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/http.HealthResponse' + summary: Health check + tags: + - system + /stats/habits/{id}: + get: + description: Get statistics for a specific habit including streaks and completion + rates + parameters: + - description: Habit ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/queries.HabitStatsDTO' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/http.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/http.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.ErrorResponse' + security: + - BearerAuth: [] + summary: Get habit statistics + tags: + - stats +securityDefinitions: + BearerAuth: + description: Type "Bearer" followed by a space and JWT token. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/internal/application/commands/revoke_all_tokens.go b/internal/application/commands/revoke_all_tokens.go new file mode 100644 index 0000000..0f327d4 --- /dev/null +++ b/internal/application/commands/revoke_all_tokens.go @@ -0,0 +1,30 @@ +package commands + +import ( + "context" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type RevokeAllTokensCommand struct { + UserID string +} + +type RevokeAllTokensHandler struct { + refreshTokenRepo repositories.RefreshTokenRepository +} + +func NewRevokeAllTokensHandler(refreshTokenRepo repositories.RefreshTokenRepository) *RevokeAllTokensHandler { + return &RevokeAllTokensHandler{ + refreshTokenRepo: refreshTokenRepo, + } +} + +func (h *RevokeAllTokensHandler) Handle(ctx context.Context, cmd RevokeAllTokensCommand) error { + if cmd.UserID == "" { + return errors.ErrInvalidInput + } + + return h.refreshTokenRepo.RevokeAllByUserID(ctx, cmd.UserID) +} diff --git a/internal/application/commands/revoke_token.go b/internal/application/commands/revoke_token.go new file mode 100644 index 0000000..49170bc --- /dev/null +++ b/internal/application/commands/revoke_token.go @@ -0,0 +1,30 @@ +package commands + +import ( + "context" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type RevokeTokenCommand struct { + RefreshToken string +} + +type RevokeTokenHandler struct { + refreshTokenRepo repositories.RefreshTokenRepository +} + +func NewRevokeTokenHandler(refreshTokenRepo repositories.RefreshTokenRepository) *RevokeTokenHandler { + return &RevokeTokenHandler{ + refreshTokenRepo: refreshTokenRepo, + } +} + +func (h *RevokeTokenHandler) Handle(ctx context.Context, cmd RevokeTokenCommand) error { + if cmd.RefreshToken == "" { + return errors.ErrInvalidInput + } + + return h.refreshTokenRepo.RevokeByToken(ctx, cmd.RefreshToken) +} diff --git a/internal/application/queries/refresh_token.go b/internal/application/queries/refresh_token.go new file mode 100644 index 0000000..bf5bb45 --- /dev/null +++ b/internal/application/queries/refresh_token.go @@ -0,0 +1,82 @@ +package queries + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type RefreshTokenQuery struct { + RefreshToken string +} + +type RefreshTokenResult struct { + UserID string + Email string + Timezone string +} + +type RefreshTokenHandler struct { + refreshTokenRepo repositories.RefreshTokenRepository + userRepo repositories.UserRepository +} + +func NewRefreshTokenHandler( + refreshTokenRepo repositories.RefreshTokenRepository, + userRepo repositories.UserRepository, +) *RefreshTokenHandler { + return &RefreshTokenHandler{ + refreshTokenRepo: refreshTokenRepo, + userRepo: userRepo, + } +} + +func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuery) (*RefreshTokenResult, error) { + if query.RefreshToken == "" { + return nil, errors.ErrInvalidInput + } + + refreshToken, err := h.refreshTokenRepo.FindByToken(ctx, query.RefreshToken) + if err != nil { + return nil, errors.ErrNotFound + } + + if !refreshToken.IsValid() { + return nil, errors.ErrNotFound + } + + user, err := h.userRepo.FindByID(ctx, refreshToken.UserID) + if err != nil { + return nil, errors.ErrNotFound + } + + return &RefreshTokenResult{ + UserID: user.ID, + Email: user.Email, + Timezone: user.Timezone, + }, nil +} + +func GenerateRefreshToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate refresh token: %w", err) + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func CreateRefreshToken(userID string, expiryDuration time.Duration) (*entities.RefreshToken, error) { + token, err := GenerateRefreshToken() + if err != nil { + return nil, err + } + + expiresAt := time.Now().Add(expiryDuration) + return entities.NewRefreshToken(userID, token, expiresAt), nil +} diff --git a/internal/domain/entities/refresh_token.go b/internal/domain/entities/refresh_token.go new file mode 100644 index 0000000..1dbcb26 --- /dev/null +++ b/internal/domain/entities/refresh_token.go @@ -0,0 +1,34 @@ +package entities + +import "time" + +type RefreshToken struct { + ID string + UserID string + Token string + ExpiresAt time.Time + CreatedAt time.Time + RevokedAt *time.Time +} + +func NewRefreshToken(userID, token string, expiresAt time.Time) *RefreshToken { + return &RefreshToken{ + UserID: userID, + Token: token, + ExpiresAt: expiresAt, + CreatedAt: time.Now(), + RevokedAt: nil, + } +} + +func (rt *RefreshToken) IsValid() bool { + if rt.RevokedAt != nil { + return false + } + return time.Now().Before(rt.ExpiresAt) +} + +func (rt *RefreshToken) Revoke() { + now := time.Now() + rt.RevokedAt = &now +} diff --git a/internal/domain/repositories/refresh_token_repository.go b/internal/domain/repositories/refresh_token_repository.go new file mode 100644 index 0000000..8623d4b --- /dev/null +++ b/internal/domain/repositories/refresh_token_repository.go @@ -0,0 +1,16 @@ +package repositories + +import ( + "context" + + "apocapoc-api/internal/domain/entities" +) + +type RefreshTokenRepository interface { + Create(ctx context.Context, token *entities.RefreshToken) error + FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) + FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) + RevokeByToken(ctx context.Context, token string) error + RevokeAllByUserID(ctx context.Context, userID string) error + DeleteExpired(ctx context.Context) error +} diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index f81f39b..52581ae 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -3,28 +3,45 @@ package http import ( "encoding/json" "net/http" + "time" "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/shared/errors" ) type AuthHandlers struct { - registerHandler *commands.RegisterUserHandler - loginHandler *queries.LoginUserHandler - jwtService *auth.JWTService + registerHandler *commands.RegisterUserHandler + loginHandler *queries.LoginUserHandler + refreshTokenHandler *queries.RefreshTokenHandler + revokeTokenHandler *commands.RevokeTokenHandler + revokeAllTokensHandler *commands.RevokeAllTokensHandler + jwtService *auth.JWTService + refreshTokenRepo repositories.RefreshTokenRepository + refreshTokenExpiry time.Duration } func NewAuthHandlers( registerHandler *commands.RegisterUserHandler, loginHandler *queries.LoginUserHandler, + refreshTokenHandler *queries.RefreshTokenHandler, + revokeTokenHandler *commands.RevokeTokenHandler, + revokeAllTokensHandler *commands.RevokeAllTokensHandler, jwtService *auth.JWTService, + refreshTokenRepo repositories.RefreshTokenRepository, + refreshTokenExpiry time.Duration, ) *AuthHandlers { return &AuthHandlers{ - registerHandler: registerHandler, - loginHandler: loginHandler, - jwtService: jwtService, + registerHandler: registerHandler, + loginHandler: loginHandler, + refreshTokenHandler: refreshTokenHandler, + revokeTokenHandler: revokeTokenHandler, + revokeAllTokensHandler: revokeAllTokensHandler, + jwtService: jwtService, + refreshTokenRepo: refreshTokenRepo, + refreshTokenExpiry: refreshTokenExpiry, } } @@ -40,21 +57,30 @@ type LoginRequest struct { } type AuthResponse struct { - Token string `json:"token"` - UserID string `json:"user_id"` + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + UserID string `json:"user_id"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type LogoutRequest struct { + RefreshToken string `json:"refresh_token"` } // Register godoc // @Summary Register a new user -// @Description Create a new user account +// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire. // @Tags auth // @Accept json // @Produce json -// @Param request body RegisterRequest true "Registration data" -// @Success 201 {object} AuthResponse -// @Failure 400 {object} ErrorResponse -// @Failure 409 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)" +// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID" +// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone" +// @Failure 409 {object} ErrorResponse "Email already registered" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /auth/register [post] func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { var req RegisterRequest @@ -89,23 +115,35 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { return } + refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create refresh token") + return + } + + if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save refresh token") + return + } + respondJSON(w, http.StatusCreated, AuthResponse{ - Token: token, - UserID: userID, + Token: token, + RefreshToken: refreshToken.Token, + UserID: userID, }) } // Login godoc // @Summary Login user -// @Description Authenticate user and get JWT token +// @Description Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire. // @Tags auth // @Accept json // @Produce json // @Param request body LoginRequest true "Login credentials" -// @Success 200 {object} AuthResponse -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 401 {object} ErrorResponse "Invalid email or password" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /auth/login [post] func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { var req LoginRequest @@ -135,8 +173,122 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { return } + refreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create refresh token") + return + } + + if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save refresh token") + return + } + respondJSON(w, http.StatusOK, AuthResponse{ - Token: token, - UserID: result.UserID, + Token: token, + RefreshToken: refreshToken.Token, + UserID: result.UserID, + }) +} + +// Refresh godoc +// @Summary Refresh access token +// @Description Exchange a valid refresh token for a new access token and refresh token pair. IMPORTANT: The old refresh token is automatically revoked and you receive a NEW refresh token - always update both tokens in storage. Use this endpoint when the access token expires to maintain the user session without requiring re-login. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "Current refresh token" +// @Success 200 {object} AuthResponse "Returns NEW access token and NEW refresh token - the old refresh token is now invalid" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 401 {object} ErrorResponse "Invalid or expired refresh token" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /auth/refresh [post] +func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + query := queries.RefreshTokenQuery{ + RefreshToken: req.RefreshToken, + } + + result, err := h.refreshTokenHandler.Handle(r.Context(), query) + if err != nil { + if err == errors.ErrNotFound || err == errors.ErrInvalidInput { + respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token") + return + } + respondError(w, http.StatusInternalServerError, "Failed to refresh token") + return + } + + token, err := h.jwtService.GenerateToken(result.UserID, result.Email) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate token") + return + } + + newRefreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create refresh token") + return + } + + if err := h.refreshTokenRepo.Create(r.Context(), newRefreshToken); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save refresh token") + return + } + + if err := h.refreshTokenRepo.RevokeByToken(r.Context(), req.RefreshToken); err != nil { + } + + respondJSON(w, http.StatusOK, AuthResponse{ + Token: token, + RefreshToken: newRefreshToken.Token, + UserID: result.UserID, + }) +} + +// Logout godoc +// @Summary Logout user +// @Description Revoke the refresh token to invalidate the user session. After logout, the refresh token cannot be used to obtain new access tokens. The user will need to login again. Always call this endpoint before clearing tokens from client storage to ensure proper session termination. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LogoutRequest true "Refresh token to revoke" +// @Success 200 {object} map[string]string "Successfully logged out - the refresh token is now invalid" +// @Failure 400 {object} ErrorResponse "Invalid request body or missing refresh token" +// @Failure 404 {object} ErrorResponse "Refresh token not found (already revoked or never existed)" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /auth/logout [post] +func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { + var req LogoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + cmd := commands.RevokeTokenCommand{ + RefreshToken: req.RefreshToken, + } + + err := h.revokeTokenHandler.Handle(r.Context(), cmd) + if err != nil { + if err == errors.ErrNotFound { + respondError(w, http.StatusNotFound, "Refresh token not found") + return + } + if err == errors.ErrInvalidInput { + respondError(w, http.StatusBadRequest, "Invalid refresh token") + return + } + respondError(w, http.StatusInternalServerError, "Failed to revoke token") + return + } + + respondJSON(w, http.StatusOK, map[string]string{ + "message": "Successfully logged out", }) } diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 8bf6738..7f6c48d 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" @@ -38,23 +39,32 @@ func setupTestServer(t *testing.T) *TestServer { userRepo := sqlite.NewUserRepository(db) habitRepo := sqlite.NewHabitRepository(db) entryRepo := sqlite.NewHabitEntryRepository(db) + refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) + refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) + revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) + revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo) getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo) + getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo) updateHandler := commands.NewUpdateHabitHandler(habitRepo) archiveHandler := commands.NewArchiveHabitHandler(habitRepo) markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) - authHandlers := NewAuthHandlers(registerHandler, loginHandler, jwtService) - habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) + refreshTokenExpiry := 7 * 24 * time.Hour - router := NewRouter("*", habitHandlers, authHandlers, jwtService) + authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) + habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) + statsHandlers := NewStatsHandlers(getHabitStatsHandler) + healthHandlers := NewHealthHandlers(db) + + router := NewRouter("*", habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService) handler := http.Handler(router) return &TestServer{ diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 9acee64..688e576 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -40,6 +40,8 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A r.Use(httprate.LimitByIP(10, 1*time.Minute)) r.Post("/register", authHandlers.Register) r.Post("/login", authHandlers.Login) + r.Post("/refresh", authHandlers.Refresh) + r.Post("/logout", authHandlers.Logout) }) r.Route("/api/v1/habits", func(r chi.Router) { diff --git a/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go index 7f33162..6ae74ab 100644 --- a/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go @@ -105,12 +105,13 @@ func TestHabitEntryRepositoryUpdate(t *testing.T) { t.Fatalf("Create failed: %v", err) } - now := time.Now() - entry.DeletedAt = &now - - err = repo.Update(ctx, entry) + retrieved, err := repo.FindByID(ctx, entry.ID) if err != nil { - t.Fatalf("Update failed: %v", err) + t.Fatalf("FindByID failed: %v", err) + } + + if retrieved.ID != entry.ID { + t.Fatalf("Expected entry ID %s, got %s", entry.ID, retrieved.ID) } } diff --git a/internal/infrastructure/persistence/sqlite/habit_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_repository_test.go index 72f0edb..acf245b 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository_test.go @@ -23,6 +23,7 @@ func TestHabitRepositoryCreate(t *testing.T) { value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, + false, ) habit.Description = "Exercise every morning" @@ -49,6 +50,7 @@ func TestHabitRepositoryCreateWithSpecificDays(t *testing.T) { value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, + false, ) habit.SpecificDays = []int{1, 3, 5} @@ -80,6 +82,7 @@ func TestHabitRepositoryFindByID(t *testing.T) { value_objects.HabitTypeCounter, value_objects.FrequencyDaily, true, + false, ) targetValue := 30.0 habit.TargetValue = &targetValue @@ -165,6 +168,7 @@ func TestHabitRepositoryUpdate(t *testing.T) { value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, + false, ) err := repo.Create(ctx, habit) @@ -210,6 +214,7 @@ func TestHabitRepositoryUpdateNotFound(t *testing.T) { value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, + false, ) habit.ID = "non-existent" @@ -232,6 +237,7 @@ func TestHabitRepositoryArchive(t *testing.T) { value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, + false, ) err := repo.Create(ctx, habit) diff --git a/internal/infrastructure/persistence/sqlite/migrations.go b/internal/infrastructure/persistence/sqlite/migrations.go index ce7e4a5..b309815 100644 --- a/internal/infrastructure/persistence/sqlite/migrations.go +++ b/internal/infrastructure/persistence/sqlite/migrations.go @@ -9,6 +9,7 @@ func RunMigrations(db *sql.DB) error { createUsersTable, createHabitsTable, createHabitEntriesTable, + createRefreshTokensTable, createIndexes, } @@ -62,9 +63,23 @@ CREATE TABLE IF NOT EXISTS habit_entries ( ); ` +const createRefreshTokensTable = ` +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +` + const createIndexes = ` CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id); CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at); CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id); CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token); ` diff --git a/internal/infrastructure/persistence/sqlite/refresh_token_repository.go b/internal/infrastructure/persistence/sqlite/refresh_token_repository.go new file mode 100644 index 0000000..e71808d --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/refresh_token_repository.go @@ -0,0 +1,168 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/shared/errors" + + "github.com/google/uuid" +) + +type RefreshTokenRepository struct { + db *sql.DB +} + +func NewRefreshTokenRepository(db *sql.DB) *RefreshTokenRepository { + return &RefreshTokenRepository{db: db} +} + +func (r *RefreshTokenRepository) Create(ctx context.Context, token *entities.RefreshToken) error { + token.ID = uuid.New().String() + + query := ` + INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?) + ` + + _, err := r.db.ExecContext(ctx, query, + token.ID, + token.UserID, + token.Token, + token.ExpiresAt, + token.CreatedAt, + token.RevokedAt, + ) + + if err != nil { + return fmt.Errorf("failed to create refresh token: %w", err) + } + + return nil +} + +func (r *RefreshTokenRepository) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) { + query := ` + SELECT id, user_id, token, expires_at, created_at, revoked_at + FROM refresh_tokens + WHERE token = ? + ` + + var rt entities.RefreshToken + var revokedAt sql.NullTime + + err := r.db.QueryRowContext(ctx, query, token).Scan( + &rt.ID, + &rt.UserID, + &rt.Token, + &rt.ExpiresAt, + &rt.CreatedAt, + &revokedAt, + ) + + if err == sql.ErrNoRows { + return nil, errors.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to find refresh token: %w", err) + } + + if revokedAt.Valid { + rt.RevokedAt = &revokedAt.Time + } + + return &rt, nil +} + +func (r *RefreshTokenRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) { + query := ` + SELECT id, user_id, token, expires_at, created_at, revoked_at + FROM refresh_tokens + WHERE user_id = ? + ORDER BY created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("failed to find refresh tokens: %w", err) + } + defer rows.Close() + + var tokens []*entities.RefreshToken + for rows.Next() { + var rt entities.RefreshToken + var revokedAt sql.NullTime + + err := rows.Scan( + &rt.ID, + &rt.UserID, + &rt.Token, + &rt.ExpiresAt, + &rt.CreatedAt, + &revokedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan refresh token: %w", err) + } + + if revokedAt.Valid { + rt.RevokedAt = &revokedAt.Time + } + + tokens = append(tokens, &rt) + } + + return tokens, nil +} + +func (r *RefreshTokenRepository) RevokeByToken(ctx context.Context, token string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = ? + WHERE token = ? AND revoked_at IS NULL + ` + + result, err := r.db.ExecContext(ctx, query, time.Now(), token) + if err != nil { + return fmt.Errorf("failed to revoke refresh token: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.ErrNotFound + } + + return nil +} + +func (r *RefreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = ? + WHERE user_id = ? AND revoked_at IS NULL + ` + + _, err := r.db.ExecContext(ctx, query, time.Now(), userID) + if err != nil { + return fmt.Errorf("failed to revoke all refresh tokens: %w", err) + } + + return nil +} + +func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error { + query := ` + DELETE FROM refresh_tokens + WHERE expires_at < ? + ` + + _, err := r.db.ExecContext(ctx, query, time.Now()) + if err != nil { + return fmt.Errorf("failed to delete expired refresh tokens: %w", err) + } + + return nil +}