diff --git a/README.md b/README.md index d40194c..8ce6a27 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,39 @@ # auth-go -A production-minded authentication starter for Go teams who want secure defaults without a giant framework. - -Build a REST API with Gin, PostgreSQL, OAuth, email verification, password reset, and a security-focused token lifecycle—ready to extend from a clean hexagonal architecture. +A production-minded Go authentication starter with Gin, PostgreSQL, OAuth, email verification, password reset, and a security-focused token lifecycle. Its hexagonal structure is designed to be extended without a large framework. ## Highlights -- **Argon2id passwords** — memory-hard password hashing with explicit parameters. -- **JWT access tokens** — short-lived tokens for authenticated API requests. -- **Opaque refresh tokens** — cryptographically random values with only HMAC-SHA-256 digests stored in PostgreSQL. -- **Refresh rotation and replay detection** — one-time refresh tokens with family-wide revocation. -- **Secure cookies** — HttpOnly, SameSite=Lax, and Secure in production. -- **OAuth login** — GitHub and Google providers. -- **Email workflows** — verification and password-reset flows. -- **Integration-ready CI** — PostgreSQL-backed tests run separately from the unit suite. -- **Bounded server lifecycle** — explicit HTTP timeouts and graceful SIGINT/SIGTERM shutdown. +- Argon2id passwords and short-lived JWT access tokens. +- Opaque, rotating refresh tokens with replay detection and family-wide revocation. +- GitHub/Google OAuth, email verification, and password recovery. +- Configurable authentication rate limiting, audit logs, and optional Prometheus metrics. ## Quick start -### Prerequisites - -- Go 1.25+ -- Docker and Docker Compose -- PostgreSQL, or the included Compose database -- `migrate` CLI for integration tests and migrations - -### Run locally +Requires Go 1.25+, Docker Compose, and the `migrate` CLI. ```bash git clone https://github.com/Jonathan0823/auth-go.git cd auth-go cp .env.example .env - +# Set the secrets and email credentials in .env before running. go mod download docker compose up -d db redis make migrate-up make run ``` -The API starts on `http://localhost:8080` by default. - -With `ENABLE_SWAGGER=true` outside production, interactive API documentation is available at [`http://localhost:8080/swagger/index.html`](http://localhost:8080/swagger/index.html). The generated Swagger 2.0 document is served at `/swagger/doc.json`. - -## Configuration - -The application uses one PostgreSQL connection string everywhere: `DATABASE_URL`. - -Start from `.env.example` and set at least: - -```dotenv -DATABASE_URL=postgres://postgres:postgres@localhost:5432/auth_go?sslmode=disable -JWT_ACCESS_SECRET=replace-with-a-long-random-secret -REFRESH_TOKEN_HASH_KEY=replace-with-a-separate-long-random-secret -ENABLE_SWAGGER=true -ENABLE_METRICS=false -SESSION_SECRET=replace-with-a-long-random-secret -``` - -Email credentials are required because registration and recovery send mail. OAuth providers are optional, but each configured client ID must have its matching secret: - -```dotenv -EMAIL=your-email@gmail.com -PASSWORD=your-gmail-app-password -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -``` - -Configuration is validated before the database or HTTP server starts. In production, JWT, refresh-token, session, and rate-limit secrets must each be at least 32 bytes. - -Authentication rate limiting supports `memory`, `redis`, and `postgres` backends. Memory is intended for development or an explicitly approved single-instance deployment; production should use Redis or PostgreSQL. Set `RATE_LIMIT_KEY` to a separate secret and configure `TRUSTED_PROXIES` only for known proxy networks. - -`make` loads `.env` automatically. Use another file explicitly when needed: - -```bash -ENV_FILE=.env.prod make run -``` - -## Database and tests - -```bash -make migrate-up # Apply migrations -make test # Unit tests -make integration # Migrate + PostgreSQL/Redis integration tests -make coverage # Integration tests + coverage/coverage.out -make vet # Static checks -make sqlc # Regenerate PostgreSQL code -make swagger # Regenerate Swagger artifacts -make swagger-validate # Validate the Swagger document -``` - -Integration tests use the `integration` build tag and require PostgreSQL. Redis-backed tests run when `REDIS_ADDR` is configured: +The API listens at `http://localhost:8080` by default. Swagger UI is available at `/swagger/index.html` when `ENABLE_SWAGGER=true` outside production. -```bash -go test -tags=integration ./... -``` - -## Observability - -- `GET /health/live` reports process liveness without checking PostgreSQL. -- `GET /health/ready` reports database readiness with a bounded PostgreSQL ping. -- `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`; it is an operational endpoint and is intentionally excluded from Swagger. -- Security audit events are emitted as structured JSON logs with request IDs and safe categorical context. -- Rate-limit denials and backend failures use low-cardinality audit and Prometheus events without raw identifiers. - -Metrics labels use route templates and avoid user-controlled values. Restrict `/metrics` to trusted monitoring systems in production. Grafana, Loki, and tracing infrastructure are intentionally not bundled. - -## Authentication model - -1. A successful login returns a short-lived JWT access token and an opaque refresh token in secure cookies. -2. Refresh tokens are random bearer values; no user data or JWT claims are embedded in them. -3. Only an HMAC digest of a refresh token is persisted. -4. Every refresh invalidates the previous token and issues a replacement. -5. Reusing a rotated token revokes the entire refresh-token family. -6. Access tokens remain stateless and expire after 15 minutes. - -> **Migration note:** The security migration invalidates existing JWT refresh sessions. Legacy bcrypt password hashes are not accepted; affected users must reset their password. - -## API documentation - -Swagger documentation is generated from Go annotations and committed under `docs/`. To regenerate it after changing a handler contract: - -```bash -make swagger -make swagger-validate -``` - -Swagger UI is opt-in and development-only. Set `ENABLE_SWAGGER=true` locally; it remains disabled whenever `ENVIRONMENT=production`. - -## API overview - -### Authentication - -- `POST /api/auth/register` -- `POST /api/auth/login` -- `POST /api/auth/refresh` -- `POST /api/auth/logout` -- `POST /api/auth/forgot-password` -- `POST /api/auth/reset-password` -- `GET /api/auth/verify/email` -- `POST /api/auth/verify/email/resend` - -### OAuth +## Documentation -- `GET /api/oauth/:provider/` -- `GET /api/oauth/:provider/callback` - -Supported providers: GitHub and Google. - -### Users - -- `GET /api/user/me` -- `GET /api/user/:id` -- `GET /api/user/get-all` -- `GET /api/user/email` -- `PATCH /api/user/update` -- `DELETE /api/user/delete/:id` - -## Project structure - -```text -cmd/ application entrypoint -docs/ generated Swagger specification and registration -internal/ - core/ domain models, ports, and services - adapter/ HTTP, PostgreSQL, JWT, password, OAuth, and email adapters - platform/ configuration, database, logging, and server setup -migrations/ PostgreSQL schema migrations -``` +- [Setup and operations](docs/setup.md) — configuration, migrations, testing, and observability. +- [Authentication and API](docs/authentication.md) — tokens, cookies, and routes. +- [Rate limiting](docs/rate-limiting.md) — policies, backends, and failure handling. +- [Storage and caching](docs/storage-and-caching.md) — PostgreSQL, Redis, and what is not cached. +- [Architecture](docs/architecture.md) — package boundaries and extension points. +- [Swagger specification](docs/swagger.yaml) — generated HTTP API contract. ## License diff --git a/cmd/auth-go/main.go b/cmd/auth-go/main.go index 1c0683c..02c0980 100644 --- a/cmd/auth-go/main.go +++ b/cmd/auth-go/main.go @@ -23,7 +23,7 @@ import ( "github.com/joho/godotenv" "github.com/Jonathan0823/auth-go/internal/bootstrap" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/config" ) func main() { @@ -31,7 +31,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := bootstrap.Run(ctx, platform.LoadConfig()); err != nil { + if err := bootstrap.Run(ctx, config.LoadConfig()); err != nil { log.Fatal(err) } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4a184a7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,25 @@ +# Architecture + +```text +cmd/ application entrypoint +internal/ + bootstrap/ dependency construction and resource lifecycle + config/ environment loading and validation + core/ + domain/ business models and errors + port/ application-owned contracts and inputs + ratelimit/ transport-independent rate-limit coordination + service/ authentication and user use cases + adapter/ + inbound/http/ Gin handlers, routing, DTOs, and HTTP middleware + outbound/ PostgreSQL, rate-limit stores, JWT, OAuth, password, and email + observability/ logging, metrics, and audit implementations +docs/ generated Swagger files and hand-written guides +migrations/ PostgreSQL schema migrations +``` + +Dependencies point inward: core code does not import HTTP, configuration, observability, or infrastructure adapters. `bootstrap` is the composition root that wires application-owned ports to concrete adapters and closes resources. The application decides *when* to apply account/email/token policies; HTTP owns client IP extraction and HTTP responses; stores implement persistence. See [Rate limiting](rate-limiting.md). + +JWT encoding and token hashing live in the outbound JWT adapter, not in the core service. HTTP request/response DTOs are mapped explicitly rather than exposing domain objects directly. If a future workflow needs a queue, its publisher belongs in an outbound adapter and its worker in an inbound adapter; this template does not implement a queue or outbox. + +Go source and test filenames under `cmd/` and `internal/` are lowercase and descriptive, with underscores between words. Test files append `_test.go`; tagged integration tests use `_integration_test.go`. Generator-controlled filenames remain unchanged. The test in `internal/architecture/` checks that core dependencies stay inward. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..1437684 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,48 @@ +# Authentication and API + +## Token lifecycle + +1. Registration stores an Argon2id password hash and sends a verification email. +2. Successful password login sets a short-lived JWT access token and a random opaque refresh token in HttpOnly, SameSite=Lax cookies (`Secure` in production). +3. The access token is signed, expires after 15 minutes, and is verified without a database lookup. Refresh tokens contain no JWT claims; only their HMAC-SHA-256 digests are stored in PostgreSQL. +4. Refresh rotates the one-time token. Reusing an already-used token revokes its entire token family. Logout revokes the family and clears the cookies. +5. Password recovery and verification tokens are stored in PostgreSQL; [rate limits](rate-limiting.md) protect these workflows. + +```mermaid +sequenceDiagram + participant Client + participant HTTP as HTTP adapter + participant Auth as Auth service + participant Tokens as JWT/token adapter + participant DB as PostgreSQL + Client->>HTTP: Login with credentials + HTTP->>Auth: Login + Auth->>DB: Load user and password hash + Auth->>Tokens: Sign access JWT and generate opaque refresh token plus HMAC + Auth->>DB: Store refresh-token HMAC and family ID + Auth-->>HTTP: Access and refresh tokens + HTTP-->>Client: Set HttpOnly cookies + Client->>HTTP: Refresh with refresh cookie + HTTP->>Auth: RefreshTokens + Auth->>Tokens: HMAC the presented token + Auth->>Tokens: Sign new JWT and generate replacement refresh token plus HMAC + Auth->>DB: Consume old token and create replacement in a transaction + Auth-->>HTTP: New access and refresh tokens + HTTP-->>Client: Replace cookies + Client->>HTTP: Reuse old refresh token + HTTP->>Auth: RefreshTokens + Auth->>DB: Revoke token family in a transaction + HTTP-->>Client: 401 unauthorized +``` + +Existing JWT refresh sessions are invalidated by the security migration. Legacy bcrypt password hashes are not accepted; affected users must reset their password. + +## Routes + +| Area | Endpoints | +| --- | --- | +| Authentication | `POST /api/auth/register`, `/login`, `/refresh`, `/logout`, `/forgot-password`, `/reset-password`; `GET /api/auth/verify/email`; `POST /api/auth/verify/email/resend` | +| OAuth | `GET /api/oauth/:provider/`, `/api/oauth/:provider/callback` (`github` and `google`) | +| Users | `GET /api/user/me`, `/api/user/:id`, `/api/user/get-all`, `/api/user/email`; `PATCH /api/user/update`; `DELETE /api/user/delete/:id` | + +See the generated [Swagger spec](swagger.yaml) or the development-only Swagger UI for request and response details. Handler routing lives in `internal/adapter/inbound/http/routes.go`. diff --git a/docs/docs.go b/docs/docs.go index c01c946..4ee5565 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -47,19 +47,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -94,7 +94,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -106,19 +106,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -143,7 +143,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -155,13 +155,13 @@ const docTemplate = `{ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -187,7 +187,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -199,13 +199,13 @@ const docTemplate = `{ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -240,25 +240,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -292,19 +292,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -333,19 +333,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -374,19 +374,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -419,7 +419,7 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -452,19 +452,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -498,31 +498,31 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -556,25 +556,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -599,19 +599,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UsersResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -636,19 +636,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -687,25 +687,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -739,25 +739,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -778,7 +778,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } } } @@ -799,13 +799,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } }, "503": { "description": "Service Unavailable", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } } } @@ -830,6 +830,15 @@ const docTemplate = `{ } } }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid input" + } + } + }, "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ForgotPasswordRequest": { "type": "object", "required": [ @@ -841,6 +850,24 @@ const docTemplate = `{ } } }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation completed successfully" + } + } + }, "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ResetPasswordRequest": { "type": "object", "required": [ @@ -913,34 +940,7 @@ const docTemplate = `{ } } }, - "internal_adapter_inbound_http.ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "invalid input" - } - } - }, - "internal_adapter_inbound_http.HealthResponse": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "ok" - } - } - }, - "internal_adapter_inbound_http.MessageResponse": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Operation completed successfully" - } - } - }, - "internal_adapter_inbound_http.UserResponseEnvelope": { + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope": { "type": "object", "properties": { "message": { @@ -952,7 +952,7 @@ const docTemplate = `{ } } }, - "internal_adapter_inbound_http.UsersResponseEnvelope": { + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope": { "type": "object", "properties": { "message": { diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md new file mode 100644 index 0000000..edf63d0 --- /dev/null +++ b/docs/rate-limiting.md @@ -0,0 +1,66 @@ +# Authentication rate limiting + +The HTTP adapter checks IP policies before calling auth services. Auth services check account/email/token policies so non-HTTP callers also get those limits. A request subject to both consumes the IP counter first, then the account/email/token counter. Each policy has a separate counter; requests denied by a counter are not incremented by that counter. + +Counters use a **fixed window starting with the first request**. The next window starts on the first request after expiry. Keys are HMAC-SHA-256 digests of the policy, dimension, and identifier using `RATE_LIMIT_KEY`; raw emails, tokens, and IP addresses are not stored as rate-limit keys. Email keys are trimmed and lowercased. + +```mermaid +flowchart TD + Request[Auth HTTP request] --> IP[HTTP adapter checks client IP policy] + IP -->|denied| Limited[429 with Retry-After] + IP -->|backend error| Unavailable[503 fail closed] + IP -->|allowed| Service[Auth application service] + Service -->|account, email or token policy applies| Subject[Check subject policy] + Service -->|no subject policy| UseCase[Run auth use case] + Subject -->|denied| Limited + Subject -->|backend error| Unavailable + Subject -->|allowed| UseCase + UseCase -->|successful password login| Reset[Reset login account counter] + Reset --> Result[Return response] + UseCase -->|other result| Result +``` + +Both checks use HMAC-derived keys and the same configured rate-limit store. A non-HTTP caller enters at the application service, so it still receives subject-policy checks but has no HTTP client-IP check. + +## Default policies + +Override a policy with `RATE_LIMIT_=/` (for example, `RATE_LIMIT_LOGIN_IP=30/1m`). These defaults are defined in `internal/config/rate_limit.go`: + +| Policy | Applies to | Default | +| --- | --- | --- | +| `login_ip` | Login HTTP requests, per client IP | 30/1m | +| `login_account` | Login attempts, per normalized email | 5/15m | +| `register_ip` | Registration HTTP requests, per client IP | 5/1h | +| `register_email` | Registration attempts, per normalized email | 5/1h | +| `recovery_ip` | Forgot/reset password HTTP requests, per client IP | 10/1h | +| `recovery_email` | Forgot-password attempts, per normalized email | 3/1h | +| `recovery_token` | Reset-password attempts, per token ID | 3/1h | +| `refresh_ip` | Refresh HTTP requests, per client IP | 30/1m | +| `verify_ip` | Resend-verification HTTP requests, per client IP | 10/1h | +| `verify_email` | Resend-verification attempts, per normalized email | 3/1h | +| `oauth_ip` | OAuth start and callback requests, per client IP | 10/1m | + +A successful password login attempts to reset its `login_account` counter (reset failures do not fail the login). Other counters expire at the end of their windows. The email-verification link (`GET /api/auth/verify/email`) is **not** subject to the resend-verification policies. + +## Select a backend + +`RATE_LIMIT_BACKEND` defaults to `memory`. Starting the Compose Redis service does **not** change the backend. + +```dotenv +RATE_LIMIT_BACKEND=redis +REDIS_ADDR=localhost:6379 +REDIS_DB=0 +RATE_LIMIT_KEY=replace-with-a-separate-random-secret-at-least-32-bytes +``` + +- `memory`: local to one process, capped by `RATE_LIMIT_MAX_MEMORY_KEYS` (default 10000). Counters disappear on restart, and multiple instances do not share them. Disabled in production unless `RATE_LIMIT_ALLOW_MEMORY_PRODUCTION=true` is explicitly set. +- `redis`: shared counters with atomic Lua updates and a TTL matching the policy window. Set `REDIS_ADDR`; `REDIS_PASSWORD` and `REDIS_DB` are optional. Use a dedicated Redis instance or logical database when isolation matters. +- `postgres`: shared counters in `rate_limit_buckets` (migration `000003`), serialized per key with a PostgreSQL advisory transaction lock. Expired rows are deleted during use and periodically. + +Use Redis or PostgreSQL for multiple application instances. Set `RATE_LIMIT_KEY` separately from JWT, refresh-token, and session secrets; rotating it effectively starts fresh counters. Production validation requires it to be at least 32 bytes. + +## IP trust and responses + +Gin's `ClientIP()` trusts only the direct connection unless `TRUSTED_PROXIES` contains your actual proxy IPs or CIDRs (comma-separated). Do **not** trust arbitrary client-supplied forwarded headers; configure this only when traffic arrives through a known reverse proxy. + +A denied request returns HTTP **429** with `Retry-After` (seconds) and `Cache-Control: no-store`. Backend errors fail closed with HTTP **503** (`authentication temporarily unavailable`), `Retry-After: 30`, and `Cache-Control: no-store`; there is no fallback to another backend. The retry header is clamped to 1–3600 seconds. The IP check lives in the HTTP adapter; the account/email/token error is mapped to the same responses by its error middleware. Denials and failures emit audit events/metrics without raw identifiers. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..1eed941 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,46 @@ +# Setup and operations + +## Prerequisites and configuration + +Install Go 1.25+, Docker/Compose (or your own PostgreSQL), and the `migrate` CLI. Copy `.env.example` to `.env`; `make` loads `.env` automatically. To use another file, run `ENV_FILE=.env.prod make run`. + +All database operations use `DATABASE_URL`. Before running the server, configure separate secrets and mail credentials: + +```dotenv +DATABASE_URL=postgres://postgres:postgres@localhost:5432/auth_go?sslmode=disable +JWT_ACCESS_SECRET=replace-with-a-long-random-secret +REFRESH_TOKEN_HASH_KEY=replace-with-a-separate-long-random-secret +SESSION_SECRET=replace-with-a-third-long-random-secret +RATE_LIMIT_KEY=replace-with-a-fourth-long-random-secret +EMAIL=your-email@gmail.com +PASSWORD=your-gmail-app-password +``` + +GitHub and Google OAuth are optional (`GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`); a configured client ID requires a matching secret. Configuration is validated before the database or HTTP server starts; production requires each secret above to be at least 32 bytes. Refer to `.env.example` for every supported setting, including `ALLOWED_ORIGINS`, `DOMAIN`, `LOG_LEVEL`, and [rate-limit policies](rate-limiting.md). Replace the example secrets and do not commit `.env`. + +## Run and verify + +```bash +docker compose up -d db redis # Redis is optional unless RATE_LIMIT_BACKEND=redis +make migrate-up +make run +``` + +By default the API listens on `http://localhost:8080`. `GET /health/live` reports process liveness; `GET /health/ready` checks the database with a bounded ping. + +```bash +make test # Unit tests +make vet # Static checks +make integration # Run migrations and integration-tagged tests +make sqlc # Regenerate PostgreSQL code +make swagger # Regenerate Swagger from Go annotations +make swagger-validate # Lint the generated Swagger document +``` + +**Integration tests write to the database in `DATABASE_URL`.** Use a dedicated, disposable test database (with migrations applied), never production or a shared application database. Redis-backed integration tests run only when `REDIS_ADDR` is set. The `integration` build tag can also be run directly with `go test -tags=integration ./...` after migrations. + +## API docs and observability + +Swagger UI is opt-in: set `ENABLE_SWAGGER=true` to serve `/swagger/index.html` and `/swagger/doc.json` outside production; Swagger is always disabled in production. The generated spec is committed under `docs/`. + +Set `ENABLE_METRICS=true` for `GET /metrics`; restrict this endpoint to trusted monitoring systems in production. Security audits are structured JSON logs with request IDs and categorical context, not raw identifiers. Rate-limit denials/backend failures are also recorded in low-cardinality metrics. Grafana, Loki, and tracing infrastructure are not bundled. diff --git a/docs/storage-and-caching.md b/docs/storage-and-caching.md new file mode 100644 index 0000000..38f4914 --- /dev/null +++ b/docs/storage-and-caching.md @@ -0,0 +1,27 @@ +# Storage and caching + +**There is no application data or HTTP response cache in this template.** Redis is an optional store for *rate-limit counters*, not a cache for users, access tokens, refresh tokens, sessions, or database queries. Starting Redis via `docker compose up -d db redis` does not enable the Redis backend; `RATE_LIMIT_BACKEND` defaults to `memory`. + +```mermaid +flowchart LR + HTTP[HTTP adapter] --> Auth[Auth application services] + Auth --> PG[(PostgreSQL auth records)] + HTTP -->|IP limits| Limits[Rate limiter] + Auth -->|email, account and token limits| Limits + Limits -->|default| Memory[(Process memory counters)] + Limits -->|optional redis backend| Redis[(Redis counters only)] + Limits -->|optional postgres backend| Buckets[(PostgreSQL rate-limit buckets)] +``` + +Only one rate-limit backend is selected at a time. Neither Redis nor the in-process counter store sits between the application and its PostgreSQL auth records. + +| Data | Where it lives | +| --- | --- | +| Users, password hashes, email-verification/password-reset records, refresh-token digests and rotation state | PostgreSQL | +| Rate-limit counters | In-process memory (default), Redis (`RATE_LIMIT_BACKEND=redis`), or PostgreSQL (`RATE_LIMIT_BACKEND=postgres`) | +| JWT access tokens | Signed and sent to clients; validated from their signature and expiry, not looked up in Redis | +| OAuth session cookie | Signed cookie handled by the OAuth adapter, not stored in Redis | + +Redis rate-limit keys hold a counter with a TTL for one policy window. Keys are HMAC digests, not raw IPs, emails, or tokens. Redis persistence, eviction settings, and availability are operational choices: if counters are lost they start over, and if the selected backend cannot be reached, authentication requests subject to its checks fail closed with HTTP 503. The template does not cache PostgreSQL results or use Redis pub/sub, a job queue, or session storage. + +Add a data cache only for a measured need, with an explicit expiry/invalidation strategy. Avoid caching security-sensitive authentication state without handling revocation and consistency first. See [Rate limiting](rate-limiting.md) for backend setup and failure behavior. diff --git a/docs/swagger.json b/docs/swagger.json index 4b0764a..b894f24 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -45,19 +45,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -92,7 +92,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -104,19 +104,19 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -141,7 +141,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -153,13 +153,13 @@ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -185,7 +185,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" }, "headers": { "Set-Cookie": { @@ -197,13 +197,13 @@ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -238,25 +238,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -290,19 +290,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -331,19 +331,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -372,19 +372,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -417,7 +417,7 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -450,19 +450,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -496,31 +496,31 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -554,25 +554,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -597,19 +597,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UsersResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -634,19 +634,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -685,25 +685,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.MessageResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -737,25 +737,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.UserResponseEnvelope" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.ErrorResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse" } } } @@ -776,7 +776,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } } } @@ -797,13 +797,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } }, "503": { "description": "Service Unavailable", "schema": { - "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + "$ref": "#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse" } } } @@ -828,6 +828,15 @@ } } }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid input" + } + } + }, "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ForgotPasswordRequest": { "type": "object", "required": [ @@ -839,6 +848,24 @@ } } }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation completed successfully" + } + } + }, "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ResetPasswordRequest": { "type": "object", "required": [ @@ -911,34 +938,7 @@ } } }, - "internal_adapter_inbound_http.ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "invalid input" - } - } - }, - "internal_adapter_inbound_http.HealthResponse": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "ok" - } - } - }, - "internal_adapter_inbound_http.MessageResponse": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Operation completed successfully" - } - } - }, - "internal_adapter_inbound_http.UserResponseEnvelope": { + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope": { "type": "object", "properties": { "message": { @@ -950,7 +950,7 @@ } } }, - "internal_adapter_inbound_http.UsersResponseEnvelope": { + "github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope": { "type": "object", "properties": { "message": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index dbe3933..0e3c62a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12,6 +12,12 @@ definitions: - email - password type: object + github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse: + properties: + error: + example: invalid input + type: string + type: object github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ForgotPasswordRequest: properties: email: @@ -19,6 +25,18 @@ definitions: required: - email type: object + github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse: + properties: + status: + example: ok + type: string + type: object + github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse: + properties: + message: + example: Operation completed successfully + type: string + type: object github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ResetPasswordRequest: properties: id: @@ -68,25 +86,7 @@ definitions: username: type: string type: object - internal_adapter_inbound_http.ErrorResponse: - properties: - error: - example: invalid input - type: string - type: object - internal_adapter_inbound_http.HealthResponse: - properties: - status: - example: ok - type: string - type: object - internal_adapter_inbound_http.MessageResponse: - properties: - message: - example: Operation completed successfully - type: string - type: object - internal_adapter_inbound_http.UserResponseEnvelope: + github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope: properties: message: example: User retrieved successfully @@ -94,7 +94,7 @@ definitions: user: $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponse' type: object - internal_adapter_inbound_http.UsersResponseEnvelope: + github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope: properties: message: example: Users retrieved successfully @@ -133,15 +133,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Request password reset tags: - authentication @@ -169,19 +169,19 @@ paths: description: refresh_token=; HttpOnly; SameSite=Lax type: string schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Log in tags: - authentication @@ -198,15 +198,15 @@ paths: description: refresh_token=; Max-Age=0 type: string schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Log out @@ -227,15 +227,15 @@ paths: description: refresh_token=; HttpOnly; SameSite=Lax type: string schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Refresh tokens @@ -260,19 +260,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "409": description: Conflict schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Register a user tags: - authentication @@ -294,15 +294,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Reset password tags: - authentication @@ -321,15 +321,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Verify email tags: - authentication @@ -348,15 +348,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Resend email verification tags: - authentication @@ -378,7 +378,7 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Start OAuth login tags: - oauth @@ -400,15 +400,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.UserResponseEnvelope' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' summary: Complete OAuth login tags: - oauth @@ -427,19 +427,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.UserResponseEnvelope' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Get user by ID @@ -460,23 +460,23 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "403": description: Forbidden schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Delete user @@ -497,19 +497,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.UserResponseEnvelope' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Get user by email @@ -524,15 +524,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.UsersResponseEnvelope' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UsersResponseEnvelope' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: List users @@ -547,15 +547,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.UserResponseEnvelope' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.UserResponseEnvelope' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Get current user @@ -579,19 +579,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.MessageResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.MessageResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/internal_adapter_inbound_http.ErrorResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.ErrorResponse' security: - CookieAuth: [] summary: Update current user @@ -607,7 +607,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse' summary: Check process liveness tags: - operations @@ -621,11 +621,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse' "503": description: Service Unavailable schema: - $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + $ref: '#/definitions/github_com_Jonathan0823_auth-go_internal_adapter_inbound_http_dto.HealthResponse' summary: Check service readiness tags: - operations diff --git a/internal/adapter/inbound/http/audit.go b/internal/adapter/inbound/http/audit.go index 9d608c2..3c374bf 100644 --- a/internal/adapter/inbound/http/audit.go +++ b/internal/adapter/inbound/http/audit.go @@ -6,7 +6,7 @@ import ( inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" "github.com/Jonathan0823/auth-go/internal/core/domain" "github.com/Jonathan0823/auth-go/internal/core/service" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) @@ -14,7 +14,7 @@ func (h *Handler) audit(c *gin.Context, name, outcome, reason, provider string, if h.Audit == nil { return } - h.Audit.Log(c.Request.Context(), platform.AuditEvent{ + h.Audit.Log(c.Request.Context(), observability.AuditEvent{ Name: name, Outcome: outcome, RequestID: inhttp.RequestIDFromContext(c.Request.Context()), @@ -25,11 +25,24 @@ func (h *Handler) audit(c *gin.Context, name, outcome, reason, provider string, } func (h *Handler) auditFailure(c *gin.Context, name, provider string, err error, userID int) { - h.audit(c, name, platform.OutcomeFailure, auditReason(err), provider, userID) + var rateLimitErr *service.RateLimitError + if h.Audit != nil && errors.As(err, &rateLimitErr) { + event, outcome, reason := observability.EventAuthRateLimited, observability.OutcomeDetected, observability.ReasonRateLimited + if rateLimitErr.Unavailable { + event, outcome, reason = observability.EventRateLimiterUnavailable, observability.OutcomeFailure, observability.ReasonBackendUnavailable + } + h.Audit.Log(c.Request.Context(), observability.AuditEvent{ + Name: event, Outcome: outcome, RequestID: inhttp.RequestIDFromContext(c.Request.Context()), + UserID: userID, Provider: provider, Reason: reason, Route: c.FullPath(), + Policy: rateLimitErr.Policy, Backend: rateLimitErr.Backend, + }) + return + } + h.audit(c, name, observability.OutcomeFailure, auditReason(err), provider, userID) } func (h *Handler) auditSuccess(c *gin.Context, name, provider string, userID int) { - h.audit(c, name, platform.OutcomeSuccess, platform.ReasonNone, provider, userID) + h.audit(c, name, observability.OutcomeSuccess, observability.ReasonNone, provider, userID) } func isRefreshReplay(err error) bool { @@ -39,16 +52,16 @@ func isRefreshReplay(err error) bool { func auditReason(err error) string { switch { case errors.Is(err, service.ErrRefreshTokenReused): - return platform.ReasonReplayDetected + return observability.ReasonReplayDetected case errors.Is(err, domain.ErrInvalidInput): - return platform.ReasonValidation + return observability.ReasonValidation case errors.Is(err, domain.ErrUnauthenticated): - return platform.ReasonUnauthenticated + return observability.ReasonUnauthenticated case errors.Is(err, domain.ErrNotFound): - return platform.ReasonNotFound + return observability.ReasonNotFound case errors.Is(err, domain.ErrConflict): - return platform.ReasonConflict + return observability.ReasonConflict default: - return platform.ReasonInternal + return observability.ReasonInternal } } diff --git a/internal/adapter/inbound/http/audit_test.go b/internal/adapter/inbound/http/audit_test.go index 95d334c..b9fb6b5 100644 --- a/internal/adapter/inbound/http/audit_test.go +++ b/internal/adapter/inbound/http/audit_test.go @@ -12,7 +12,7 @@ import ( inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" "github.com/Jonathan0823/auth-go/internal/core/domain" "github.com/Jonathan0823/auth-go/internal/core/service" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) @@ -20,12 +20,12 @@ func TestAuditIncludesRequestIDWithoutSensitiveInput(t *testing.T) { gin.SetMode(gin.TestMode) var output bytes.Buffer handler := &Handler{ - Audit: platform.NewAuditLogger(slog.New(slog.NewJSONHandler(&output, nil)), platform.NewMetrics(nil)), + Audit: observability.NewAuditLogger(slog.New(slog.NewJSONHandler(&output, nil)), observability.NewMetrics(nil)), } router := gin.New() router.Use(inhttp.RequestID()) router.GET("/audit", func(c *gin.Context) { - handler.auditSuccess(c, platform.EventAuthLogin, "", 0) + handler.auditSuccess(c, observability.EventAuthLogin, "", 0) c.Status(http.StatusNoContent) }) @@ -52,10 +52,10 @@ func TestAuditReasonClassifiesErrors(t *testing.T) { err error want string }{ - {name: "replay", err: fmtReplayError(), want: platform.ReasonReplayDetected}, - {name: "validation", err: domain.ErrInvalidInput, want: platform.ReasonValidation}, - {name: "unauthenticated", err: domain.ErrUnauthenticated, want: platform.ReasonUnauthenticated}, - {name: "unknown", err: errors.New("internal details"), want: platform.ReasonInternal}, + {name: "replay", err: fmtReplayError(), want: observability.ReasonReplayDetected}, + {name: "validation", err: domain.ErrInvalidInput, want: observability.ReasonValidation}, + {name: "unauthenticated", err: domain.ErrUnauthenticated, want: observability.ReasonUnauthenticated}, + {name: "unknown", err: errors.New("internal details"), want: observability.ReasonInternal}, } for _, tt := range tests { diff --git a/internal/adapter/inbound/http/auth_handler.go b/internal/adapter/inbound/http/auth_handler.go index 9318d99..1fbe328 100644 --- a/internal/adapter/inbound/http/auth_handler.go +++ b/internal/adapter/inbound/http/auth_handler.go @@ -8,7 +8,8 @@ import ( "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/dto" "github.com/Jonathan0823/auth-go/internal/core/domain" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/observability" ) const ( @@ -68,34 +69,31 @@ func (h *Handler) clearCookie(c *gin.Context, name string) { // @Accept json // @Produce json // @Param request body dto.CredentialsRequest true "Registration credentials" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 409 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 409 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/auth/register [post] func (h *Handler) Register(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() var req dto.CredentialsRequest if !BindJSONWithValidation(c, &req) { - h.audit(c, platform.EventUserRegister, platform.OutcomeFailure, platform.ReasonValidation, "", 0) + h.audit(c, observability.EventUserRegister, observability.OutcomeFailure, observability.ReasonValidation, "", 0) return } - if !h.allowRateLimit(c, - ipRateLimit("register_ip", c), - emailRateLimit("register_email", req.Email), - ) { + if !h.allowRateLimit(c, ipRateLimit("register_ip", c)) { return } - user := domain.User{Email: req.Email, Password: req.Password} - if err := h.Svc.Auth.Register(ctx, user); err != nil { - h.auditFailure(c, platform.EventUserRegister, "", err, 0) + command := port.RegisterCommand{Email: req.Email, Password: req.Password} + if err := h.Svc.Auth.Register(ctx, command); err != nil { + h.auditFailure(c, observability.EventUserRegister, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventUserRegister, "", 0) - c.JSON(http.StatusOK, gin.H{"message": "User registered successfully"}) + h.auditSuccess(c, observability.EventUserRegister, "", 0) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "User registered successfully"}) } // Login authenticates a user and sets access and refresh token cookies. @@ -106,46 +104,42 @@ func (h *Handler) Register(c *gin.Context) { // @Accept json // @Produce json // @Param request body dto.CredentialsRequest true "Login credentials" -// @Success 200 {object} MessageResponse +// @Success 200 {object} dto.MessageResponse // @Header 200 {string} Set-Cookie "access_token=; HttpOnly; SameSite=Lax" // @Header 200 {string} Set-Cookie "refresh_token=; HttpOnly; SameSite=Lax" -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/auth/login [post] func (h *Handler) Login(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() var req dto.CredentialsRequest if !BindJSONWithValidation(c, &req) { - h.audit(c, platform.EventAuthLogin, platform.OutcomeFailure, platform.ReasonValidation, "", 0) + h.audit(c, observability.EventAuthLogin, observability.OutcomeFailure, observability.ReasonValidation, "", 0) return } - if !h.allowRateLimit(c, - ipRateLimit("login_ip", c), - emailRateLimit("login_account", req.Email), - ) { + if !h.allowRateLimit(c, ipRateLimit("login_ip", c)) { return } - user := domain.User{ + command := port.LoginCommand{ Email: req.Email, Password: req.Password, IPAddress: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), } - accessToken, refreshToken, err := h.Svc.Auth.Login(ctx, user) + accessToken, refreshToken, err := h.Svc.Auth.Login(ctx, command) if err != nil { - h.auditFailure(c, platform.EventAuthLogin, "", err, 0) + h.auditFailure(c, observability.EventAuthLogin, "", err, 0) _ = c.Error(err) return } - h.resetRateLimit(c, emailRateLimit("login_account", req.Email)) - h.auditSuccess(c, platform.EventAuthLogin, "", 0) + h.auditSuccess(c, observability.EventAuthLogin, "", 0) h.setAccessCookie(c, accessToken) h.setRefreshCookie(c, refreshToken) - c.JSON(http.StatusOK, gin.H{"message": "User logged in successfully"}) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "User logged in successfully"}) } // Logout revokes the refresh-token family and clears authentication cookies. @@ -154,11 +148,11 @@ func (h *Handler) Login(c *gin.Context) { // @Tags authentication // @Produce json // @Security CookieAuth -// @Success 200 {object} MessageResponse +// @Success 200 {object} dto.MessageResponse // @Header 200 {string} Set-Cookie "access_token=; Max-Age=0" // @Header 200 {string} Set-Cookie "refresh_token=; Max-Age=0" -// @Failure 401 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/auth/logout [post] func (h *Handler) Logout(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -166,20 +160,20 @@ func (h *Handler) Logout(c *gin.Context) { refreshToken, err := c.Cookie("refresh_token") if err != nil || refreshToken == "" { authErr := fmt.Errorf("refresh token not found: %w", domain.ErrUnauthenticated) - h.auditFailure(c, platform.EventAuthLogout, "", authErr, 0) + h.auditFailure(c, observability.EventAuthLogout, "", authErr, 0) _ = c.Error(authErr) return } if err := h.Svc.Auth.Logout(ctx, refreshToken); err != nil { - h.auditFailure(c, platform.EventAuthLogout, "", err, 0) + h.auditFailure(c, observability.EventAuthLogout, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthLogout, "", 0) + h.auditSuccess(c, observability.EventAuthLogout, "", 0) h.clearCookies(c) - c.JSON(http.StatusOK, gin.H{"message": "User logged out successfully"}) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "User logged out successfully"}) } // Refresh rotates the refresh token and sets a new access-token cookie. @@ -189,11 +183,11 @@ func (h *Handler) Logout(c *gin.Context) { // @Tags authentication // @Produce json // @Security CookieAuth -// @Success 200 {object} MessageResponse +// @Success 200 {object} dto.MessageResponse // @Header 200 {string} Set-Cookie "access_token=; HttpOnly; SameSite=Lax" // @Header 200 {string} Set-Cookie "refresh_token=; HttpOnly; SameSite=Lax" -// @Failure 401 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/auth/refresh [post] func (h *Handler) Refresh(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -204,7 +198,7 @@ func (h *Handler) Refresh(c *gin.Context) { refreshToken, err := c.Cookie("refresh_token") if err != nil || refreshToken == "" { authErr := fmt.Errorf("refresh token not found: %w", domain.ErrUnauthenticated) - h.auditFailure(c, platform.EventAuthRefresh, "", authErr, 0) + h.auditFailure(c, observability.EventAuthRefresh, "", authErr, 0) _ = c.Error(authErr) return } @@ -212,18 +206,18 @@ func (h *Handler) Refresh(c *gin.Context) { newAccess, newRefresh, err := h.Svc.Auth.RefreshTokens(ctx, refreshToken, c.ClientIP(), c.GetHeader("User-Agent")) if err != nil { if isRefreshReplay(err) { - h.audit(c, platform.EventAuthRefreshReplay, platform.OutcomeDetected, platform.ReasonReplayDetected, "", 0) + h.audit(c, observability.EventAuthRefreshReplay, observability.OutcomeDetected, observability.ReasonReplayDetected, "", 0) } else { - h.auditFailure(c, platform.EventAuthRefresh, "", err, 0) + h.auditFailure(c, observability.EventAuthRefresh, "", err, 0) } _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthRefresh, "", 0) + h.auditSuccess(c, observability.EventAuthRefresh, "", 0) h.setAccessCookie(c, newAccess) h.setRefreshCookie(c, newRefresh) - c.JSON(http.StatusOK, gin.H{"message": "Access token refreshed successfully"}) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "Access token refreshed successfully"}) } // VerifyEmail verifies an email address using the token in the query string. @@ -232,20 +226,20 @@ func (h *Handler) Refresh(c *gin.Context) { // @Tags authentication // @Produce json // @Param id query string true "Email verification token" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/auth/verify/email [get] func (h *Handler) VerifyEmail(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() if err := h.Svc.Auth.VerifyEmail(ctx, c.Query("id")); err != nil { - h.auditFailure(c, platform.EventAuthEmailVerification, "", err, 0) + h.auditFailure(c, observability.EventAuthEmailVerification, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthEmailVerification, "", 0) - c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"}) + h.auditSuccess(c, observability.EventAuthEmailVerification, "", 0) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "Email verified successfully"}) } // ResendVerifyEmail sends a new email verification link. @@ -254,32 +248,29 @@ func (h *Handler) VerifyEmail(c *gin.Context) { // @Tags authentication // @Produce json // @Param email query string true "Email address" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/auth/verify/email/resend [post] func (h *Handler) ResendVerifyEmail(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() email := c.Query("email") if email == "" { - h.audit(c, platform.EventAuthEmailVerification, platform.OutcomeFailure, platform.ReasonValidation, "", 0) + h.audit(c, observability.EventAuthEmailVerification, observability.OutcomeFailure, observability.ReasonValidation, "", 0) c.JSON(http.StatusBadRequest, gin.H{"error": "Email is required"}) return } - if !h.allowRateLimit(c, - ipRateLimit("verify_ip", c), - emailRateLimit("verify_email", email), - ) { + if !h.allowRateLimit(c, ipRateLimit("verify_ip", c)) { return } if err := h.Svc.Auth.CreateVerifyEmail(ctx, email); err != nil { - h.auditFailure(c, platform.EventAuthEmailVerification, "", err, 0) + h.auditFailure(c, observability.EventAuthEmailVerification, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthEmailVerification, "", 0) - c.JSON(http.StatusOK, gin.H{"message": "Verification email resent successfully"}) + h.auditSuccess(c, observability.EventAuthEmailVerification, "", 0) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "Verification email resent successfully"}) } // ForgotPassword sends a password-reset link to an existing email address. @@ -289,31 +280,28 @@ func (h *Handler) ResendVerifyEmail(c *gin.Context) { // @Accept json // @Produce json // @Param request body dto.ForgotPasswordRequest true "Email address" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/auth/forgot-password [post] func (h *Handler) ForgotPassword(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() var req dto.ForgotPasswordRequest if !BindJSONWithValidation(c, &req) { - h.audit(c, platform.EventAuthPasswordResetRequest, platform.OutcomeFailure, platform.ReasonValidation, "", 0) + h.audit(c, observability.EventAuthPasswordResetRequest, observability.OutcomeFailure, observability.ReasonValidation, "", 0) return } - if !h.allowRateLimit(c, - ipRateLimit("recovery_ip", c), - emailRateLimit("recovery_email", req.Email), - ) { + if !h.allowRateLimit(c, ipRateLimit("recovery_ip", c)) { return } if err := h.Svc.Auth.ForgotPassword(ctx, req.Email); err != nil { - h.auditFailure(c, platform.EventAuthPasswordResetRequest, "", err, 0) + h.auditFailure(c, observability.EventAuthPasswordResetRequest, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthPasswordResetRequest, "", 0) - c.JSON(http.StatusOK, gin.H{"message": "Password reset link sent to your email"}) + h.auditSuccess(c, observability.EventAuthPasswordResetRequest, "", 0) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "Password reset link sent to your email"}) } // ResetPassword replaces a user's password with a valid reset token. @@ -323,29 +311,26 @@ func (h *Handler) ForgotPassword(c *gin.Context) { // @Accept json // @Produce json // @Param request body dto.ResetPasswordRequest true "Reset token and new password" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/auth/reset-password [post] func (h *Handler) ResetPassword(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() var req dto.ResetPasswordRequest if !BindJSONWithValidation(c, &req) { - h.audit(c, platform.EventAuthPasswordReset, platform.OutcomeFailure, platform.ReasonValidation, "", 0) + h.audit(c, observability.EventAuthPasswordReset, observability.OutcomeFailure, observability.ReasonValidation, "", 0) return } - if !h.allowRateLimit(c, - ipRateLimit("recovery_ip", c), - tokenRateLimit("recovery_token", req.ID), - ) { + if !h.allowRateLimit(c, ipRateLimit("recovery_ip", c)) { return } if err := h.Svc.Auth.ResetPassword(ctx, req.ID, req.Password); err != nil { - h.auditFailure(c, platform.EventAuthPasswordReset, "", err, 0) + h.auditFailure(c, observability.EventAuthPasswordReset, "", err, 0) _ = c.Error(err) return } - h.auditSuccess(c, platform.EventAuthPasswordReset, "", 0) - c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"}) + h.auditSuccess(c, observability.EventAuthPasswordReset, "", 0) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "Password reset successfully"}) } diff --git a/internal/adapter/inbound/http/auth_integration_test.go b/internal/adapter/inbound/http/auth_integration_test.go index 75afed4..8cd4d44 100644 --- a/internal/adapter/inbound/http/auth_integration_test.go +++ b/internal/adapter/inbound/http/auth_integration_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -25,6 +26,7 @@ import ( outpassword "github.com/Jonathan0823/auth-go/internal/adapter/outbound/password" outpostgres "github.com/Jonathan0823/auth-go/internal/adapter/outbound/postgres" "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" "github.com/Jonathan0823/auth-go/internal/core/service" ) @@ -32,6 +34,15 @@ type integrationEmailSender struct{} func (integrationEmailSender) Send(string, string, string) error { return nil } +type integrationRateLimitStore struct{} + +func (integrationRateLimitStore) Allow(context.Context, string, port.RateLimitPolicy) (port.RateLimitDecision, error) { + return port.RateLimitDecision{Allowed: true}, nil +} +func (integrationRateLimitStore) Reset(context.Context, string) error { return nil } +func (integrationRateLimitStore) Backend() string { return "test" } +func (integrationRateLimitStore) Close() error { return nil } + func setupAuthServer(t *testing.T) (*gin.Engine, *pgxpool.Pool, port.Repository, port.TokenService) { t.Helper() dsn := os.Getenv("DATABASE_URL") @@ -51,7 +62,12 @@ func setupAuthServer(t *testing.T) (*gin.Engine, *pgxpool.Pool, port.Repository, repo := outpostgres.NewRepository(pool) tokens := outjwt.NewTokenService(os.Getenv("JWT_ACCESS_SECRET"), os.Getenv("REFRESH_TOKEN_HASH_KEY")) - svc := service.New(repo, tokens, integrationEmailSender{}, outpassword.NewHasher(), "http://localhost:8080") + policies := make(map[string]port.RateLimitPolicy) + for _, name := range []string{"register_email", "login_account", "verify_email", "recovery_email", "recovery_token"} { + policies[name] = port.RateLimitPolicy{Name: name, Limit: 1000, Window: time.Hour} + } + limiter := ratelimit.NewRateLimiter(integrationRateLimitStore{}, "integration-key", policies) + svc := service.New(repo, tokens, integrationEmailSender{}, outpassword.NewHasher(), "http://localhost:8080", limiter) gin.SetMode(gin.TestMode) router := gin.New() diff --git a/internal/adapter/inbound/http/context.go b/internal/adapter/inbound/http/context.go deleted file mode 100644 index 469a5d4..0000000 --- a/internal/adapter/inbound/http/context.go +++ /dev/null @@ -1,12 +0,0 @@ -package http - -import ( - "context" - "time" - - "github.com/gin-gonic/gin" -) - -func CtxWithTimeout(c *gin.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(c.Request.Context(), 10*time.Second) -} diff --git a/internal/adapter/inbound/http/swagger_types.go b/internal/adapter/inbound/http/dto/response.go similarity index 50% rename from internal/adapter/inbound/http/swagger_types.go rename to internal/adapter/inbound/http/dto/response.go index c23fba8..eb5086e 100644 --- a/internal/adapter/inbound/http/swagger_types.go +++ b/internal/adapter/inbound/http/dto/response.go @@ -1,19 +1,17 @@ -package http - -import "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/dto" +package dto type MessageResponse struct { Message string `json:"message" example:"Operation completed successfully"` } type UserResponseEnvelope struct { - Message string `json:"message" example:"User retrieved successfully"` - User dto.UserResponse `json:"user"` + Message string `json:"message" example:"User retrieved successfully"` + User UserResponse `json:"user"` } type UsersResponseEnvelope struct { - Message string `json:"message" example:"Users retrieved successfully"` - Users []dto.UserResponse `json:"users"` + Message string `json:"message" example:"Users retrieved successfully"` + Users []UserResponse `json:"users"` } type ErrorResponse struct { diff --git a/internal/adapter/inbound/http/dto/user_test.go b/internal/adapter/inbound/http/dto/user_test.go index deabf46..59a4ea6 100644 --- a/internal/adapter/inbound/http/dto/user_test.go +++ b/internal/adapter/inbound/http/dto/user_test.go @@ -1,6 +1,8 @@ package dto import ( + "encoding/json" + "strings" "testing" "time" @@ -20,10 +22,18 @@ func TestUserResponseMappings(t *testing.T) { CreatedAt: now, UpdatedAt: now, } + user.PasswordHash = "must-not-be-exposed" response := UserResponseFromDomain(user) if response.ID != user.ID || response.Email != user.Email || response.Provider != user.Provider || !response.IsVerified { t.Fatalf("response = %#v", response) } + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "must-not-be-exposed") || strings.Contains(string(encoded), "password") { + t.Fatalf("user response exposed password data: %s", encoded) + } responses := UserResponsesFromDomain([]*domain.User{user}) if len(responses) != 1 || responses[0].ID != user.ID { t.Fatalf("responses = %#v", responses) diff --git a/internal/adapter/inbound/http/handler.go b/internal/adapter/inbound/http/handler.go index 70af37c..2ca8ed1 100644 --- a/internal/adapter/inbound/http/handler.go +++ b/internal/adapter/inbound/http/handler.go @@ -5,7 +5,8 @@ import ( "github.com/Jonathan0823/auth-go/internal/core/domain" "github.com/Jonathan0823/auth-go/internal/core/port" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" + "github.com/Jonathan0823/auth-go/internal/observability" ) // OAuthFlow owns the HTTP-specific provider handshake outside the core service. @@ -25,8 +26,8 @@ type Handler struct { OAuth OAuthFlow CookieDomain string SecureCookie bool - Audit *platform.AuditLogger - RateLimiter *platform.RateLimiter + Audit *observability.AuditLogger + RateLimiter *ratelimit.RateLimiter } func NewHandler(svc port.Service, tokens port.TokenService, oauth OAuthFlow, cfg HandlerConfig) *Handler { diff --git a/internal/adapter/inbound/http/handler_test.go b/internal/adapter/inbound/http/handler_test.go index 78b4842..7884b63 100644 --- a/internal/adapter/inbound/http/handler_test.go +++ b/internal/adapter/inbound/http/handler_test.go @@ -33,11 +33,11 @@ type handlerAuthService struct { forgotCalls int } -func (f *handlerAuthService) Register(context.Context, domain.User) error { +func (f *handlerAuthService) Register(context.Context, port.RegisterCommand) error { f.registerCalls++ return f.registerErr } -func (f *handlerAuthService) Login(context.Context, domain.User) (string, string, error) { +func (f *handlerAuthService) Login(context.Context, port.LoginCommand) (string, string, error) { f.loginCalls++ return "access", "refresh", f.loginErr } diff --git a/internal/adapter/inbound/http/health.go b/internal/adapter/inbound/http/health.go index 812f222..f76e1cd 100644 --- a/internal/adapter/inbound/http/health.go +++ b/internal/adapter/inbound/http/health.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/dto" "github.com/gin-gonic/gin" ) @@ -28,10 +29,10 @@ func NewHealthHandler(db DBPinger) *HealthHandler { // @Description Returns liveness without checking external dependencies. // @Tags operations // @Produce json -// @Success 200 {object} HealthResponse +// @Success 200 {object} dto.HealthResponse // @Router /health/live [get] func (h *HealthHandler) Live(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, dto.HealthResponse{Status: "ok"}) } // Ready godoc @@ -40,20 +41,20 @@ func (h *HealthHandler) Live(c *gin.Context) { // @Description Checks PostgreSQL connectivity with a bounded timeout. // @Tags operations // @Produce json -// @Success 200 {object} HealthResponse -// @Failure 503 {object} HealthResponse +// @Success 200 {object} dto.HealthResponse +// @Failure 503 {object} dto.HealthResponse // @Router /health/ready [get] func (h *HealthHandler) Ready(c *gin.Context) { if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"}) + c.JSON(http.StatusServiceUnavailable, dto.HealthResponse{Status: "not_ready"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), readinessTimeout) defer cancel() if err := h.db.Ping(ctx); err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"}) + c.JSON(http.StatusServiceUnavailable, dto.HealthResponse{Status: "not_ready"}) return } - c.JSON(http.StatusOK, gin.H{"status": "ready"}) + c.JSON(http.StatusOK, dto.HealthResponse{Status: "ready"}) } diff --git a/internal/adapter/inbound/http/metrics.go b/internal/adapter/inbound/http/metrics.go deleted file mode 100644 index 69a984f..0000000 --- a/internal/adapter/inbound/http/metrics.go +++ /dev/null @@ -1,13 +0,0 @@ -package http - -import ( - "github.com/Jonathan0823/auth-go/internal/platform" - "github.com/gin-gonic/gin" -) - -func RegisterMetricsRoute(r *gin.Engine, metrics *platform.Metrics, enabled bool) { - if !enabled || metrics == nil { - return - } - r.GET("/metrics", gin.WrapH(metrics.Handler())) -} diff --git a/internal/adapter/inbound/http/metrics_test.go b/internal/adapter/inbound/http/metrics_routes_test.go similarity index 94% rename from internal/adapter/inbound/http/metrics_test.go rename to internal/adapter/inbound/http/metrics_routes_test.go index fc7c084..8c63238 100644 --- a/internal/adapter/inbound/http/metrics_test.go +++ b/internal/adapter/inbound/http/metrics_routes_test.go @@ -7,13 +7,13 @@ import ( "testing" inhttpmw "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) func TestMetricsMiddlewareUsesRouteTemplates(t *testing.T) { gin.SetMode(gin.TestMode) - metrics := platform.NewMetrics(nil) + metrics := observability.NewMetrics(nil) router := gin.New() router.Use(inhttpmw.Metrics(metrics)) router.GET("/users/:id", func(c *gin.Context) {}) @@ -53,7 +53,7 @@ func TestMetricsRouteIsOptIn(t *testing.T) { gin.SetMode(gin.TestMode) for _, enabled := range []bool{false, true} { t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) { - metrics := platform.NewMetrics(nil) + metrics := observability.NewMetrics(nil) router := gin.New() if enabled { router.Use(inhttpmw.Metrics(metrics)) diff --git a/internal/adapter/inbound/http/middleware/middleware.go b/internal/adapter/inbound/http/middleware/auth.go similarity index 58% rename from internal/adapter/inbound/http/middleware/middleware.go rename to internal/adapter/inbound/http/middleware/auth.go index dfa8632..f350221 100644 --- a/internal/adapter/inbound/http/middleware/middleware.go +++ b/internal/adapter/inbound/http/middleware/auth.go @@ -2,7 +2,6 @@ package middleware import ( "fmt" - "log/slog" "github.com/gin-gonic/gin" @@ -36,25 +35,3 @@ func (m *AuthMiddleware) Handler() gin.HandlerFunc { c.Next() } } - -func ErrorHandler(logger *slog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - c.Next() - if len(c.Errors) == 0 { - return - } - - err := c.Errors.Last().Err - status, message := mapError(err) - if statusIsServerError(status) { - logger.ErrorContext(c.Request.Context(), "application error", - slog.String("request_id", RequestIDFromContext(c.Request.Context())), - slog.Int("status", status), - slog.String("error", message), - slog.String("error_type", fmt.Sprintf("%T", err)), - ) - } - c.JSON(status, gin.H{"error": message}) - c.Abort() - } -} diff --git a/internal/adapter/inbound/http/middleware/error_handler.go b/internal/adapter/inbound/http/middleware/error_handler.go new file mode 100644 index 0000000..f0c288f --- /dev/null +++ b/internal/adapter/inbound/http/middleware/error_handler.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +func ErrorHandler(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if len(c.Errors) == 0 { + return + } + + err := c.Errors.Last().Err + status, message := mapError(err) + if status == http.StatusTooManyRequests || status == http.StatusServiceUnavailable { + c.Header("Retry-After", rateLimitRetryAfter(err)) + c.Header("Cache-Control", "no-store") + } + if statusIsServerError(status) && logger != nil { + logger.ErrorContext(c.Request.Context(), "application error", + slog.String("request_id", RequestIDFromContext(c.Request.Context())), + slog.Int("status", status), + slog.String("error", message), + slog.String("error_type", fmt.Sprintf("%T", err)), + ) + } + c.JSON(status, gin.H{"error": message}) + c.Abort() + } +} diff --git a/internal/adapter/inbound/http/middleware/errors.go b/internal/adapter/inbound/http/middleware/errors.go index 2ffad39..19e7475 100644 --- a/internal/adapter/inbound/http/middleware/errors.go +++ b/internal/adapter/inbound/http/middleware/errors.go @@ -2,12 +2,23 @@ package middleware import ( "errors" + "math" "net/http" + "strconv" + "time" "github.com/Jonathan0823/auth-go/internal/core/domain" + "github.com/Jonathan0823/auth-go/internal/core/service" ) func mapError(err error) (int, string) { + var limitErr *service.RateLimitError + if errors.As(err, &limitErr) { + if limitErr.Unavailable { + return http.StatusServiceUnavailable, "authentication temporarily unavailable" + } + return http.StatusTooManyRequests, "too many requests" + } switch { case errors.Is(err, domain.ErrInvalidInput): return http.StatusBadRequest, "invalid input" @@ -23,3 +34,26 @@ func mapError(err error) (int, string) { return http.StatusInternalServerError, "internal server error" } } + +func rateLimitRetryAfter(err error) string { + var limitErr *service.RateLimitError + if !errors.As(err, &limitErr) { + return RetryAfterHeader(0) + } + duration := limitErr.RetryAfter + if limitErr.Unavailable { + duration = 30 * time.Second + } + return RetryAfterHeader(duration) +} + +func RetryAfterHeader(duration time.Duration) string { + seconds := int64(math.Ceil(duration.Seconds())) + if seconds < 1 { + seconds = 1 + } + if seconds > 3600 { + seconds = 3600 + } + return strconv.FormatInt(seconds, 10) +} diff --git a/internal/adapter/inbound/http/middleware/errors_test.go b/internal/adapter/inbound/http/middleware/errors_test.go index f9c5c3a..8c844ba 100644 --- a/internal/adapter/inbound/http/middleware/errors_test.go +++ b/internal/adapter/inbound/http/middleware/errors_test.go @@ -3,12 +3,42 @@ package middleware import ( "errors" "fmt" + "io" + "log/slog" "net/http" + "net/http/httptest" "testing" + "time" "github.com/Jonathan0823/auth-go/internal/core/domain" + "github.com/Jonathan0823/auth-go/internal/core/service" + "github.com/gin-gonic/gin" ) +func TestErrorHandlerRateLimitResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + name string + err error + status int + retryAfter string + }{ + {"limited", &service.RateLimitError{RetryAfter: 2500 * time.Millisecond}, http.StatusTooManyRequests, "3"}, + {"backend unavailable", &service.RateLimitError{Unavailable: true}, http.StatusServiceUnavailable, "30"}, + } { + t.Run(test.name, func(t *testing.T) { + router := gin.New() + router.Use(ErrorHandler(slog.New(slog.NewTextHandler(io.Discard, nil)))) + router.GET("/", func(c *gin.Context) { _ = c.Error(test.err) }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) + if response.Code != test.status || response.Header().Get("Retry-After") != test.retryAfter || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d headers=%v", response.Code, response.Header()) + } + }) + } +} + func TestMapError(t *testing.T) { tests := []struct { name string @@ -21,6 +51,8 @@ func TestMapError(t *testing.T) { {"forbidden", domain.ErrForbidden, http.StatusForbidden, "forbidden"}, {"not found", domain.ErrNotFound, http.StatusNotFound, "not found"}, {"conflict", domain.ErrConflict, http.StatusConflict, "conflict"}, + {"rate limited", &service.RateLimitError{RetryAfter: 10 * time.Second}, http.StatusTooManyRequests, "too many requests"}, + {"rate limit backend unavailable", &service.RateLimitError{Unavailable: true}, http.StatusServiceUnavailable, "authentication temporarily unavailable"}, {"unknown", errors.New("database password leaked"), http.StatusInternalServerError, "internal server error"}, } diff --git a/internal/adapter/inbound/http/middleware/logging.go b/internal/adapter/inbound/http/middleware/logging.go index b588432..cbf263e 100644 --- a/internal/adapter/inbound/http/middleware/logging.go +++ b/internal/adapter/inbound/http/middleware/logging.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" ) type requestIDKey struct{} @@ -47,7 +47,7 @@ func RequestLogger(logger *slog.Logger) gin.HandlerFunc { slog.String("path", c.Request.URL.Path), slog.Int("status", c.Writer.Status()), slog.Duration("duration", time.Since(start)), - platform.Redact("user_agent", c.GetHeader("User-Agent")), + observability.Redact("user_agent", c.GetHeader("User-Agent")), ) } } diff --git a/internal/adapter/inbound/http/middleware/metrics.go b/internal/adapter/inbound/http/middleware/metrics.go index 19819d9..9fa3259 100644 --- a/internal/adapter/inbound/http/middleware/metrics.go +++ b/internal/adapter/inbound/http/middleware/metrics.go @@ -4,11 +4,11 @@ import ( "strconv" "time" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) -func Metrics(metrics *platform.Metrics) gin.HandlerFunc { +func Metrics(metrics *observability.Metrics) gin.HandlerFunc { if metrics == nil { return func(c *gin.Context) { c.Next() } } diff --git a/internal/adapter/inbound/http/middleware/metrics_test.go b/internal/adapter/inbound/http/middleware/metrics_test.go index 634fb24..aaa43bc 100644 --- a/internal/adapter/inbound/http/middleware/metrics_test.go +++ b/internal/adapter/inbound/http/middleware/metrics_test.go @@ -7,12 +7,12 @@ import ( "github.com/gin-gonic/gin" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" ) func TestMetricsMiddlewareRecordsMatchedAndUnmatchedRoutes(t *testing.T) { gin.SetMode(gin.TestMode) - metrics := platform.NewMetrics(nil) + metrics := observability.NewMetrics(nil) router := gin.New() router.Use(Metrics(metrics)) router.GET("/health", func(c *gin.Context) { c.Status(http.StatusNoContent) }) diff --git a/internal/adapter/inbound/http/oauth_handler.go b/internal/adapter/inbound/http/oauth_handler.go index 0f3d0fc..b345e93 100644 --- a/internal/adapter/inbound/http/oauth_handler.go +++ b/internal/adapter/inbound/http/oauth_handler.go @@ -8,7 +8,7 @@ import ( "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/dto" "github.com/Jonathan0823/auth-go/internal/core/domain" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" ) // OAuthLogin starts the OAuth authorization flow for a supported provider. @@ -17,7 +17,7 @@ import ( // @Tags oauth // @Param provider path string true "OAuth provider" Enums(github,google) // @Success 302 -// @Failure 400 {object} ErrorResponse +// @Failure 400 {object} dto.ErrorResponse // @Router /api/oauth/{provider}/ [get] func (h *Handler) OAuthLogin(c *gin.Context) { if !h.allowRateLimit(c, ipRateLimit("oauth_ip", c)) { @@ -36,9 +36,9 @@ func (h *Handler) OAuthLogin(c *gin.Context) { // @Tags oauth // @Produce json // @Param provider path string true "OAuth provider" Enums(github,google) -// @Success 200 {object} UserResponseEnvelope -// @Failure 401 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Success 200 {object} dto.UserResponseEnvelope +// @Failure 401 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/oauth/{provider}/callback [get] func (h *Handler) OAuthCallback(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -53,21 +53,21 @@ func (h *Handler) OAuthCallback(c *gin.Context) { profile, err := h.OAuth.CompleteAuth(c.Writer, c.Request, c.Param("provider")) if err != nil { authErr := fmt.Errorf("oauth authentication failed: %w", domain.ErrUnauthenticated) - h.auditFailure(c, platform.EventAuthOAuth, c.Param("provider"), authErr, 0) + h.auditFailure(c, observability.EventAuthOAuth, c.Param("provider"), authErr, 0) _ = c.Error(authErr) return } userData, err := h.Svc.OAuth.Login(ctx, profile) if err != nil { authErr := fmt.Errorf("oauth authentication failed: %w", domain.ErrUnauthenticated) - h.auditFailure(c, platform.EventAuthOAuth, c.Param("provider"), authErr, 0) + h.auditFailure(c, observability.EventAuthOAuth, c.Param("provider"), authErr, 0) _ = c.Error(authErr) return } - h.auditSuccess(c, platform.EventAuthOAuth, c.Param("provider"), userData.ID) - c.JSON(http.StatusOK, gin.H{ - "message": "User logged in successfully", - "user": dto.UserResponseFromDomain(userData), + h.auditSuccess(c, observability.EventAuthOAuth, c.Param("provider"), userData.ID) + c.JSON(http.StatusOK, dto.UserResponseEnvelope{ + Message: "User logged in successfully", + User: dto.UserResponseFromDomain(userData), }) } diff --git a/internal/adapter/inbound/http/rate_limit.go b/internal/adapter/inbound/http/rate_limit.go index af47d5a..762cd0b 100644 --- a/internal/adapter/inbound/http/rate_limit.go +++ b/internal/adapter/inbound/http/rate_limit.go @@ -1,39 +1,29 @@ package http import ( - "math" "net/http" - "strconv" - "strings" "time" inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) const backendRetryAfter = 30 * time.Second -func ipRateLimit(policy string, c *gin.Context) platform.RateLimitCheck { - return platform.RateLimitCheck{Policy: policy, Dimension: "ip", Value: c.ClientIP()} +func ipRateLimit(policy string, c *gin.Context) ratelimit.RateLimitCheck { + return ratelimit.RateLimitCheck{Policy: policy, Dimension: "ip", Value: c.ClientIP()} } -func emailRateLimit(policy, email string) platform.RateLimitCheck { - return platform.RateLimitCheck{Policy: policy, Dimension: "email", Value: strings.ToLower(strings.TrimSpace(email))} -} - -func tokenRateLimit(policy, token string) platform.RateLimitCheck { - return platform.RateLimitCheck{Policy: policy, Dimension: "token", Value: token} -} - -func (h *Handler) allowRateLimit(c *gin.Context, checks ...platform.RateLimitCheck) bool { +func (h *Handler) allowRateLimit(c *gin.Context, checks ...ratelimit.RateLimitCheck) bool { if h.RateLimiter == nil { return true } decision, err := h.RateLimiter.Allow(c.Request.Context(), checks...) if err != nil { - h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, checks) - c.Header("Retry-After", retryAfterHeader(backendRetryAfter)) + h.logRateLimitEvent(c, observability.EventRateLimiterUnavailable, observability.OutcomeFailure, observability.ReasonBackendUnavailable, checks) + c.Header("Retry-After", inhttp.RetryAfterHeader(backendRetryAfter)) c.Header("Cache-Control", "no-store") c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "authentication temporarily unavailable"}) return false @@ -42,27 +32,18 @@ func (h *Handler) allowRateLimit(c *gin.Context, checks ...platform.RateLimitChe return true } - h.logRateLimitEvent(c, platform.EventAuthRateLimited, platform.OutcomeDetected, platform.ReasonRateLimited, checks) - c.Header("Retry-After", retryAfterHeader(decision.RetryAfter)) + h.logRateLimitEvent(c, observability.EventAuthRateLimited, observability.OutcomeDetected, observability.ReasonRateLimited, checks) + c.Header("Retry-After", inhttp.RetryAfterHeader(decision.RetryAfter)) c.Header("Cache-Control", "no-store") c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"}) return false } -func (h *Handler) resetRateLimit(c *gin.Context, check platform.RateLimitCheck) { - if h.RateLimiter == nil { - return - } - if err := h.RateLimiter.Reset(c.Request.Context(), check); err != nil { - h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, []platform.RateLimitCheck{check}) - } -} - -func (h *Handler) logRateLimitEvent(c *gin.Context, event, outcome, reason string, checks []platform.RateLimitCheck) { +func (h *Handler) logRateLimitEvent(c *gin.Context, event, outcome, reason string, checks []ratelimit.RateLimitCheck) { if h.Audit == nil || len(checks) == 0 { return } - h.Audit.Log(c.Request.Context(), platform.AuditEvent{ + h.Audit.Log(c.Request.Context(), observability.AuditEvent{ Name: event, Outcome: outcome, RequestID: requestID(c), @@ -76,14 +57,3 @@ func (h *Handler) logRateLimitEvent(c *gin.Context, event, outcome, reason strin func requestID(c *gin.Context) string { return inhttp.RequestIDFromContext(c.Request.Context()) } - -func retryAfterHeader(duration time.Duration) string { - seconds := int64(math.Ceil(duration.Seconds())) - if seconds < 1 { - seconds = 1 - } - if seconds > 3600 { - seconds = 3600 - } - return strconv.FormatInt(seconds, 10) -} diff --git a/internal/adapter/inbound/http/rate_limit_test.go b/internal/adapter/inbound/http/rate_limit_test.go index e8dc4db..93c6f18 100644 --- a/internal/adapter/inbound/http/rate_limit_test.go +++ b/internal/adapter/inbound/http/rate_limit_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/Jonathan0823/auth-go/internal/core/port" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" "github.com/gin-gonic/gin" ) @@ -27,7 +27,7 @@ func (fakeRateLimitStore) Close() error { return nil } func TestRateLimitResponse(t *testing.T) { gin.SetMode(gin.TestMode) - handler := &Handler{RateLimiter: platform.NewRateLimiter( + handler := &Handler{RateLimiter: ratelimit.NewRateLimiter( fakeRateLimitStore{decision: port.RateLimitDecision{RetryAfter: 10 * time.Second}}, "test-key", map[string]port.RateLimitPolicy{"login_ip": {Name: "login_ip", Limit: 1, Window: time.Minute}}, @@ -99,8 +99,9 @@ func TestRateLimitUsesForwardedIPFromTrustedProxy(t *testing.T) { func TestRateLimitBackendFailureDoesNotLeakError(t *testing.T) { gin.SetMode(gin.TestMode) - handler := &Handler{RateLimiter: platform.NewRateLimiter( + handler := &Handler{RateLimiter: ratelimit.NewRateLimiter( fakeRateLimitStore{err: errors.New("redis password leaked")}, + "test-key", map[string]port.RateLimitPolicy{"login_ip": {Name: "login_ip", Limit: 1, Window: time.Minute}}, )} diff --git a/internal/adapter/inbound/http/request_context.go b/internal/adapter/inbound/http/request_context.go new file mode 100644 index 0000000..3354ca2 --- /dev/null +++ b/internal/adapter/inbound/http/request_context.go @@ -0,0 +1,43 @@ +package http + +import ( + "context" + "fmt" + "time" + + "github.com/gin-gonic/gin" +) + +type AuthenticatedUser struct { + ID int + Username string + Email string +} + +func CtxWithTimeout(c *gin.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(c.Request.Context(), 10*time.Second) +} + +func GetUser(c *gin.Context) (AuthenticatedUser, error) { + raw, exists := c.Get("user") + if !exists { + return AuthenticatedUser{}, fmt.Errorf("user is not found") + } + claims, ok := raw.(map[string]any) + if !ok { + return AuthenticatedUser{}, fmt.Errorf("invalid token claims") + } + id, ok := claims["id"].(float64) + if !ok || id <= 0 || id != float64(int(id)) { + return AuthenticatedUser{}, fmt.Errorf("invalid token user ID") + } + username, ok := claims["username"].(string) + if !ok { + return AuthenticatedUser{}, fmt.Errorf("invalid token username") + } + email, ok := claims["email"].(string) + if !ok { + return AuthenticatedUser{}, fmt.Errorf("invalid token email") + } + return AuthenticatedUser{ID: int(id), Username: username, Email: email}, nil +} diff --git a/internal/adapter/inbound/http/request_context_test.go b/internal/adapter/inbound/http/request_context_test.go new file mode 100644 index 0000000..7531084 --- /dev/null +++ b/internal/adapter/inbound/http/request_context_test.go @@ -0,0 +1,23 @@ +package http + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestGetUserRejectsMalformedClaims(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, claims := range []any{ + map[string]any{"id": "1", "username": "user", "email": "user@example.com"}, + map[string]any{"id": float64(1), "username": 1, "email": "user@example.com"}, + "invalid claims", + } { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("user", claims) + if _, err := GetUser(c); err == nil { + t.Errorf("GetUser(%#v) succeeded, want malformed-claims error", claims) + } + } +} diff --git a/internal/adapter/inbound/http/routes.go b/internal/adapter/inbound/http/routes.go index ce5ad06..880bd87 100644 --- a/internal/adapter/inbound/http/routes.go +++ b/internal/adapter/inbound/http/routes.go @@ -3,7 +3,12 @@ package http import ( "log/slog" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + _ "github.com/Jonathan0823/auth-go/docs" inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" + "github.com/Jonathan0823/auth-go/internal/observability" "github.com/gin-gonic/gin" ) @@ -51,3 +56,15 @@ func RegisterHealthRoutes(r *gin.Engine, db DBPinger) { r.GET("/health/live", health.Live) r.GET("/health/ready", health.Ready) } + +func RegisterMetricsRoute(r *gin.Engine, metrics *observability.Metrics, enabled bool) { + if enabled && metrics != nil { + r.GET("/metrics", gin.WrapH(metrics.Handler())) + } +} + +func RegisterSwaggerRoutes(r *gin.Engine, enabled bool, environment string) { + if enabled && environment != "production" { + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } +} diff --git a/internal/platform/server.go b/internal/adapter/inbound/http/server.go similarity index 88% rename from internal/platform/server.go rename to internal/adapter/inbound/http/server.go index c8163d4..646399f 100644 --- a/internal/platform/server.go +++ b/internal/adapter/inbound/http/server.go @@ -1,4 +1,4 @@ -package platform +package http import ( "context" @@ -7,13 +7,14 @@ import ( "net/http" "time" + "github.com/Jonathan0823/auth-go/internal/config" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) const shutdownTimeout = 10 * time.Second -func CORS(cfg Config) gin.HandlerFunc { +func CORS(cfg config.Config) gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: cfg.AllowedOrigins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, @@ -24,7 +25,7 @@ func CORS(cfg Config) gin.HandlerFunc { }) } -func RunServer(ctx context.Context, handler http.Handler, cfg Config) error { +func RunServer(ctx context.Context, handler http.Handler, cfg config.Config) error { server := newHTTPServer(handler, cfg.Port) errCh := make(chan error, 1) go func() { errCh <- server.ListenAndServe() }() diff --git a/internal/platform/server_test.go b/internal/adapter/inbound/http/server_test.go similarity index 85% rename from internal/platform/server_test.go rename to internal/adapter/inbound/http/server_test.go index e148a2a..7242b9d 100644 --- a/internal/platform/server_test.go +++ b/internal/adapter/inbound/http/server_test.go @@ -1,4 +1,4 @@ -package platform +package http import ( "context" @@ -7,13 +7,14 @@ import ( "testing" "time" + "github.com/Jonathan0823/auth-go/internal/config" "github.com/gin-gonic/gin" ) func TestCORSAllowsConfiguredOrigin(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() - router.Use(CORS(Config{AllowedOrigins: []string{"https://app.example.com"}})) + router.Use(CORS(config.Config{AllowedOrigins: []string{"https://app.example.com"}})) router.GET("/", func(c *gin.Context) { c.Status(http.StatusNoContent) }) request := httptest.NewRequest(http.MethodGet, "/", nil) @@ -32,7 +33,7 @@ func TestRunServerShutsDownWhenContextIsCancelled(t *testing.T) { time.Sleep(10 * time.Millisecond) cancel() }() - if err := RunServer(ctx, gin.New(), Config{Port: "0"}); err != nil { + if err := RunServer(ctx, gin.New(), config.Config{Port: "0"}); err != nil { t.Fatalf("RunServer() error = %v", err) } } diff --git a/internal/adapter/inbound/http/swagger.go b/internal/adapter/inbound/http/swagger.go deleted file mode 100644 index 35f32b2..0000000 --- a/internal/adapter/inbound/http/swagger.go +++ /dev/null @@ -1,16 +0,0 @@ -package http - -import ( - swaggerFiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" - - _ "github.com/Jonathan0823/auth-go/docs" - "github.com/gin-gonic/gin" -) - -func RegisterSwaggerRoutes(r *gin.Engine, enabled bool, environment string) { - if !enabled || environment == "production" { - return - } - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) -} diff --git a/internal/adapter/inbound/http/swagger_test.go b/internal/adapter/inbound/http/swagger_routes_test.go similarity index 100% rename from internal/adapter/inbound/http/swagger_test.go rename to internal/adapter/inbound/http/swagger_routes_test.go diff --git a/internal/adapter/inbound/http/user_handler.go b/internal/adapter/inbound/http/user_handler.go index 259f135..1bb5992 100644 --- a/internal/adapter/inbound/http/user_handler.go +++ b/internal/adapter/inbound/http/user_handler.go @@ -22,10 +22,10 @@ func unauthenticatedError() error { // @Produce json // @Security CookieAuth // @Param id path int true "User ID" -// @Success 200 {object} UserResponseEnvelope -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.UserResponseEnvelope +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/user/{id} [get] func (h *Handler) GetUserByID(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -40,9 +40,9 @@ func (h *Handler) GetUserByID(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{ - "message": "User retrieved successfully", - "user": dto.UserResponseFromDomain(user), + c.JSON(http.StatusOK, dto.UserResponseEnvelope{ + Message: "User retrieved successfully", + User: dto.UserResponseFromDomain(user), }) } @@ -52,9 +52,9 @@ func (h *Handler) GetUserByID(c *gin.Context) { // @Tags users // @Produce json // @Security CookieAuth -// @Success 200 {object} UsersResponseEnvelope -// @Failure 401 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Success 200 {object} dto.UsersResponseEnvelope +// @Failure 401 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/user/get-all [get] func (h *Handler) GetAllUsers(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -64,9 +64,9 @@ func (h *Handler) GetAllUsers(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{ - "message": "Users retrieved successfully", - "users": dto.UserResponsesFromDomain(users), + c.JSON(http.StatusOK, dto.UsersResponseEnvelope{ + Message: "Users retrieved successfully", + Users: dto.UserResponsesFromDomain(users), }) } @@ -77,10 +77,10 @@ func (h *Handler) GetAllUsers(c *gin.Context) { // @Produce json // @Security CookieAuth // @Param email query string true "User email address" -// @Success 200 {object} UserResponseEnvelope -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.UserResponseEnvelope +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/user/email [get] func (h *Handler) GetUserByEmail(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -95,9 +95,9 @@ func (h *Handler) GetUserByEmail(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{ - "message": "User retrieved successfully", - "user": dto.UserResponseFromDomain(user), + c.JSON(http.StatusOK, dto.UserResponseEnvelope{ + Message: "User retrieved successfully", + User: dto.UserResponseFromDomain(user), }) } @@ -109,10 +109,10 @@ func (h *Handler) GetUserByEmail(c *gin.Context) { // @Produce json // @Security CookieAuth // @Param request body dto.UpdateUserRequest true "Profile updates" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/user/update [patch] func (h *Handler) UpdateUser(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -136,7 +136,7 @@ func (h *Handler) UpdateUser(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"}) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "User updated successfully"}) } // DeleteUser deletes a user by ID when authorized by the authenticated user. @@ -146,11 +146,11 @@ func (h *Handler) UpdateUser(c *gin.Context) { // @Produce json // @Security CookieAuth // @Param id path int true "User ID" -// @Success 200 {object} MessageResponse -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 403 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.MessageResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/user/delete/{id} [delete] func (h *Handler) DeleteUser(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -169,7 +169,7 @@ func (h *Handler) DeleteUser(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) + c.JSON(http.StatusOK, dto.MessageResponse{Message: "User deleted successfully"}) } // GetCurrentUser returns the authenticated user's profile. @@ -178,9 +178,9 @@ func (h *Handler) DeleteUser(c *gin.Context) { // @Tags users // @Produce json // @Security CookieAuth -// @Success 200 {object} UserResponseEnvelope -// @Failure 401 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse +// @Success 200 {object} dto.UserResponseEnvelope +// @Failure 401 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse // @Router /api/user/me [get] func (h *Handler) GetCurrentUser(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) @@ -195,8 +195,8 @@ func (h *Handler) GetCurrentUser(c *gin.Context) { _ = c.Error(err) return } - c.JSON(http.StatusOK, gin.H{ - "message": "Current user retrieved successfully", - "user": dto.UserResponseFromDomain(data), + c.JSON(http.StatusOK, dto.UserResponseEnvelope{ + Message: "Current user retrieved successfully", + User: dto.UserResponseFromDomain(data), }) } diff --git a/internal/adapter/inbound/http/user_helper.go b/internal/adapter/inbound/http/user_helper.go deleted file mode 100644 index bfa9ae6..0000000 --- a/internal/adapter/inbound/http/user_helper.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package http provides inbound HTTP handlers and routing. -package http - -import ( - "fmt" - - "github.com/gin-gonic/gin" - - "github.com/Jonathan0823/auth-go/internal/core/domain" -) - -func GetUser(c *gin.Context) (domain.User, error) { - raw, exists := c.Get("user") - if !exists { - return domain.User{}, fmt.Errorf("user is not found") - } - claims, ok := raw.(map[string]any) - if !ok { - return domain.User{}, fmt.Errorf("invalid token claims") - } - return domain.User{ - ID: int(claims["id"].(float64)), - Username: claims["username"].(string), - Email: claims["email"].(string), - }, nil -} diff --git a/internal/adapter/outbound/email/email.go b/internal/adapter/outbound/email/sender.go similarity index 100% rename from internal/adapter/outbound/email/email.go rename to internal/adapter/outbound/email/sender.go diff --git a/internal/adapter/outbound/email/email_test.go b/internal/adapter/outbound/email/sender_test.go similarity index 100% rename from internal/adapter/outbound/email/email_test.go rename to internal/adapter/outbound/email/sender_test.go diff --git a/internal/adapter/outbound/jwt/jwt.go b/internal/adapter/outbound/jwt/tokens.go similarity index 100% rename from internal/adapter/outbound/jwt/jwt.go rename to internal/adapter/outbound/jwt/tokens.go diff --git a/internal/adapter/outbound/jwt/jwt_test.go b/internal/adapter/outbound/jwt/tokens_test.go similarity index 100% rename from internal/adapter/outbound/jwt/jwt_test.go rename to internal/adapter/outbound/jwt/tokens_test.go diff --git a/internal/adapter/outbound/oauth/oauth.go b/internal/adapter/outbound/oauth/client.go similarity index 100% rename from internal/adapter/outbound/oauth/oauth.go rename to internal/adapter/outbound/oauth/client.go diff --git a/internal/adapter/outbound/oauth/oauth_test.go b/internal/adapter/outbound/oauth/client_test.go similarity index 100% rename from internal/adapter/outbound/oauth/oauth_test.go rename to internal/adapter/outbound/oauth/client_test.go diff --git a/internal/adapter/outbound/password/password.go b/internal/adapter/outbound/password/hasher.go similarity index 100% rename from internal/adapter/outbound/password/password.go rename to internal/adapter/outbound/password/hasher.go diff --git a/internal/adapter/outbound/password/password_test.go b/internal/adapter/outbound/password/hasher_test.go similarity index 100% rename from internal/adapter/outbound/password/password_test.go rename to internal/adapter/outbound/password/hasher_test.go diff --git a/internal/adapter/outbound/postgres/helpers.go b/internal/adapter/outbound/postgres/pgtype_helpers.go similarity index 100% rename from internal/adapter/outbound/postgres/helpers.go rename to internal/adapter/outbound/postgres/pgtype_helpers.go diff --git a/internal/platform/database.go b/internal/adapter/outbound/postgres/pool.go similarity index 95% rename from internal/platform/database.go rename to internal/adapter/outbound/postgres/pool.go index 4dcabf7..89a3fe8 100644 --- a/internal/platform/database.go +++ b/internal/adapter/outbound/postgres/pool.go @@ -1,4 +1,4 @@ -package platform +package postgres import ( "context" diff --git a/internal/adapter/outbound/postgres/refresh_token_test.go b/internal/adapter/outbound/postgres/refresh_token_integration_test.go similarity index 100% rename from internal/adapter/outbound/postgres/refresh_token_test.go rename to internal/adapter/outbound/postgres/refresh_token_integration_test.go diff --git a/internal/adapter/outbound/postgres/user_integration_test.go b/internal/adapter/outbound/postgres/user_integration_test.go index c118aa6..f1ae14c 100644 --- a/internal/adapter/outbound/postgres/user_integration_test.go +++ b/internal/adapter/outbound/postgres/user_integration_test.go @@ -24,10 +24,10 @@ func TestUserRepositoryCRUD(t *testing.T) { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email = $1`, email) }) - if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "seed-value"}); err != nil { + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, PasswordHash: "seed-value"}); err != nil { t.Fatalf("Create: %v", err) } - if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "duplicate-value"}); !errors.Is(err, domain.ErrConflict) { + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, PasswordHash: "duplicate-value"}); !errors.Is(err, domain.ErrConflict) { t.Fatalf("duplicate Create error = %v, want conflict", err) } @@ -35,14 +35,14 @@ func TestUserRepositoryCRUD(t *testing.T) { if err != nil { t.Fatalf("GetByEmail with password: %v", err) } - if storedUser == nil || storedUser.Password != "seed-value" { + if storedUser == nil || storedUser.PasswordHash != "seed-value" { t.Fatalf("GetByEmail with password = %#v", storedUser) } withoutPassword, err := repo.Users().GetByEmail(ctx, email, false) if err != nil { t.Fatalf("GetByEmail without password: %v", err) } - if withoutPassword == nil || withoutPassword.Password != "" { + if withoutPassword == nil || withoutPassword.PasswordHash != "" { t.Fatalf("GetByEmail without password = %#v", withoutPassword) } @@ -64,7 +64,7 @@ func TestUserRepositoryCRUD(t *testing.T) { t.Fatalf("UpdatePassword: %v", err) } updated, err := repo.Users().GetByEmail(ctx, email, true) - if err != nil || updated == nil || updated.Username != "updated-user" || updated.Password != "updated-value" { + if err != nil || updated == nil || updated.Username != "updated-user" || updated.PasswordHash != "updated-value" { t.Fatalf("updated user = %#v, %v", updated, err) } diff --git a/internal/adapter/outbound/postgres/user_repository.go b/internal/adapter/outbound/postgres/user_repository.go index 426509f..32fa5cc 100644 --- a/internal/adapter/outbound/postgres/user_repository.go +++ b/internal/adapter/outbound/postgres/user_repository.go @@ -48,13 +48,13 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string, includePa return nil, err } return &domain.User{ - ID: int(row.ID), - Username: row.Username, - Email: row.Email, - Password: row.Password, - IsVerified: row.IsVerified.Bool, - UpdatedAt: row.UpdatedAt.Time, - CreatedAt: row.CreatedAt.Time, + ID: int(row.ID), + Username: row.Username, + Email: row.Email, + PasswordHash: row.Password, + IsVerified: row.IsVerified.Bool, + UpdatedAt: row.UpdatedAt.Time, + CreatedAt: row.CreatedAt.Time, }, nil } row, err := r.q.GetUserByEmailWithoutPassword(ctx, email) @@ -78,7 +78,7 @@ func (r *userRepository) Create(ctx context.Context, user domain.User) error { _, err := r.q.CreateUser(ctx, CreateUserParams{ Username: user.Username, Email: user.Email, - Password: user.Password, + Password: user.PasswordHash, }) if err != nil { if pgUniqueViolation(err) { diff --git a/internal/adapter/outbound/ratelimit/memory.go b/internal/adapter/outbound/ratelimit/memory_store.go similarity index 100% rename from internal/adapter/outbound/ratelimit/memory.go rename to internal/adapter/outbound/ratelimit/memory_store.go diff --git a/internal/adapter/outbound/ratelimit/memory_test.go b/internal/adapter/outbound/ratelimit/memory_store_test.go similarity index 100% rename from internal/adapter/outbound/ratelimit/memory_test.go rename to internal/adapter/outbound/ratelimit/memory_store_test.go diff --git a/internal/adapter/outbound/ratelimit/postgres.go b/internal/adapter/outbound/ratelimit/postgres_store.go similarity index 100% rename from internal/adapter/outbound/ratelimit/postgres.go rename to internal/adapter/outbound/ratelimit/postgres_store.go diff --git a/internal/adapter/outbound/ratelimit/postgres_integration_test.go b/internal/adapter/outbound/ratelimit/postgres_store_integration_test.go similarity index 100% rename from internal/adapter/outbound/ratelimit/postgres_integration_test.go rename to internal/adapter/outbound/ratelimit/postgres_store_integration_test.go diff --git a/internal/platform/redis.go b/internal/adapter/outbound/ratelimit/redis_client.go similarity index 92% rename from internal/platform/redis.go rename to internal/adapter/outbound/ratelimit/redis_client.go index 338b485..18c0877 100644 --- a/internal/platform/redis.go +++ b/internal/adapter/outbound/ratelimit/redis_client.go @@ -1,4 +1,4 @@ -package platform +package ratelimit import "github.com/redis/go-redis/v9" diff --git a/internal/adapter/outbound/ratelimit/redis.go b/internal/adapter/outbound/ratelimit/redis_store.go similarity index 100% rename from internal/adapter/outbound/ratelimit/redis.go rename to internal/adapter/outbound/ratelimit/redis_store.go diff --git a/internal/adapter/outbound/ratelimit/redis_integration_test.go b/internal/adapter/outbound/ratelimit/redis_store_integration_test.go similarity index 91% rename from internal/adapter/outbound/ratelimit/redis_integration_test.go rename to internal/adapter/outbound/ratelimit/redis_store_integration_test.go index f041eb3..3a5be45 100644 --- a/internal/adapter/outbound/ratelimit/redis_integration_test.go +++ b/internal/adapter/outbound/ratelimit/redis_store_integration_test.go @@ -11,7 +11,6 @@ import ( "time" "github.com/Jonathan0823/auth-go/internal/core/port" - "github.com/Jonathan0823/auth-go/internal/platform" ) func newIntegrationRedisStore(t *testing.T) *RedisStore { @@ -28,7 +27,7 @@ func newIntegrationRedisStore(t *testing.T) *RedisStore { t.Fatal(err) } } - client := platform.NewRedisClient(redisAddr, os.Getenv("REDIS_PASSWORD"), database) + client := NewRedisClient(redisAddr, os.Getenv("REDIS_PASSWORD"), database) if err := client.Ping(context.Background()).Err(); err != nil { _ = client.Close() t.Fatal(err) diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go new file mode 100644 index 0000000..9f0189f --- /dev/null +++ b/internal/architecture/dependencies_test.go @@ -0,0 +1,55 @@ +package architecture + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +func TestCoreDoesNotImportOuterLayers(t *testing.T) { + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate architecture test source") + } + coreDir := filepath.Join(filepath.Dir(source), "..", "core") + forbidden := []string{ + "github.com/Jonathan0823/auth-go/internal/adapter", + "github.com/Jonathan0823/auth-go/internal/bootstrap", + "github.com/Jonathan0823/auth-go/internal/config", + "github.com/Jonathan0823/auth-go/internal/observability", + "github.com/Jonathan0823/auth-go/internal/platform", + } + + err := filepath.WalkDir(coreDir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return err + } + for _, prefix := range forbidden { + if importPath == prefix || strings.HasPrefix(importPath, prefix+"/") { + t.Errorf("core source %s imports outer-layer package %q", path, importPath) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/bootstrap/ratelimit.go b/internal/bootstrap/rate_limit.go similarity index 66% rename from internal/bootstrap/ratelimit.go rename to internal/bootstrap/rate_limit.go index 6b014d9..f95111f 100644 --- a/internal/bootstrap/ratelimit.go +++ b/internal/bootstrap/rate_limit.go @@ -2,20 +2,20 @@ package bootstrap import ( "github.com/Jonathan0823/auth-go/internal/adapter/outbound/ratelimit" + "github.com/Jonathan0823/auth-go/internal/config" "github.com/Jonathan0823/auth-go/internal/core/port" - "github.com/Jonathan0823/auth-go/internal/platform" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" ) -func newRateLimitStore(config platform.RateLimitConfig, pool *pgxpool.Pool) (port.RateLimitStore, *redis.Client, error) { +func newRateLimitStore(config config.RateLimitConfig, pool *pgxpool.Pool) (port.RateLimitStore, *redis.Client, error) { switch config.Backend { case "memory": return ratelimit.NewMemoryStore(config.MaxMemoryKeys), nil, nil case "postgres": return ratelimit.NewPostgresStore(pool), nil, nil case "redis": - client := platform.NewRedisClient(config.RedisAddr, config.RedisPassword, config.RedisDB) + client := ratelimit.NewRedisClient(config.RedisAddr, config.RedisPassword, config.RedisDB) return ratelimit.NewRedisStore(client), client, nil default: return nil, nil, port.ErrRateLimitBackendUnavailable diff --git a/internal/bootstrap/ratelimit_test.go b/internal/bootstrap/rate_limit_test.go similarity index 65% rename from internal/bootstrap/ratelimit_test.go rename to internal/bootstrap/rate_limit_test.go index 69c4b68..971cfdf 100644 --- a/internal/bootstrap/ratelimit_test.go +++ b/internal/bootstrap/rate_limit_test.go @@ -4,31 +4,31 @@ import ( "testing" "github.com/Jonathan0823/auth-go/internal/adapter/outbound/ratelimit" + "github.com/Jonathan0823/auth-go/internal/config" "github.com/Jonathan0823/auth-go/internal/core/port" - "github.com/Jonathan0823/auth-go/internal/platform" ) func TestNewRateLimitStoreBackends(t *testing.T) { - memory, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "memory", MaxMemoryKeys: 10}, nil) + memory, redisClient, err := newRateLimitStore(config.RateLimitConfig{Backend: "memory", MaxMemoryKeys: 10}, nil) if err != nil || redisClient != nil || memory.Backend() != "memory" { t.Fatalf("memory store = %v, %v, %v", memory, redisClient, err) } _ = memory.Close() - postgres, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "postgres"}, nil) + postgres, redisClient, err := newRateLimitStore(config.RateLimitConfig{Backend: "postgres"}, nil) if err != nil || redisClient != nil || postgres.Backend() != "postgres" { t.Fatalf("postgres store = %v, %v, %v", postgres, redisClient, err) } _ = postgres.Close() - redis, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "redis", RedisAddr: "localhost:6399"}, nil) + redis, redisClient, err := newRateLimitStore(config.RateLimitConfig{Backend: "redis", RedisAddr: "localhost:6399"}, nil) if err != nil || redisClient == nil || redis.Backend() != "redis" { t.Fatalf("redis store = %v, %v, %v", redis, redisClient, err) } _ = redis.Close() _ = redisClient.Close() - store, client, err := newRateLimitStore(platform.RateLimitConfig{Backend: "unknown"}, nil) + store, client, err := newRateLimitStore(config.RateLimitConfig{Backend: "unknown"}, nil) if store != nil || client != nil || err != port.ErrRateLimitBackendUnavailable { t.Fatalf("unknown backend = %v, %v, %v", store, client, err) } diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/run.go similarity index 77% rename from internal/bootstrap/bootstrap.go rename to internal/bootstrap/run.go index a788402..045ab97 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/run.go @@ -14,17 +14,19 @@ import ( outoauth "github.com/Jonathan0823/auth-go/internal/adapter/outbound/oauth" outpassword "github.com/Jonathan0823/auth-go/internal/adapter/outbound/password" outpostgres "github.com/Jonathan0823/auth-go/internal/adapter/outbound/postgres" + "github.com/Jonathan0823/auth-go/internal/config" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" "github.com/Jonathan0823/auth-go/internal/core/service" - "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/Jonathan0823/auth-go/internal/observability" ) -func Run(ctx context.Context, cfg platform.Config) error { +func Run(ctx context.Context, cfg config.Config) error { if err := cfg.Validate(); err != nil { return fmt.Errorf("validate configuration: %w", err) } startupCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - pool, err := platform.NewPool(startupCtx, cfg.DatabaseURL) + pool, err := outpostgres.NewPool(startupCtx, cfg.DatabaseURL) cancel() if err != nil { return err @@ -35,7 +37,6 @@ func Run(ctx context.Context, cfg platform.Config) error { tokens := outjwt.NewTokenService(cfg.JWTAccessSecret, cfg.RefreshTokenHashKey) email := outemail.NewSender(cfg.EmailAddress, cfg.EmailPassword) hasher := outpassword.NewHasher() - svc := service.New(repo, tokens, email, hasher, cfg.BaseURL) oauth := outoauth.New(outoauth.Config{ BaseURL: cfg.BaseURL, SessionSecret: cfg.SessionSecret, @@ -50,9 +51,9 @@ func Run(ctx context.Context, cfg platform.Config) error { if err := r.SetTrustedProxies(cfg.RateLimit.TrustedProxies); err != nil { return fmt.Errorf("configure trusted proxies: %w", err) } - logger := platform.NewLogger(cfg.LogLevel) - metrics := platform.NewMetrics(pool) - audit := platform.NewAuditLogger(logger, metrics) + logger := observability.NewLogger(cfg.LogLevel) + metrics := observability.NewMetrics(pool) + audit := observability.NewAuditLogger(logger, metrics) rateLimitStore, redisClient, err := newRateLimitStore(cfg.RateLimit, pool) if err != nil { return fmt.Errorf("create rate-limit backend: %w", err) @@ -61,8 +62,10 @@ func Run(ctx context.Context, cfg platform.Config) error { if redisClient != nil { defer func() { _ = redisClient.Close() }() } + rateLimiter := ratelimit.NewRateLimiter(rateLimitStore, cfg.RateLimit.Key, cfg.RateLimit.Policies) + svc := service.New(repo, tokens, email, hasher, cfg.BaseURL, rateLimiter) - r.Use(platform.CORS(cfg)) + r.Use(inhttp.CORS(cfg)) r.Use(inhttpmw.RequestID()) if cfg.EnableMetrics { r.Use(inhttpmw.Metrics(metrics)) @@ -74,11 +77,11 @@ func Run(ctx context.Context, cfg platform.Config) error { SecureCookie: cfg.Environment == "production", }) handler.Audit = audit - handler.RateLimiter = platform.NewRateLimiter(rateLimitStore, cfg.RateLimit.Key, cfg.RateLimit.Policies) + handler.RateLimiter = rateLimiter inhttp.RegisterRoutes(r, handler, logger) inhttp.RegisterSwaggerRoutes(r, cfg.EnableSwagger, cfg.Environment) inhttp.RegisterHealthRoutes(r, pool) inhttp.RegisterMetricsRoute(r, metrics, cfg.EnableMetrics) - return platform.RunServer(ctx, r, cfg) + return inhttp.RunServer(ctx, r, cfg) } diff --git a/internal/platform/rate_limit_config.go b/internal/config/rate_limit.go similarity index 99% rename from internal/platform/rate_limit_config.go rename to internal/config/rate_limit.go index 53d5e2b..21bc3b0 100644 --- a/internal/platform/rate_limit_config.go +++ b/internal/config/rate_limit.go @@ -1,4 +1,4 @@ -package platform +package config import ( "fmt" diff --git a/internal/platform/rate_limit_config_test.go b/internal/config/rate_limit_test.go similarity index 99% rename from internal/platform/rate_limit_config_test.go rename to internal/config/rate_limit_test.go index 6c44aa2..653389d 100644 --- a/internal/platform/rate_limit_config_test.go +++ b/internal/config/rate_limit_test.go @@ -1,4 +1,4 @@ -package platform +package config import ( "testing" diff --git a/internal/platform/config.go b/internal/config/settings.go similarity index 99% rename from internal/platform/config.go rename to internal/config/settings.go index c01ef9a..65031d3 100644 --- a/internal/platform/config.go +++ b/internal/config/settings.go @@ -1,4 +1,4 @@ -package platform +package config import ( "fmt" diff --git a/internal/platform/config_test.go b/internal/config/settings_test.go similarity index 99% rename from internal/platform/config_test.go rename to internal/config/settings_test.go index 04cf841..5009278 100644 --- a/internal/platform/config_test.go +++ b/internal/config/settings_test.go @@ -1,4 +1,4 @@ -package platform +package config import ( "strings" diff --git a/internal/core/domain/user.go b/internal/core/domain/user.go index 8ec740c..a7ebb9d 100644 --- a/internal/core/domain/user.go +++ b/internal/core/domain/user.go @@ -3,18 +3,16 @@ package domain import "time" type User struct { - ID int - OAuthID string - Username string - AvatarURL string - Email string - Password string - IsVerified bool - Provider string - IPAddress string - UserAgent string - CreatedAt time.Time - UpdatedAt time.Time + ID int + OAuthID string + Username string + AvatarURL string + Email string + PasswordHash string + IsVerified bool + Provider string + CreatedAt time.Time + UpdatedAt time.Time } type UpdateUserCommand struct { diff --git a/internal/core/port/service.go b/internal/core/port/service.go index 04a8fa5..c51153b 100644 --- a/internal/core/port/service.go +++ b/internal/core/port/service.go @@ -6,9 +6,21 @@ import ( "github.com/Jonathan0823/auth-go/internal/core/domain" ) +type RegisterCommand struct { + Email string + Password string +} + +type LoginCommand struct { + Email string + Password string + IPAddress string + UserAgent string +} + type AuthService interface { - Register(ctx context.Context, user domain.User) error - Login(ctx context.Context, user domain.User) (accessToken, refreshToken string, err error) + Register(ctx context.Context, command RegisterCommand) error + Login(ctx context.Context, command LoginCommand) (accessToken, refreshToken string, err error) ForgotPassword(ctx context.Context, email string) error CreateVerifyEmail(ctx context.Context, email string) error VerifyEmail(ctx context.Context, id string) error diff --git a/internal/platform/rate_limiter.go b/internal/core/ratelimit/limiter.go similarity index 99% rename from internal/platform/rate_limiter.go rename to internal/core/ratelimit/limiter.go index 8b4d7ce..aadf05c 100644 --- a/internal/platform/rate_limiter.go +++ b/internal/core/ratelimit/limiter.go @@ -1,4 +1,4 @@ -package platform +package ratelimit import ( "context" diff --git a/internal/platform/rate_limiter_test.go b/internal/core/ratelimit/limiter_test.go similarity index 99% rename from internal/platform/rate_limiter_test.go rename to internal/core/ratelimit/limiter_test.go index bbd6155..cb5f8d3 100644 --- a/internal/platform/rate_limiter_test.go +++ b/internal/core/ratelimit/limiter_test.go @@ -1,4 +1,4 @@ -package platform +package ratelimit import ( "context" diff --git a/internal/core/service/auth.go b/internal/core/service/auth.go index 19ef3f4..984b00f 100644 --- a/internal/core/service/auth.go +++ b/internal/core/service/auth.go @@ -10,6 +10,7 @@ import ( "github.com/Jonathan0823/auth-go/internal/core/domain" "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" ) var ErrRefreshTokenReused = errors.New("refresh token reused") @@ -20,29 +21,34 @@ const ( ) type authService struct { - repo port.Repository - tokens port.TokenService - email port.EmailSender - hasher port.PasswordHasher - baseURL string + repo port.Repository + tokens port.TokenService + email port.EmailSender + hasher port.PasswordHasher + baseURL string + rateLimiter *ratelimit.RateLimiter } -func NewAuthService(repo port.Repository, tokens port.TokenService, email port.EmailSender, hasher port.PasswordHasher, baseURL string) port.AuthService { +func NewAuthService(repo port.Repository, tokens port.TokenService, email port.EmailSender, hasher port.PasswordHasher, baseURL string, rateLimiter *ratelimit.RateLimiter) *authService { return &authService{ - repo: repo, - tokens: tokens, - email: email, - hasher: hasher, - baseURL: baseURL, + repo: repo, + tokens: tokens, + email: email, + hasher: hasher, + baseURL: baseURL, + rateLimiter: rateLimiter, } } -func (s *authService) Register(ctx context.Context, user domain.User) error { - hashed, err := s.hasher.Hash(user.Password) +func (s *authService) Register(ctx context.Context, command port.RegisterCommand) error { + if err := s.allowRateLimit(ctx, "register_email", "email", normalizeRateLimitEmail(command.Email)); err != nil { + return err + } + hashed, err := s.hasher.Hash(command.Password) if err != nil { return fmt.Errorf("hash password: %w", err) } - user.Password = hashed + user := domain.User{Email: command.Email, PasswordHash: hashed} var verification domain.VerifyEmail if err := s.repo.WithTx(ctx, func(u port.UnitOfWork) error { @@ -57,8 +63,12 @@ func (s *authService) Register(ctx context.Context, user domain.User) error { return s.sendVerificationEmail(verification) } -func (s *authService) Login(ctx context.Context, user domain.User) (string, string, error) { - userFromDB, err := s.repo.Users().GetByEmail(ctx, user.Email, true) +func (s *authService) Login(ctx context.Context, command port.LoginCommand) (string, string, error) { + email := normalizeRateLimitEmail(command.Email) + if err := s.allowRateLimit(ctx, "login_account", "email", email); err != nil { + return "", "", err + } + userFromDB, err := s.repo.Users().GetByEmail(ctx, command.Email, true) if err != nil { return "", "", fmt.Errorf(errGetUserByEmail, err) } @@ -66,7 +76,7 @@ func (s *authService) Login(ctx context.Context, user domain.User) (string, stri return "", "", fmt.Errorf(errUserNotFound, domain.ErrNotFound) } - if err := s.hasher.Compare(userFromDB.Password, user.Password); err != nil { + if err := s.hasher.Compare(userFromDB.PasswordHash, command.Password); err != nil { return "", "", fmt.Errorf("invalid credentials: %w", domain.ErrUnauthenticated) } @@ -89,17 +99,21 @@ func (s *authService) Login(ctx context.Context, user domain.User) (string, stri ParentID: nil, ExpiredAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, - IPAddress: user.IPAddress, - UserAgent: user.UserAgent, + IPAddress: command.IPAddress, + UserAgent: command.UserAgent, } if err := s.repo.Auth().CreateRefreshToken(ctx, rt); err != nil { return "", "", fmt.Errorf("create refresh token: %w", err) } + s.resetRateLimit(ctx, "login_account", "email", email) return accessToken, rawRefresh, nil } func (s *authService) CreateVerifyEmail(ctx context.Context, email string) error { + if err := s.allowRateLimit(ctx, "verify_email", "email", normalizeRateLimitEmail(email)); err != nil { + return err + } verification, err := createVerification(ctx, s.repo.Users(), s.repo.Auth(), email) if err != nil { return err @@ -160,6 +174,9 @@ func (s *authService) VerifyEmail(ctx context.Context, id string) error { } func (s *authService) ForgotPassword(ctx context.Context, email string) error { + if err := s.allowRateLimit(ctx, "recovery_email", "email", normalizeRateLimitEmail(email)); err != nil { + return err + } userFromDB, err := s.repo.Users().GetByEmail(ctx, email, false) if err != nil { return fmt.Errorf(errGetUserByEmail, err) @@ -190,6 +207,9 @@ func (s *authService) ResetPassword(ctx context.Context, tokenID, newPassword st if _, err := uuid.Parse(tokenID); err != nil { return fmt.Errorf("invalid reset token: %w", domain.ErrInvalidInput) } + if err := s.allowRateLimit(ctx, "recovery_token", "token", tokenID); err != nil { + return err + } hashed, err := s.hasher.Hash(newPassword) if err != nil { diff --git a/internal/core/service/service.go b/internal/core/service/new.go similarity index 51% rename from internal/core/service/service.go rename to internal/core/service/new.go index 80e6b20..894471f 100644 --- a/internal/core/service/service.go +++ b/internal/core/service/new.go @@ -2,12 +2,13 @@ package service import ( "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" ) -func New(repo port.Repository, tokens port.TokenService, email port.EmailSender, hasher port.PasswordHasher, baseURL string) port.Service { +func New(repo port.Repository, tokens port.TokenService, email port.EmailSender, hasher port.PasswordHasher, baseURL string, rateLimiter *ratelimit.RateLimiter) port.Service { users := repo.Users() return port.Service{ - Auth: NewAuthService(repo, tokens, email, hasher, baseURL), + Auth: NewAuthService(repo, tokens, email, hasher, baseURL, rateLimiter), User: NewUserService(users), OAuth: NewOAuthService(users), } diff --git a/internal/core/service/rate_limit.go b/internal/core/service/rate_limit.go new file mode 100644 index 0000000..d656aa4 --- /dev/null +++ b/internal/core/service/rate_limit.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" +) + +var ErrRateLimitExceeded = errors.New("rate limit exceeded") + +type RateLimitError struct { + Policy string + Backend string + RetryAfter time.Duration + Unavailable bool +} + +func (e *RateLimitError) Error() string { + if e.Unavailable { + return "rate limit backend unavailable" + } + return "rate limit exceeded" +} + +func (e *RateLimitError) Unwrap() error { + if e.Unavailable { + return port.ErrRateLimitBackendUnavailable + } + return ErrRateLimitExceeded +} + +func (s *authService) allowRateLimit(ctx context.Context, policy, dimension, value string) error { + check := ratelimit.RateLimitCheck{Policy: policy, Dimension: dimension, Value: value} + if s.rateLimiter == nil { + return &RateLimitError{Policy: policy, Backend: "unknown", Unavailable: true} + } + decision, err := s.rateLimiter.Allow(ctx, check) + if err != nil { + return &RateLimitError{Policy: policy, Backend: s.rateLimiter.Backend(), Unavailable: true} + } + if !decision.Allowed { + return &RateLimitError{ + Policy: policy, + Backend: s.rateLimiter.Backend(), + RetryAfter: decision.RetryAfter, + } + } + return nil +} + +func (s *authService) resetRateLimit(ctx context.Context, policy, dimension, value string) { + if s.rateLimiter == nil { + return + } + _ = s.rateLimiter.Reset(ctx, ratelimit.RateLimitCheck{Policy: policy, Dimension: dimension, Value: value}) +} + +func normalizeRateLimitEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} diff --git a/internal/core/service/service_test.go b/internal/core/service/service_test.go index b70ade4..39ae251 100644 --- a/internal/core/service/service_test.go +++ b/internal/core/service/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/Jonathan0823/auth-go/internal/core/domain" "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/core/ratelimit" ) var errFake = errors.New("fake failure") @@ -256,6 +257,47 @@ func newAuthFakes() (*fakeRepository, *fakeUserRepository, *fakeAuthRepository, return repo, users, auth, &fakeEmailSender{} } +type testRateLimitStore struct { + decision port.RateLimitDecision + allowErr error + resetErr error + policies []string + resetCalls int +} + +func (s *testRateLimitStore) Allow(_ context.Context, _ string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + s.policies = append(s.policies, policy.Name) + if s.allowErr != nil { + return port.RateLimitDecision{}, s.allowErr + } + if s.decision.Allowed || s.decision.RetryAfter > 0 { + return s.decision, nil + } + return port.RateLimitDecision{Allowed: true}, nil +} +func (s *testRateLimitStore) Reset(context.Context, string) error { + s.resetCalls++ + return s.resetErr +} +func (*testRateLimitStore) Backend() string { return "test" } +func (*testRateLimitStore) Close() error { return nil } + +func testRateLimiterWithStore(store port.RateLimitStore) *ratelimit.RateLimiter { + policies := make(map[string]port.RateLimitPolicy) + for _, name := range []string{"register_email", "login_account", "verify_email", "recovery_email", "recovery_token"} { + policies[name] = port.RateLimitPolicy{Name: name, Limit: 5, Window: time.Minute} + } + return ratelimit.NewRateLimiter(store, "test-key", policies) +} + +func testRateLimiter() *ratelimit.RateLimiter { + return testRateLimiterWithStore(&testRateLimitStore{decision: port.RateLimitDecision{Allowed: true}}) +} + +func newTestAuthService(repo port.Repository, tokens port.TokenService, email port.EmailSender, hasher port.PasswordHasher, baseURL string) *authService { + return NewAuthService(repo, tokens, email, hasher, baseURL, testRateLimiter()) +} + func TestUserService(t *testing.T) { user := &domain.User{ID: 7, Email: "user@example.com"} t.Run("gets users", func(t *testing.T) { @@ -403,8 +445,105 @@ func TestOAuthService(t *testing.T) { } } +func TestAuthServiceAppliesEachAccountPolicy(t *testing.T) { + cases := []struct { + name string + policy string + call func(*authService) error + }{ + {"registration email", "register_email", func(s *authService) error { + return s.Register(context.Background(), port.RegisterCommand{Email: "user@example.com", Password: "secret"}) + }}, + {"login account", "login_account", func(s *authService) error { + _, _, err := s.Login(context.Background(), port.LoginCommand{Email: "user@example.com", Password: "secret"}) + return err + }}, + {"verification email", "verify_email", func(s *authService) error { + return s.CreateVerifyEmail(context.Background(), "user@example.com") + }}, + {"recovery email", "recovery_email", func(s *authService) error { + return s.ForgotPassword(context.Background(), "user@example.com") + }}, + {"recovery token", "recovery_token", func(s *authService) error { + return s.ResetPassword(context.Background(), uuid.NewString(), "secret") + }}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + store := &testRateLimitStore{decision: port.RateLimitDecision{RetryAfter: time.Minute}} + repo, _, _, email := newAuthFakes() + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost", testRateLimiterWithStore(store)) + err := tt.call(svc) + var limitErr *RateLimitError + if !errors.As(err, &limitErr) || limitErr.Unavailable || limitErr.Policy != tt.policy { + t.Fatalf("error = %#v, want denied %s policy", err, tt.policy) + } + if len(store.policies) != 1 || store.policies[0] != tt.policy || repo.withTxCalls != 0 { + t.Fatalf("checked policies=%v transactions=%d, want only %s before repository work", store.policies, repo.withTxCalls, tt.policy) + } + }) + } +} + +func TestAuthServiceRateLimitsAtApplicationBoundary(t *testing.T) { + t.Run("registration is rejected before hashing or persistence", func(t *testing.T) { + store := &testRateLimitStore{decision: port.RateLimitDecision{RetryAfter: 12 * time.Second}} + repo, _, _, email := newAuthFakes() + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost", testRateLimiterWithStore(store)) + + err := svc.Register(context.Background(), port.RegisterCommand{Email: "user@example.com", Password: "secret"}) + var limitErr *RateLimitError + if !errors.As(err, &limitErr) || limitErr.Unavailable || limitErr.Policy != "register_email" || limitErr.RetryAfter != 12*time.Second { + t.Fatalf("Register error = %#v, want register_email rate-limit error", err) + } + if repo.withTxCalls != 0 { + t.Fatal("rate-limited registration reached persistence") + } + if len(store.policies) != 1 || store.policies[0] != "register_email" { + t.Fatalf("checked policies = %v, want [register_email]", store.policies) + } + }) + + t.Run("successful login resets its account counter", func(t *testing.T) { + store := &testRateLimitStore{decision: port.RateLimitDecision{Allowed: true}} + repo, users, _, email := newAuthFakes() + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: 9, Email: "user@example.com", PasswordHash: "hashed"}, nil + } + svc := NewAuthService(repo, &fakeTokens{accessToken: "access", refreshToken: "refresh", refreshHash: []byte("hash")}, email, fakeHasher{}, "http://localhost", testRateLimiterWithStore(store)) + + if _, _, err := svc.Login(context.Background(), port.LoginCommand{Email: " USER@example.com ", Password: "secret"}); err != nil { + t.Fatalf("Login = %v", err) + } + if len(store.policies) != 1 || store.policies[0] != "login_account" || store.resetCalls != 1 { + t.Fatalf("policies=%v reset calls=%d, want login_account and one reset", store.policies, store.resetCalls) + } + }) + + t.Run("backend failure denies login before account lookup", func(t *testing.T) { + store := &testRateLimitStore{allowErr: errors.New("redis unavailable")} + repo, users, _, email := newAuthFakes() + lookupCalls := 0 + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + lookupCalls++ + return nil, nil + } + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost", testRateLimiterWithStore(store)) + + _, _, err := svc.Login(context.Background(), port.LoginCommand{Email: "user@example.com", Password: "secret"}) + var limitErr *RateLimitError + if !errors.As(err, &limitErr) || !limitErr.Unavailable || !errors.Is(err, port.ErrRateLimitBackendUnavailable) { + t.Fatalf("Login error = %v, want backend-unavailable rate-limit error", err) + } + if lookupCalls != 0 { + t.Fatalf("account lookup calls = %d, want 0", lookupCalls) + } + }) +} + func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { - user := domain.User{ID: 3, Email: "user@example.com", Password: "secret"} + userID := 3 + user := port.RegisterCommand{Email: "user@example.com", Password: "secret"} t.Run("register success and failures", func(t *testing.T) { tests := []struct { name string @@ -431,14 +570,14 @@ func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { t.Run(tt.name, func(t *testing.T) { repo, users, auth, email := newAuthFakes() users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { - return &domain.User{ID: user.ID, Email: user.Email}, nil + return &domain.User{ID: userID, Email: user.Email}, nil } hasher := fakeHasher{} if tt.name == "hash" { hasher.hashFn = func(string) (string, error) { return "", errFake } } tt.setup(repo, users, auth, email) - svc := NewAuthService(repo, fakeTokens{}, email, hasher, "http://localhost") + svc := newTestAuthService(repo, fakeTokens{}, email, hasher, "http://localhost") if err := svc.Register(context.Background(), user); err == nil { t.Fatal("Register succeeded unexpectedly") } @@ -447,9 +586,9 @@ func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { repo, users, _, email := newAuthFakes() users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { - return &domain.User{ID: user.ID, Email: user.Email}, nil + return &domain.User{ID: userID, Email: user.Email}, nil } - if err := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost").Register(context.Background(), user); err != nil { + if err := newTestAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost").Register(context.Background(), user); err != nil { t.Fatalf("Register = %v", err) } if repo.withTxCalls != 1 { @@ -460,9 +599,9 @@ func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { t.Run("create verification and forgot password", func(t *testing.T) { repo, users, auth, email := newAuthFakes() users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { - return &domain.User{ID: user.ID, Email: user.Email}, nil + return &domain.User{ID: userID, Email: user.Email}, nil } - svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + svc := newTestAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") if err := svc.CreateVerifyEmail(context.Background(), user.Email); err != nil { t.Fatalf("CreateVerifyEmail = %v", err) } @@ -482,7 +621,7 @@ func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { } func TestAuthServiceLogin(t *testing.T) { - baseUser := &domain.User{ID: 4, Email: "user@example.com", Password: "hashed"} + baseUser := &domain.User{ID: 4, Email: "user@example.com", PasswordHash: "hashed"} tests := []struct { name string setup func(*fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeHasher, *fakeTokens) @@ -516,7 +655,7 @@ func TestAuthServiceLogin(t *testing.T) { hasher := &fakeHasher{} tokens := &fakeTokens{accessToken: "access", refreshToken: "refresh", refreshHash: []byte("hash")} tt.setup(repo, users, auth, hasher, tokens) - _, _, err := NewAuthService(repo, tokens, email, hasher, "http://localhost").Login(context.Background(), domain.User{Email: baseUser.Email, Password: "password"}) + _, _, err := newTestAuthService(repo, tokens, email, hasher, "http://localhost").Login(context.Background(), port.LoginCommand{Email: baseUser.Email, Password: "password"}) if err == nil { t.Fatal("Login succeeded unexpectedly") } @@ -526,7 +665,7 @@ func TestAuthServiceLogin(t *testing.T) { repo, users, auth, email := newAuthFakes() users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } tokens := &fakeTokens{accessToken: "access", refreshToken: "refresh", refreshHash: []byte("hash")} - access, refresh, err := NewAuthService(repo, tokens, email, fakeHasher{}, "http://localhost").Login(context.Background(), domain.User{Email: baseUser.Email, Password: "password"}) + access, refresh, err := newTestAuthService(repo, tokens, email, fakeHasher{}, "http://localhost").Login(context.Background(), port.LoginCommand{Email: baseUser.Email, Password: "password"}) if err != nil || access != "access" || refresh != "refresh" { t.Fatalf("Login = %q, %q, %v", access, refresh, err) } @@ -538,7 +677,7 @@ func TestAuthServiceVerifyAndReset(t *testing.T) { userID := 5 t.Run("verify email", func(t *testing.T) { repo, _, auth, email := newAuthFakes() - svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + svc := newTestAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") if err := svc.VerifyEmail(context.Background(), "bad"); !errors.Is(err, domain.ErrInvalidInput) { t.Fatalf("invalid ID error = %v", err) } @@ -571,12 +710,12 @@ func TestAuthServiceVerifyAndReset(t *testing.T) { t.Run("reset password", func(t *testing.T) { repo, users, auth, email := newAuthFakes() - svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + svc := newTestAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") if err := svc.ResetPassword(context.Background(), "bad", "new"); !errors.Is(err, domain.ErrInvalidInput) { t.Fatalf("invalid ID error = %v", err) } hasher := fakeHasher{hashFn: func(string) (string, error) { return "", errFake }} - if err := NewAuthService(repo, fakeTokens{}, email, hasher, "http://localhost").ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { + if err := newTestAuthService(repo, fakeTokens{}, email, hasher, "http://localhost").ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { t.Fatalf("hash error = %v", err) } auth.getForgotPasswordByIDFn = func(context.Context, string) (domain.ForgotPassword, error) { return domain.ForgotPassword{}, errFake } @@ -622,7 +761,7 @@ func TestAuthServiceRefreshAndLogout(t *testing.T) { users.getByIDFn = func(context.Context, int) (*domain.User, error) { return user, nil } auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return baseToken, nil } tokens := &fakeTokens{accessToken: "access", refreshToken: "new-refresh", refreshHash: []byte("hash")} - return NewAuthService(repo, tokens, email, fakeHasher{}, "http://localhost").(*authService), repo, users, auth, tokens + return newTestAuthService(repo, tokens, email, fakeHasher{}, "http://localhost"), repo, users, auth, tokens } t.Run("refresh success", func(t *testing.T) { diff --git a/internal/platform/audit.go b/internal/observability/audit.go similarity index 99% rename from internal/platform/audit.go rename to internal/observability/audit.go index 0cc2f29..7d0059b 100644 --- a/internal/platform/audit.go +++ b/internal/observability/audit.go @@ -1,4 +1,4 @@ -package platform +package observability import ( "context" diff --git a/internal/platform/logging.go b/internal/observability/logging.go similarity index 97% rename from internal/platform/logging.go rename to internal/observability/logging.go index 8d36388..650701c 100644 --- a/internal/platform/logging.go +++ b/internal/observability/logging.go @@ -1,4 +1,4 @@ -package platform +package observability import ( "log/slog" diff --git a/internal/platform/logging_test.go b/internal/observability/logging_test.go similarity index 98% rename from internal/platform/logging_test.go rename to internal/observability/logging_test.go index 36f4564..53c26ef 100644 --- a/internal/platform/logging_test.go +++ b/internal/observability/logging_test.go @@ -1,4 +1,4 @@ -package platform +package observability import ( "bytes" diff --git a/internal/platform/metrics.go b/internal/observability/metrics.go similarity index 99% rename from internal/platform/metrics.go rename to internal/observability/metrics.go index 515e656..cca8199 100644 --- a/internal/platform/metrics.go +++ b/internal/observability/metrics.go @@ -1,4 +1,4 @@ -package platform +package observability import ( "net/http" diff --git a/internal/platform/metrics_test.go b/internal/observability/metrics_test.go similarity index 99% rename from internal/platform/metrics_test.go rename to internal/observability/metrics_test.go index 42859da..47fd328 100644 --- a/internal/platform/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -1,4 +1,4 @@ -package platform +package observability import ( "bytes"