Merge pull request #34 from bnema/refactor/ratelimit-clean-architecture
refactor: extract rate limiter to clean architecture port/adapter
36 files · +2514 -573
M .github/actions/deploy/README.md +1 -2
| @@ -122,12 +122,11 @@ In your repository settings, add: | |||
| 122 | 122 | ||
| 123 | 123 | ### 3. Configure Gordon | |
| 124 | 124 | ||
| 125 | - | Ensure your Gordon config uses token authentication: | |
| 125 | + | Ensure your Gordon config has authentication enabled: | |
| 126 | 126 | ||
| 127 | 127 | ```toml | |
| 128 | 128 | [auth] | |
| 129 | 129 | enabled = true | |
| 130 | - | type = "token" | |
| 131 | 130 | secrets_backend = "pass" # or "sops" | |
| 132 | 131 | token_secret = "gordon/auth/token_secret" | |
| 133 | 132 | ``` | |
| @@ -122,12 +122,11 @@ In your repository settings, add: | |||||
| 122 | 122 | ||||
| 123 | ### 3. Configure Gordon | 123 | ### 3. Configure Gordon | ||
| 124 | 124 | ||||
| 125 | - | Ensure your Gordon config uses token authentication: | 125 | + | Ensure your Gordon config has authentication enabled: |
| 126 | 126 | ||||
| 127 | ```toml | 127 | ```toml | ||
| 128 | [auth] | 128 | [auth] | ||
| 129 | enabled = true | 129 | enabled = true | ||
| 130 | - | type = "token" | |||
| 131 | secrets_backend = "pass" # or "sops" | 130 | secrets_backend = "pass" # or "sops" | ||
| 132 | token_secret = "gordon/auth/token_secret" | 131 | token_secret = "gordon/auth/token_secret" | ||
| 133 | ``` | 132 | ``` | ||
M .mockery.yaml +1 -0
| @@ -21,6 +21,7 @@ packages: | |||
| 21 | 21 | ContainerLogWriter: | |
| 22 | 22 | TokenStore: | |
| 23 | 23 | DomainSecretStore: | |
| 24 | + | RateLimiter: | |
| 24 | 25 | gordon/internal/boundaries/in: | |
| 25 | 26 | interfaces: | |
| 26 | 27 | ContainerService: | |
| @@ -21,6 +21,7 @@ packages: | |||||
| 21 | ContainerLogWriter: | 21 | ContainerLogWriter: | ||
| 22 | TokenStore: | 22 | TokenStore: | ||
| 23 | DomainSecretStore: | 23 | DomainSecretStore: | ||
| 24 | + | RateLimiter: | |||
| 24 | gordon/internal/boundaries/in: | 25 | gordon/internal/boundaries/in: | ||
| 25 | interfaces: | 26 | interfaces: | ||
| 26 | ContainerService: | 27 | ContainerService: | ||
M docs/cli/auth.md +58 -2
| @@ -8,6 +8,7 @@ Manage Gordon server authentication tokens and passwords. | |||
| 8 | 8 | ||
| 9 | 9 | | Subcommand | Description | | |
| 10 | 10 | |------------|-------------| | |
| 11 | + | | `login` | Authenticate with a remote Gordon server | | |
| 11 | 12 | | `token generate` | Generate a new JWT token | | |
| 12 | 13 | | `token list` | List all stored tokens | | |
| 13 | 14 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | |
| @@ -16,6 +17,56 @@ Manage Gordon server authentication tokens and passwords. | |||
| 16 | 17 | ||
| 17 | 18 | --- | |
| 18 | 19 | ||
| 20 | + | ## gordon auth login | |
| 21 | + | ||
| 22 | + | Authenticate with a remote Gordon server using password authentication. | |
| 23 | + | ||
| 24 | + | ```bash | |
| 25 | + | gordon auth login [options] | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | ### Options | |
| 29 | + | ||
| 30 | + | | Option | Description | | |
| 31 | + | |--------|-------------| | |
| 32 | + | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |
| 33 | + | | `-u, --username` | Username for authentication | | |
| 34 | + | ||
| 35 | + | ### Description | |
| 36 | + | ||
| 37 | + | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration. | |
| 38 | + | ||
| 39 | + | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | |
| 40 | + | ||
| 41 | + | > **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead. | |
| 42 | + | ||
| 43 | + | ### Examples | |
| 44 | + | ||
| 45 | + | ```bash | |
| 46 | + | # Login using active remote | |
| 47 | + | gordon auth login | |
| 48 | + | ||
| 49 | + | # Login to specific remote | |
| 50 | + | gordon auth login --remote prod | |
| 51 | + | ||
| 52 | + | # Pre-fill username | |
| 53 | + | gordon auth login --username admin | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ### Output | |
| 57 | + | ||
| 58 | + | ``` | |
| 59 | + | Username: admin | |
| 60 | + | Password: ******** | |
| 61 | + | Authenticating with prod... | |
| 62 | + | ||
| 63 | + | ✓ Authentication successful! | |
| 64 | + | Token stored for remote 'prod' | |
| 65 | + | Expires in: 604800 seconds | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | --- | |
| 69 | + | ||
| 19 | 70 | ## gordon auth token generate | |
| 20 | 71 | ||
| 21 | 72 | Generate a new JWT authentication token for registry access. | |
| @@ -146,8 +197,9 @@ $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 146 | 197 | ||
| 147 | 198 | Then reference the path in your config: | |
| 148 | 199 | [auth] | |
| 149 | - | type = "password" | |
| 200 | + | username = "deploy" | |
| 150 | 201 | password_hash = "gordon/auth/password_hash" | |
| 202 | + | token_secret = "gordon/auth/token_secret" | |
| 151 | 203 | ``` | |
| 152 | 204 | ||
| 153 | 205 | --- | |
| @@ -253,13 +305,17 @@ gordon auth password hash | |||
| 253 | 305 | pass insert gordon/auth/password_hash | |
| 254 | 306 | # Paste: $2a$10$... | |
| 255 | 307 | ||
| 308 | + | # Create token secret | |
| 309 | + | pass insert gordon/auth/token_secret | |
| 310 | + | # Enter a random 32+ character string | |
| 311 | + | ||
| 256 | 312 | # Configure gordon.toml | |
| 257 | 313 | [auth] | |
| 258 | 314 | enabled = true | |
| 259 | - | type = "password" | |
| 260 | 315 | secrets_backend = "pass" | |
| 261 | 316 | username = "deploy" | |
| 262 | 317 | password_hash = "gordon/auth/password_hash" | |
| 318 | + | token_secret = "gordon/auth/token_secret" | |
| 263 | 319 | ``` | |
| 264 | 320 | ||
| 265 | 321 | ## Related | |
| @@ -8,6 +8,7 @@ Manage Gordon server authentication tokens and passwords. | |||||
| 8 | 8 | ||||
| 9 | | Subcommand | Description | | 9 | | Subcommand | Description | | ||
| 10 | |------------|-------------| | 10 | |------------|-------------| | ||
| 11 | + | | `login` | Authenticate with a remote Gordon server | | |||
| 11 | | `token generate` | Generate a new JWT token | | 12 | | `token generate` | Generate a new JWT token | | ||
| 12 | | `token list` | List all stored tokens | | 13 | | `token list` | List all stored tokens | | ||
| 13 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | 14 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | ||
| @@ -16,6 +17,56 @@ Manage Gordon server authentication tokens and passwords. | |||||
| 16 | 17 | ||||
| 17 | --- | 18 | --- | ||
| 18 | 19 | ||||
| 20 | + | ## gordon auth login | |||
| 21 | + | ||||
| 22 | + | Authenticate with a remote Gordon server using password authentication. | |||
| 23 | + | ||||
| 24 | + | ```bash | |||
| 25 | + | gordon auth login [options] | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | ### Options | |||
| 29 | + | ||||
| 30 | + | | Option | Description | | |||
| 31 | + | |--------|-------------| | |||
| 32 | + | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |||
| 33 | + | | `-u, --username` | Username for authentication | | |||
| 34 | + | ||||
| 35 | + | ### Description | |||
| 36 | + | ||||
| 37 | + | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration. | |||
| 38 | + | ||||
| 39 | + | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | |||
| 40 | + | ||||
| 41 | + | > **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead. | |||
| 42 | + | ||||
| 43 | + | ### Examples | |||
| 44 | + | ||||
| 45 | + | ```bash | |||
| 46 | + | # Login using active remote | |||
| 47 | + | gordon auth login | |||
| 48 | + | ||||
| 49 | + | # Login to specific remote | |||
| 50 | + | gordon auth login --remote prod | |||
| 51 | + | ||||
| 52 | + | # Pre-fill username | |||
| 53 | + | gordon auth login --username admin | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | ### Output | |||
| 57 | + | ||||
| 58 | + | ``` | |||
| 59 | + | Username: admin | |||
| 60 | + | Password: ******** | |||
| 61 | + | Authenticating with prod... | |||
| 62 | + | ||||
| 63 | + | ✓ Authentication successful! | |||
| 64 | + | Token stored for remote 'prod' | |||
| 65 | + | Expires in: 604800 seconds | |||
| 66 | + | ``` | |||
| 67 | + | ||||
| 68 | + | --- | |||
| 69 | + | ||||
| 19 | ## gordon auth token generate | 70 | ## gordon auth token generate | ||
| 20 | 71 | ||||
| 21 | Generate a new JWT authentication token for registry access. | 72 | Generate a new JWT authentication token for registry access. | ||
| @@ -146,8 +197,9 @@ $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||||
| 146 | 197 | ||||
| 147 | Then reference the path in your config: | 198 | Then reference the path in your config: | ||
| 148 | [auth] | 199 | [auth] | ||
| 149 | - | type = "password" | 200 | + | username = "deploy" |
| 150 | password_hash = "gordon/auth/password_hash" | 201 | password_hash = "gordon/auth/password_hash" | ||
| 202 | + | token_secret = "gordon/auth/token_secret" | |||
| 151 | ``` | 203 | ``` | ||
| 152 | 204 | ||||
| 153 | --- | 205 | --- | ||
| @@ -253,13 +305,17 @@ gordon auth password hash | |||||
| 253 | pass insert gordon/auth/password_hash | 305 | pass insert gordon/auth/password_hash | ||
| 254 | # Paste: $2a$10$... | 306 | # Paste: $2a$10$... | ||
| 255 | 307 | ||||
| 308 | + | # Create token secret | |||
| 309 | + | pass insert gordon/auth/token_secret | |||
| 310 | + | # Enter a random 32+ character string | |||
| 311 | + | ||||
| 256 | # Configure gordon.toml | 312 | # Configure gordon.toml | ||
| 257 | [auth] | 313 | [auth] | ||
| 258 | enabled = true | 314 | enabled = true | ||
| 259 | - | type = "password" | |||
| 260 | secrets_backend = "pass" | 315 | secrets_backend = "pass" | ||
| 261 | username = "deploy" | 316 | username = "deploy" | ||
| 262 | password_hash = "gordon/auth/password_hash" | 317 | password_hash = "gordon/auth/password_hash" | ||
| 318 | + | token_secret = "gordon/auth/token_secret" | |||
| 263 | ``` | 319 | ``` | ||
| 264 | 320 | ||||
| 265 | ## Related | 321 | ## Related | ||
M docs/cli/remotes.md +47 -0
| @@ -12,6 +12,7 @@ Manage saved remote Gordon instances for easier CLI usage. | |||
| 12 | 12 | | `add` | Add a new remote | | |
| 13 | 13 | | `remove` | Remove a saved remote | | |
| 14 | 14 | | `use` | Set active remote | | |
| 15 | + | | `set-token` | Set or update token for a remote | | |
| 15 | 16 | ||
| 16 | 17 | --- | |
| 17 | 18 | ||
| @@ -165,6 +166,52 @@ gordon remotes use prod | |||
| 165 | 166 | ||
| 166 | 167 | --- | |
| 167 | 168 | ||
| 169 | + | ## gordon remotes set-token | |
| 170 | + | ||
| 171 | + | Set or update the authentication token for a saved remote. | |
| 172 | + | ||
| 173 | + | ```bash | |
| 174 | + | gordon remotes set-token <name> <token> | |
| 175 | + | ``` | |
| 176 | + | ||
| 177 | + | ### Arguments | |
| 178 | + | ||
| 179 | + | | Argument | Description | | |
| 180 | + | |----------|-------------| | |
| 181 | + | | `<name>` | Name of the remote | | |
| 182 | + | | `<token>` | The JWT token to set | | |
| 183 | + | ||
| 184 | + | ### Description | |
| 185 | + | ||
| 186 | + | This command is useful when: | |
| 187 | + | ||
| 188 | + | - The server uses token-based authentication (not password auth) | |
| 189 | + | - You have a pre-generated token from `gordon auth token generate` | |
| 190 | + | - You want to update an expired token | |
| 191 | + | ||
| 192 | + | For servers with password authentication, use `gordon auth login` instead. | |
| 193 | + | ||
| 194 | + | ### Examples | |
| 195 | + | ||
| 196 | + | ```bash | |
| 197 | + | # Set token for prod remote | |
| 198 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |
| 199 | + | ||
| 200 | + | # Set token from file | |
| 201 | + | gordon remotes set-token staging $(cat token.txt) | |
| 202 | + | ||
| 203 | + | # Set token from environment variable | |
| 204 | + | gordon remotes set-token prod "$GORDON_TOKEN" | |
| 205 | + | ``` | |
| 206 | + | ||
| 207 | + | ### Output | |
| 208 | + | ||
| 209 | + | ``` | |
| 210 | + | ✓ Token updated for remote 'prod' | |
| 211 | + | ``` | |
| 212 | + | ||
| 213 | + | --- | |
| 214 | + | ||
| 168 | 215 | ## Configuration File | |
| 169 | 216 | ||
| 170 | 217 | Remotes are stored in `~/.config/gordon/remotes.toml`: | |
| @@ -12,6 +12,7 @@ Manage saved remote Gordon instances for easier CLI usage. | |||||
| 12 | | `add` | Add a new remote | | 12 | | `add` | Add a new remote | | ||
| 13 | | `remove` | Remove a saved remote | | 13 | | `remove` | Remove a saved remote | | ||
| 14 | | `use` | Set active remote | | 14 | | `use` | Set active remote | | ||
| 15 | + | | `set-token` | Set or update token for a remote | | |||
| 15 | 16 | ||||
| 16 | --- | 17 | --- | ||
| 17 | 18 | ||||
| @@ -165,6 +166,52 @@ gordon remotes use prod | |||||
| 165 | 166 | ||||
| 166 | --- | 167 | --- | ||
| 167 | 168 | ||||
| 169 | + | ## gordon remotes set-token | |||
| 170 | + | ||||
| 171 | + | Set or update the authentication token for a saved remote. | |||
| 172 | + | ||||
| 173 | + | ```bash | |||
| 174 | + | gordon remotes set-token <name> <token> | |||
| 175 | + | ``` | |||
| 176 | + | ||||
| 177 | + | ### Arguments | |||
| 178 | + | ||||
| 179 | + | | Argument | Description | | |||
| 180 | + | |----------|-------------| | |||
| 181 | + | | `<name>` | Name of the remote | | |||
| 182 | + | | `<token>` | The JWT token to set | | |||
| 183 | + | ||||
| 184 | + | ### Description | |||
| 185 | + | ||||
| 186 | + | This command is useful when: | |||
| 187 | + | ||||
| 188 | + | - The server uses token-based authentication (not password auth) | |||
| 189 | + | - You have a pre-generated token from `gordon auth token generate` | |||
| 190 | + | - You want to update an expired token | |||
| 191 | + | ||||
| 192 | + | For servers with password authentication, use `gordon auth login` instead. | |||
| 193 | + | ||||
| 194 | + | ### Examples | |||
| 195 | + | ||||
| 196 | + | ```bash | |||
| 197 | + | # Set token for prod remote | |||
| 198 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |||
| 199 | + | ||||
| 200 | + | # Set token from file | |||
| 201 | + | gordon remotes set-token staging $(cat token.txt) | |||
| 202 | + | ||||
| 203 | + | # Set token from environment variable | |||
| 204 | + | gordon remotes set-token prod "$GORDON_TOKEN" | |||
| 205 | + | ``` | |||
| 206 | + | ||||
| 207 | + | ### Output | |||
| 208 | + | ||||
| 209 | + | ``` | |||
| 210 | + | ✓ Token updated for remote 'prod' | |||
| 211 | + | ``` | |||
| 212 | + | ||||
| 213 | + | --- | |||
| 214 | + | ||||
| 168 | ## Configuration File | 215 | ## Configuration File | ||
| 169 | 216 | ||||
| 170 | Remotes are stored in `~/.config/gordon/remotes.toml`: | 217 | Remotes are stored in `~/.config/gordon/remotes.toml`: | ||
M docs/config/auth.md +51 -16
| @@ -34,10 +34,13 @@ enabled = false | |||
| 34 | 34 | ```toml | |
| 35 | 35 | [auth] | |
| 36 | 36 | enabled = true | |
| 37 | - | type = "token" | |
| 38 | 37 | secrets_backend = "pass" | |
| 39 | 38 | token_secret = "gordon/auth/token_secret" | |
| 40 | 39 | token_expiry = "30d" | |
| 40 | + | ||
| 41 | + | # Optional: enable password authentication | |
| 42 | + | username = "deploy" | |
| 43 | + | password_hash = "gordon/auth/password_hash" | |
| 41 | 44 | ``` | |
| 42 | 45 | ||
| 43 | 46 | ## Options | |
| @@ -45,12 +48,22 @@ token_expiry = "30d" | |||
| 45 | 48 | | Option | Type | Default | Description | | |
| 46 | 49 | |--------|------|---------|-------------| | |
| 47 | 50 | | `enabled` | bool | `true` | Enable authentication | | |
| 48 | - | | `type` | string | `"password"` | Auth type: `"password"` or `"token"` | | |
| 49 | 51 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 50 | - | | `username` | string | - | Username for password auth | | |
| 51 | - | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | |
| 52 | - | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | |
| 52 | + | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | |
| 53 | 53 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | |
| 54 | + | | `username` | string | - | Username for password auth (optional) | | |
| 55 | + | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | |
| 56 | + | ||
| 57 | + | ## Authentication Modes | |
| 58 | + | ||
| 59 | + | Gordon infers the authentication mode from your configuration: | |
| 60 | + | ||
| 61 | + | | Config | Mode | Description | | |
| 62 | + | |--------|------|-------------| | |
| 63 | + | | Only `token_secret` | Token-only | JWT tokens for all access | | |
| 64 | + | | `token_secret` + `username` + `password_hash` | Password + Token | Password for interactive login, tokens for CI/CD | | |
| 65 | + | ||
| 66 | + | Tokens always work regardless of mode. The difference is whether password authentication is available. | |
| 54 | 67 | ||
| 55 | 68 | ## Environment Variables | |
| 56 | 69 | ||
| @@ -81,14 +94,13 @@ See [Secret Providers](./secrets.md) for detailed configuration of each backend. | |||
| 81 | 94 | ||
| 82 | 95 | ## Authentication Types | |
| 83 | 96 | ||
| 84 | - | ### Token Authentication (Recommended) | |
| 97 | + | ### Token-Only Mode | |
| 85 | 98 | ||
| 86 | - | JWT-based authentication, ideal for CI/CD pipelines: | |
| 99 | + | JWT-based authentication, ideal for CI/CD pipelines when you don't need interactive login: | |
| 87 | 100 | ||
| 88 | 101 | ```toml | |
| 89 | 102 | [auth] | |
| 90 | 103 | enabled = true | |
| 91 | - | type = "token" | |
| 92 | 104 | secrets_backend = "pass" | |
| 93 | 105 | token_secret = "gordon/auth/token_secret" | |
| 94 | 106 | token_expiry = "30d" | |
| @@ -121,19 +133,21 @@ gordon auth token revoke <token-id> | |||
| 121 | 133 | gordon auth token revoke --all | |
| 122 | 134 | ``` | |
| 123 | 135 | ||
| 124 | - | ### Password Authentication | |
| 136 | + | ### Password + Token Mode | |
| 125 | 137 | ||
| 126 | - | Simple username/password authentication: | |
| 138 | + | Password authentication for interactive login, plus tokens for CI/CD: | |
| 127 | 139 | ||
| 128 | 140 | ```toml | |
| 129 | 141 | [auth] | |
| 130 | 142 | enabled = true | |
| 131 | - | type = "password" | |
| 132 | 143 | secrets_backend = "pass" | |
| 133 | 144 | username = "deploy" | |
| 134 | 145 | password_hash = "gordon/auth/password_hash" | |
| 146 | + | token_secret = "gordon/auth/token_secret" | |
| 135 | 147 | ``` | |
| 136 | 148 | ||
| 149 | + | When `username` and `password_hash` are configured, both password and token authentication work. | |
| 150 | + | ||
| 137 | 151 | **Setup:** | |
| 138 | 152 | ```bash | |
| 139 | 153 | # Generate bcrypt hash | |
| @@ -143,13 +157,36 @@ gordon auth password hash | |||
| 143 | 157 | # Store hash in secrets backend | |
| 144 | 158 | pass insert gordon/auth/password_hash | |
| 145 | 159 | # Paste the bcrypt hash | |
| 160 | + | ||
| 161 | + | # Create token secret (for CI/CD token generation) | |
| 162 | + | pass insert gordon/auth/token_secret | |
| 163 | + | # Enter a random 32+ character string | |
| 146 | 164 | ``` | |
| 147 | 165 | ||
| 148 | 166 | **Usage:** | |
| 149 | 167 | ```bash | |
| 168 | + | # Docker registry access | |
| 150 | 169 | docker login -u deploy -p <password> registry.mydomain.com | |
| 170 | + | ||
| 171 | + | # Remote CLI authentication | |
| 172 | + | gordon auth login --remote prod | |
| 173 | + | # Enter username and password when prompted | |
| 151 | 174 | ``` | |
| 152 | 175 | ||
| 176 | + | **Remote CLI Authentication:** | |
| 177 | + | ||
| 178 | + | With password authentication enabled, you can use `gordon auth login` to obtain a token for remote CLI operations: | |
| 179 | + | ||
| 180 | + | ```bash | |
| 181 | + | gordon auth login --remote prod | |
| 182 | + | # Username: deploy | |
| 183 | + | # Password: ******** | |
| 184 | + | # ✓ Authentication successful! | |
| 185 | + | # Token stored for remote 'prod' | |
| 186 | + | ``` | |
| 187 | + | ||
| 188 | + | The returned token is stored automatically and used for subsequent CLI commands. | |
| 189 | + | ||
| 153 | 190 | ## Token Scopes | |
| 154 | 191 | ||
| 155 | 192 | Tokens can have different permission levels: | |
| @@ -232,12 +269,11 @@ enabled = false | |||
| 232 | 269 | ||
| 233 | 270 | ## Examples | |
| 234 | 271 | ||
| 235 | - | ### CI/CD Setup | |
| 272 | + | ### CI/CD Setup (Token-Only) | |
| 236 | 273 | ||
| 237 | 274 | ```toml | |
| 238 | 275 | [auth] | |
| 239 | 276 | enabled = true | |
| 240 | - | type = "token" | |
| 241 | 277 | secrets_backend = "pass" | |
| 242 | 278 | token_secret = "gordon/auth/token_secret" | |
| 243 | 279 | ``` | |
| @@ -253,15 +289,15 @@ In GitHub Actions: | |||
| 253 | 289 | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | |
| 254 | 290 | ``` | |
| 255 | 291 | ||
| 256 | - | ### Production with Password | |
| 292 | + | ### Production with Password + Token | |
| 257 | 293 | ||
| 258 | 294 | ```toml | |
| 259 | 295 | [auth] | |
| 260 | 296 | enabled = true | |
| 261 | - | type = "password" | |
| 262 | 297 | secrets_backend = "pass" | |
| 263 | 298 | username = "deploy" | |
| 264 | 299 | password_hash = "gordon/auth/password_hash" | |
| 300 | + | token_secret = "gordon/auth/token_secret" | |
| 265 | 301 | ``` | |
| 266 | 302 | ||
| 267 | 303 | ### Enterprise with SOPS | |
| @@ -269,7 +305,6 @@ password_hash = "gordon/auth/password_hash" | |||
| 269 | 305 | ```toml | |
| 270 | 306 | [auth] | |
| 271 | 307 | enabled = true | |
| 272 | - | type = "token" | |
| 273 | 308 | secrets_backend = "sops" | |
| 274 | 309 | token_secret = "gordon/auth/token_secret" | |
| 275 | 310 | ``` | |
| @@ -34,10 +34,13 @@ enabled = false | |||||
| 34 | ```toml | 34 | ```toml | ||
| 35 | [auth] | 35 | [auth] | ||
| 36 | enabled = true | 36 | enabled = true | ||
| 37 | - | type = "token" | |||
| 38 | secrets_backend = "pass" | 37 | secrets_backend = "pass" | ||
| 39 | token_secret = "gordon/auth/token_secret" | 38 | token_secret = "gordon/auth/token_secret" | ||
| 40 | token_expiry = "30d" | 39 | token_expiry = "30d" | ||
| 40 | + | ||||
| 41 | + | # Optional: enable password authentication | |||
| 42 | + | username = "deploy" | |||
| 43 | + | password_hash = "gordon/auth/password_hash" | |||
| 41 | ``` | 44 | ``` | ||
| 42 | 45 | ||||
| 43 | ## Options | 46 | ## Options | ||
| @@ -45,12 +48,22 @@ token_expiry = "30d" | |||||
| 45 | | Option | Type | Default | Description | | 48 | | Option | Type | Default | Description | | ||
| 46 | |--------|------|---------|-------------| | 49 | |--------|------|---------|-------------| | ||
| 47 | | `enabled` | bool | `true` | Enable authentication | | 50 | | `enabled` | bool | `true` | Enable authentication | | ||
| 48 | - | | `type` | string | `"password"` | Auth type: `"password"` or `"token"` | | |||
| 49 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | 51 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | ||
| 50 | - | | `username` | string | - | Username for password auth | | 52 | + | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | |
| 51 | - | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | |||
| 52 | - | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | |||
| 53 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | 53 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | ||
| 54 | + | | `username` | string | - | Username for password auth (optional) | | |||
| 55 | + | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | |||
| 56 | + | ||||
| 57 | + | ## Authentication Modes | |||
| 58 | + | ||||
| 59 | + | Gordon infers the authentication mode from your configuration: | |||
| 60 | + | ||||
| 61 | + | | Config | Mode | Description | | |||
| 62 | + | |--------|------|-------------| | |||
| 63 | + | | Only `token_secret` | Token-only | JWT tokens for all access | | |||
| 64 | + | | `token_secret` + `username` + `password_hash` | Password + Token | Password for interactive login, tokens for CI/CD | | |||
| 65 | + | ||||
| 66 | + | Tokens always work regardless of mode. The difference is whether password authentication is available. | |||
| 54 | 67 | ||||
| 55 | ## Environment Variables | 68 | ## Environment Variables | ||
| 56 | 69 | ||||
| @@ -81,14 +94,13 @@ See [Secret Providers](./secrets.md) for detailed configuration of each backend. | |||||
| 81 | 94 | ||||
| 82 | ## Authentication Types | 95 | ## Authentication Types | ||
| 83 | 96 | ||||
| 84 | - | ### Token Authentication (Recommended) | 97 | + | ### Token-Only Mode |
| 85 | 98 | ||||
| 86 | - | JWT-based authentication, ideal for CI/CD pipelines: | 99 | + | JWT-based authentication, ideal for CI/CD pipelines when you don't need interactive login: |
| 87 | 100 | ||||
| 88 | ```toml | 101 | ```toml | ||
| 89 | [auth] | 102 | [auth] | ||
| 90 | enabled = true | 103 | enabled = true | ||
| 91 | - | type = "token" | |||
| 92 | secrets_backend = "pass" | 104 | secrets_backend = "pass" | ||
| 93 | token_secret = "gordon/auth/token_secret" | 105 | token_secret = "gordon/auth/token_secret" | ||
| 94 | token_expiry = "30d" | 106 | token_expiry = "30d" | ||
| @@ -121,19 +133,21 @@ gordon auth token revoke <token-id> | |||||
| 121 | gordon auth token revoke --all | 133 | gordon auth token revoke --all | ||
| 122 | ``` | 134 | ``` | ||
| 123 | 135 | ||||
| 124 | - | ### Password Authentication | 136 | + | ### Password + Token Mode |
| 125 | 137 | ||||
| 126 | - | Simple username/password authentication: | 138 | + | Password authentication for interactive login, plus tokens for CI/CD: |
| 127 | 139 | ||||
| 128 | ```toml | 140 | ```toml | ||
| 129 | [auth] | 141 | [auth] | ||
| 130 | enabled = true | 142 | enabled = true | ||
| 131 | - | type = "password" | |||
| 132 | secrets_backend = "pass" | 143 | secrets_backend = "pass" | ||
| 133 | username = "deploy" | 144 | username = "deploy" | ||
| 134 | password_hash = "gordon/auth/password_hash" | 145 | password_hash = "gordon/auth/password_hash" | ||
| 146 | + | token_secret = "gordon/auth/token_secret" | |||
| 135 | ``` | 147 | ``` | ||
| 136 | 148 | ||||
| 149 | + | When `username` and `password_hash` are configured, both password and token authentication work. | |||
| 150 | + | ||||
| 137 | **Setup:** | 151 | **Setup:** | ||
| 138 | ```bash | 152 | ```bash | ||
| 139 | # Generate bcrypt hash | 153 | # Generate bcrypt hash | ||
| @@ -143,13 +157,36 @@ gordon auth password hash | |||||
| 143 | # Store hash in secrets backend | 157 | # Store hash in secrets backend | ||
| 144 | pass insert gordon/auth/password_hash | 158 | pass insert gordon/auth/password_hash | ||
| 145 | # Paste the bcrypt hash | 159 | # Paste the bcrypt hash | ||
| 160 | + | ||||
| 161 | + | # Create token secret (for CI/CD token generation) | |||
| 162 | + | pass insert gordon/auth/token_secret | |||
| 163 | + | # Enter a random 32+ character string | |||
| 146 | ``` | 164 | ``` | ||
| 147 | 165 | ||||
| 148 | **Usage:** | 166 | **Usage:** | ||
| 149 | ```bash | 167 | ```bash | ||
| 168 | + | # Docker registry access | |||
| 150 | docker login -u deploy -p <password> registry.mydomain.com | 169 | docker login -u deploy -p <password> registry.mydomain.com | ||
| 170 | + | ||||
| 171 | + | # Remote CLI authentication | |||
| 172 | + | gordon auth login --remote prod | |||
| 173 | + | # Enter username and password when prompted | |||
| 151 | ``` | 174 | ``` | ||
| 152 | 175 | ||||
| 176 | + | **Remote CLI Authentication:** | |||
| 177 | + | ||||
| 178 | + | With password authentication enabled, you can use `gordon auth login` to obtain a token for remote CLI operations: | |||
| 179 | + | ||||
| 180 | + | ```bash | |||
| 181 | + | gordon auth login --remote prod | |||
| 182 | + | # Username: deploy | |||
| 183 | + | # Password: ******** | |||
| 184 | + | # ✓ Authentication successful! | |||
| 185 | + | # Token stored for remote 'prod' | |||
| 186 | + | ``` | |||
| 187 | + | ||||
| 188 | + | The returned token is stored automatically and used for subsequent CLI commands. | |||
| 189 | + | ||||
| 153 | ## Token Scopes | 190 | ## Token Scopes | ||
| 154 | 191 | ||||
| 155 | Tokens can have different permission levels: | 192 | Tokens can have different permission levels: | ||
| @@ -232,12 +269,11 @@ enabled = false | |||||
| 232 | 269 | ||||
| 233 | ## Examples | 270 | ## Examples | ||
| 234 | 271 | ||||
| 235 | - | ### CI/CD Setup | 272 | + | ### CI/CD Setup (Token-Only) |
| 236 | 273 | ||||
| 237 | ```toml | 274 | ```toml | ||
| 238 | [auth] | 275 | [auth] | ||
| 239 | enabled = true | 276 | enabled = true | ||
| 240 | - | type = "token" | |||
| 241 | secrets_backend = "pass" | 277 | secrets_backend = "pass" | ||
| 242 | token_secret = "gordon/auth/token_secret" | 278 | token_secret = "gordon/auth/token_secret" | ||
| 243 | ``` | 279 | ``` | ||
| @@ -253,15 +289,15 @@ In GitHub Actions: | |||||
| 253 | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | 289 | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | ||
| 254 | ``` | 290 | ``` | ||
| 255 | 291 | ||||
| 256 | - | ### Production with Password | 292 | + | ### Production with Password + Token |
| 257 | 293 | ||||
| 258 | ```toml | 294 | ```toml | ||
| 259 | [auth] | 295 | [auth] | ||
| 260 | enabled = true | 296 | enabled = true | ||
| 261 | - | type = "password" | |||
| 262 | secrets_backend = "pass" | 297 | secrets_backend = "pass" | ||
| 263 | username = "deploy" | 298 | username = "deploy" | ||
| 264 | password_hash = "gordon/auth/password_hash" | 299 | password_hash = "gordon/auth/password_hash" | ||
| 300 | + | token_secret = "gordon/auth/token_secret" | |||
| 265 | ``` | 301 | ``` | ||
| 266 | 302 | ||||
| 267 | ### Enterprise with SOPS | 303 | ### Enterprise with SOPS | ||
| @@ -269,7 +305,6 @@ password_hash = "gordon/auth/password_hash" | |||||
| 269 | ```toml | 305 | ```toml | ||
| 270 | [auth] | 306 | [auth] | ||
| 271 | enabled = true | 307 | enabled = true | ||
| 272 | - | type = "token" | |||
| 273 | secrets_backend = "sops" | 308 | secrets_backend = "sops" | ||
| 274 | token_secret = "gordon/auth/token_secret" | 309 | token_secret = "gordon/auth/token_secret" | ||
| 275 | ``` | 310 | ``` | ||
M docs/config/index.md +3 -6
| @@ -38,14 +38,12 @@ gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + A | |||
| 38 | 38 | # Authentication (includes secrets backend) | |
| 39 | 39 | [auth] | |
| 40 | 40 | enabled = true # Enable registry authentication (default: true) | |
| 41 | - | type = "token" # "password" or "token" | |
| 42 | 41 | secrets_backend = "pass" # "pass", "sops", or "unsafe" | |
| 43 | - | # Password auth: | |
| 42 | + | token_secret = "gordon/auth/token_secret" # Required: JWT signing secret | |
| 43 | + | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |
| 44 | + | # Optional: enable password authentication (for interactive login) | |
| 44 | 45 | # username = "deploy" | |
| 45 | 46 | # password_hash = "gordon/auth/password_hash" | |
| 46 | - | # Token auth: | |
| 47 | - | token_secret = "gordon/auth/token_secret" | |
| 48 | - | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |
| 49 | 47 | ||
| 50 | 48 | # API rate limiting | |
| 51 | 49 | [api.rate_limit] | |
| @@ -139,7 +137,6 @@ enabled = false # Auto-create routes from image names | |||
| 139 | 137 | | `server.registry_port` | `5000` | | |
| 140 | 138 | | `server.data_dir` | `~/.gordon` | | |
| 141 | 139 | | `auth.enabled` | `true` | | |
| 142 | - | | `auth.type` | `"password"` | | |
| 143 | 140 | | `auth.secrets_backend` | `"unsafe"` | | |
| 144 | 141 | | `auth.token_expiry` | `"30d"` | | |
| 145 | 142 | | `api.rate_limit.enabled` | `true` | | |
| @@ -38,14 +38,12 @@ gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + A | |||||
| 38 | # Authentication (includes secrets backend) | 38 | # Authentication (includes secrets backend) | ||
| 39 | [auth] | 39 | [auth] | ||
| 40 | enabled = true # Enable registry authentication (default: true) | 40 | enabled = true # Enable registry authentication (default: true) | ||
| 41 | - | type = "token" # "password" or "token" | |||
| 42 | secrets_backend = "pass" # "pass", "sops", or "unsafe" | 41 | secrets_backend = "pass" # "pass", "sops", or "unsafe" | ||
| 43 | - | # Password auth: | 42 | + | token_secret = "gordon/auth/token_secret" # Required: JWT signing secret |
| 43 | + | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |||
| 44 | + | # Optional: enable password authentication (for interactive login) | |||
| 44 | # username = "deploy" | 45 | # username = "deploy" | ||
| 45 | # password_hash = "gordon/auth/password_hash" | 46 | # password_hash = "gordon/auth/password_hash" | ||
| 46 | - | # Token auth: | |||
| 47 | - | token_secret = "gordon/auth/token_secret" | |||
| 48 | - | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |||
| 49 | 47 | ||||
| 50 | # API rate limiting | 48 | # API rate limiting | ||
| 51 | [api.rate_limit] | 49 | [api.rate_limit] | ||
| @@ -139,7 +137,6 @@ enabled = false # Auto-create routes from image names | |||||
| 139 | | `server.registry_port` | `5000` | | 137 | | `server.registry_port` | `5000` | | ||
| 140 | | `server.data_dir` | `~/.gordon` | | 138 | | `server.data_dir` | `~/.gordon` | | ||
| 141 | | `auth.enabled` | `true` | | 139 | | `auth.enabled` | `true` | | ||
| 142 | - | | `auth.type` | `"password"` | | |||
| 143 | | `auth.secrets_backend` | `"unsafe"` | | 140 | | `auth.secrets_backend` | `"unsafe"` | | ||
| 144 | | `auth.token_expiry` | `"30d"` | | 141 | | `auth.token_expiry` | `"30d"` | | ||
| 145 | | `api.rate_limit.enabled` | `true` | | 142 | | `api.rate_limit.enabled` | `true` | | ||
M docs/config/reference.md +4 -13
| @@ -19,12 +19,12 @@ data_dir = "~/.gordon" # Data directory (varies by install | |||
| 19 | 19 | # ============================================================================= | |
| 20 | 20 | [auth] | |
| 21 | 21 | enabled = true # Enable registry authentication (default: true) | |
| 22 | - | type = "password" # "password" or "token" | |
| 23 | 22 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | |
| 24 | - | username = "" # Username for password auth | |
| 25 | - | password_hash = "" # Path in secrets backend to bcrypt hash (REQUIRED for password type) | |
| 26 | - | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type) | |
| 23 | + | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED) | |
| 27 | 24 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | |
| 25 | + | # Optional: enable password authentication (for interactive login) | |
| 26 | + | # username = "" # Username for password auth | |
| 27 | + | # password_hash = "" # Path in secrets backend to bcrypt hash | |
| 28 | 28 | ||
| 29 | 29 | # ============================================================================= | |
| 30 | 30 | # API (applies to both Registry and Admin endpoints) | |
| @@ -126,7 +126,6 @@ preserve = true # Keep volumes when containers are | |||
| 126 | 126 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 127 | 127 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 128 | 128 | | `auth.enabled` | `true` | Enable authentication | | |
| 129 | - | | `auth.type` | `"password"` | Auth type | | |
| 130 | 129 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 131 | 130 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| 132 | 131 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | |
| @@ -166,7 +165,6 @@ Examples: | |||
| 166 | 165 | GORDON_SERVER_PORT=8080 | |
| 167 | 166 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | |
| 168 | 167 | GORDON_AUTH_ENABLED=true | |
| 169 | - | GORDON_AUTH_TYPE=token | |
| 170 | 168 | GORDON_LOGGING_LEVEL=debug | |
| 171 | 169 | GORDON_NETWORK_ISOLATION_ENABLED=true | |
| 172 | 170 | ``` | |
| @@ -200,13 +198,6 @@ gordon serve | |||
| 200 | 198 | | `"if-tag-changed"` | Pull only if image tag differs from running container | | |
| 201 | 199 | | `"never"` | Never pull, use local image only | | |
| 202 | 200 | ||
| 203 | - | ## Auth Type Options | |
| 204 | - | ||
| 205 | - | | Value | Description | | |
| 206 | - | |-------|-------------| | |
| 207 | - | | `"password"` | HTTP Basic authentication with username/password | | |
| 208 | - | | `"token"` | JWT token authentication | | |
| 209 | - | ||
| 210 | 201 | ## Secrets Backend Options | |
| 211 | 202 | ||
| 212 | 203 | | Value | Description | | |
| @@ -19,12 +19,12 @@ data_dir = "~/.gordon" # Data directory (varies by install | |||||
| 19 | # ============================================================================= | 19 | # ============================================================================= | ||
| 20 | [auth] | 20 | [auth] | ||
| 21 | enabled = true # Enable registry authentication (default: true) | 21 | enabled = true # Enable registry authentication (default: true) | ||
| 22 | - | type = "password" # "password" or "token" | |||
| 23 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | 22 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | ||
| 24 | - | username = "" # Username for password auth | 23 | + | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED) |
| 25 | - | password_hash = "" # Path in secrets backend to bcrypt hash (REQUIRED for password type) | |||
| 26 | - | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type) | |||
| 27 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | 24 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | ||
| 25 | + | # Optional: enable password authentication (for interactive login) | |||
| 26 | + | # username = "" # Username for password auth | |||
| 27 | + | # password_hash = "" # Path in secrets backend to bcrypt hash | |||
| 28 | 28 | ||||
| 29 | # ============================================================================= | 29 | # ============================================================================= | ||
| 30 | # API (applies to both Registry and Admin endpoints) | 30 | # API (applies to both Registry and Admin endpoints) | ||
| @@ -126,7 +126,6 @@ preserve = true # Keep volumes when containers are | |||||
| 126 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 126 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 127 | | `server.data_dir` | `~/.gordon` | Data directory | | 127 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 128 | | `auth.enabled` | `true` | Enable authentication | | 128 | | `auth.enabled` | `true` | Enable authentication | | ||
| 129 | - | | `auth.type` | `"password"` | Auth type | | |||
| 130 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 129 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 131 | | `auth.token_expiry` | `"720h"` | 30 days | | 130 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
| 132 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | 131 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | ||
| @@ -166,7 +165,6 @@ Examples: | |||||
| 166 | GORDON_SERVER_PORT=8080 | 165 | GORDON_SERVER_PORT=8080 | ||
| 167 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | 166 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | ||
| 168 | GORDON_AUTH_ENABLED=true | 167 | GORDON_AUTH_ENABLED=true | ||
| 169 | - | GORDON_AUTH_TYPE=token | |||
| 170 | GORDON_LOGGING_LEVEL=debug | 168 | GORDON_LOGGING_LEVEL=debug | ||
| 171 | GORDON_NETWORK_ISOLATION_ENABLED=true | 169 | GORDON_NETWORK_ISOLATION_ENABLED=true | ||
| 172 | ``` | 170 | ``` | ||
| @@ -200,13 +198,6 @@ gordon serve | |||||
| 200 | | `"if-tag-changed"` | Pull only if image tag differs from running container | | 198 | | `"if-tag-changed"` | Pull only if image tag differs from running container | | ||
| 201 | | `"never"` | Never pull, use local image only | | 199 | | `"never"` | Never pull, use local image only | | ||
| 202 | 200 | ||||
| 203 | - | ## Auth Type Options | |||
| 204 | - | ||||
| 205 | - | | Value | Description | | |||
| 206 | - | |-------|-------------| | |||
| 207 | - | | `"password"` | HTTP Basic authentication with username/password | | |||
| 208 | - | | `"token"` | JWT token authentication | | |||
| 209 | - | ||||
| 210 | ## Secrets Backend Options | 201 | ## Secrets Backend Options | ||
| 211 | 202 | ||||
| 212 | | Value | Description | | 203 | | Value | Description | | ||
M docs/config/secrets.md +0 -2
| @@ -139,7 +139,6 @@ API_SECRET=${sops:production.yaml:api.secret.key} | |||
| 139 | 139 | ```toml | |
| 140 | 140 | [auth] | |
| 141 | 141 | enabled = true | |
| 142 | - | type = "token" | |
| 143 | 142 | secrets_backend = "pass" | |
| 144 | 143 | token_secret = "gordon/auth/token_secret" | |
| 145 | 144 | ``` | |
| @@ -166,7 +165,6 @@ secrets_backend = "unsafe" | |||
| 166 | 165 | ```toml | |
| 167 | 166 | [auth] | |
| 168 | 167 | enabled = true | |
| 169 | - | type = "token" | |
| 170 | 168 | secrets_backend = "sops" | |
| 171 | 169 | token_secret = "gordon/auth/token_secret" | |
| 172 | 170 | ``` | |
| @@ -139,7 +139,6 @@ API_SECRET=${sops:production.yaml:api.secret.key} | |||||
| 139 | ```toml | 139 | ```toml | ||
| 140 | [auth] | 140 | [auth] | ||
| 141 | enabled = true | 141 | enabled = true | ||
| 142 | - | type = "token" | |||
| 143 | secrets_backend = "pass" | 142 | secrets_backend = "pass" | ||
| 144 | token_secret = "gordon/auth/token_secret" | 143 | token_secret = "gordon/auth/token_secret" | ||
| 145 | ``` | 144 | ``` | ||
| @@ -166,7 +165,6 @@ secrets_backend = "unsafe" | |||||
| 166 | ```toml | 165 | ```toml | ||
| 167 | [auth] | 166 | [auth] | ||
| 168 | enabled = true | 167 | enabled = true | ||
| 169 | - | type = "token" | |||
| 170 | secrets_backend = "sops" | 168 | secrets_backend = "sops" | ||
| 171 | token_secret = "gordon/auth/token_secret" | 169 | token_secret = "gordon/auth/token_secret" | ||
| 172 | ``` | 170 | ``` | ||
M internal/adapters/in/cli/auth.go +118 -0
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | 13 | "github.com/spf13/viper" | |
| 14 | 14 | "golang.org/x/term" | |
| 15 | 15 | ||
| 16 | + | "gordon/internal/adapters/in/cli/remote" | |
| 17 | + | "gordon/internal/adapters/in/cli/ui/styles" | |
| 16 | 18 | "gordon/internal/adapters/out/secrets" | |
| 17 | 19 | "gordon/internal/adapters/out/tokenstore" | |
| 18 | 20 | "gordon/internal/app" | |
| @@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command { | |||
| 32 | 34 | cmd.AddCommand(newAuthTokenCmd()) | |
| 33 | 35 | cmd.AddCommand(newAuthPasswordCmd()) | |
| 34 | 36 | cmd.AddCommand(newAuthInternalCmd()) | |
| 37 | + | cmd.AddCommand(newAuthLoginCmd()) | |
| 35 | 38 | ||
| 36 | 39 | return cmd | |
| 37 | 40 | } | |
| @@ -197,6 +200,121 @@ available while Gordon is running.`, | |||
| 197 | 200 | } | |
| 198 | 201 | } | |
| 199 | 202 | ||
| 203 | + | // newAuthLoginCmd creates the login command for remote authentication. | |
| 204 | + | func newAuthLoginCmd() *cobra.Command { | |
| 205 | + | var ( | |
| 206 | + | remoteName string | |
| 207 | + | username string | |
| 208 | + | ) | |
| 209 | + | ||
| 210 | + | cmd := &cobra.Command{ | |
| 211 | + | Use: "login", | |
| 212 | + | Short: "Authenticate with a Gordon server", | |
| 213 | + | Long: `Authenticate with a Gordon server using password authentication. | |
| 214 | + | ||
| 215 | + | This command prompts for your username and password, authenticates with the | |
| 216 | + | remote server's /auth/password endpoint, and stores the returned token. | |
| 217 | + | ||
| 218 | + | The token is stored in your remote configuration and used for subsequent | |
| 219 | + | CLI operations. | |
| 220 | + | ||
| 221 | + | Note: This command only works with servers configured for password authentication. | |
| 222 | + | For servers using token-based auth, use 'gordon remote set-token' instead. | |
| 223 | + | ||
| 224 | + | Examples: | |
| 225 | + | gordon auth login Login using active remote | |
| 226 | + | gordon auth login --remote prod Login to specific remote | |
| 227 | + | gordon auth login --username admin Pre-fill username`, | |
| 228 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 229 | + | return runAuthLogin(remoteName, username) | |
| 230 | + | }, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |
| 234 | + | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | |
| 235 | + | ||
| 236 | + | return cmd | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // runAuthLogin authenticates with a remote Gordon server. | |
| 240 | + | func runAuthLogin(remoteName, username string) error { | |
| 241 | + | // Load remotes config | |
| 242 | + | config, err := remote.LoadRemotes("") | |
| 243 | + | if err != nil { | |
| 244 | + | return fmt.Errorf("failed to load remotes: %w", err) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Determine which remote to use | |
| 248 | + | if remoteName == "" { | |
| 249 | + | remoteName = config.Active | |
| 250 | + | } | |
| 251 | + | if remoteName == "" { | |
| 252 | + | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | remoteConfig, ok := config.Remotes[remoteName] | |
| 256 | + | if !ok { | |
| 257 | + | return fmt.Errorf("remote '%s' not found", remoteName) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // Prompt for username if not provided | |
| 261 | + | if username == "" { | |
| 262 | + | fmt.Print("Username: ") | |
| 263 | + | reader := bufio.NewReader(os.Stdin) | |
| 264 | + | input, err := reader.ReadString('\n') | |
| 265 | + | if err != nil { | |
| 266 | + | return fmt.Errorf("failed to read username: %w", err) | |
| 267 | + | } | |
| 268 | + | username = strings.TrimSpace(input) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | if username == "" { | |
| 272 | + | return fmt.Errorf("username cannot be empty") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Prompt for password (hidden input) | |
| 276 | + | fmt.Print("Password: ") | |
| 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 278 | + | if err != nil { | |
| 279 | + | // Fallback for non-terminal input | |
| 280 | + | reader := bufio.NewReader(os.Stdin) | |
| 281 | + | password, readErr := reader.ReadString('\n') | |
| 282 | + | if readErr != nil { | |
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |
| 284 | + | } | |
| 285 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 286 | + | } | |
| 287 | + | fmt.Println() // newline after hidden password input | |
| 288 | + | ||
| 289 | + | if len(passwordBytes) == 0 { | |
| 290 | + | return fmt.Errorf("password cannot be empty") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Create remote client | |
| 294 | + | client := remote.NewClient(remoteConfig.URL) | |
| 295 | + | ||
| 296 | + | // Authenticate | |
| 297 | + | ctx := context.Background() | |
| 298 | + | fmt.Printf("Authenticating with %s...\n", remoteName) | |
| 299 | + | ||
| 300 | + | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | |
| 301 | + | if err != nil { | |
| 302 | + | return fmt.Errorf("authentication failed: %w", err) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // Update remote config with new token | |
| 306 | + | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | |
| 307 | + | return fmt.Errorf("failed to save token: %w", err) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | fmt.Println() | |
| 311 | + | fmt.Println(styles.RenderSuccess("Authentication successful!")) | |
| 312 | + | fmt.Printf("Token stored for remote '%s'\n", remoteName) | |
| 313 | + | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |
| 314 | + | ||
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | ||
| 200 | 318 | // runShowInternalAuth displays the internal registry credentials. | |
| 201 | 319 | func runShowInternalAuth() error { | |
| 202 | 320 | creds, err := app.GetInternalCredentials() | |
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | "github.com/spf13/viper" | 13 | "github.com/spf13/viper" | ||
| 14 | "golang.org/x/term" | 14 | "golang.org/x/term" | ||
| 15 | 15 | ||||
| 16 | + | "gordon/internal/adapters/in/cli/remote" | |||
| 17 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 16 | "gordon/internal/adapters/out/secrets" | 18 | "gordon/internal/adapters/out/secrets" | ||
| 17 | "gordon/internal/adapters/out/tokenstore" | 19 | "gordon/internal/adapters/out/tokenstore" | ||
| 18 | "gordon/internal/app" | 20 | "gordon/internal/app" | ||
| @@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command { | |||||
| 32 | cmd.AddCommand(newAuthTokenCmd()) | 34 | cmd.AddCommand(newAuthTokenCmd()) | ||
| 33 | cmd.AddCommand(newAuthPasswordCmd()) | 35 | cmd.AddCommand(newAuthPasswordCmd()) | ||
| 34 | cmd.AddCommand(newAuthInternalCmd()) | 36 | cmd.AddCommand(newAuthInternalCmd()) | ||
| 37 | + | cmd.AddCommand(newAuthLoginCmd()) | |||
| 35 | 38 | ||||
| 36 | return cmd | 39 | return cmd | ||
| 37 | } | 40 | } | ||
| @@ -197,6 +200,121 @@ available while Gordon is running.`, | |||||
| 197 | } | 200 | } | ||
| 198 | } | 201 | } | ||
| 199 | 202 | ||||
| 203 | + | // newAuthLoginCmd creates the login command for remote authentication. | |||
| 204 | + | func newAuthLoginCmd() *cobra.Command { | |||
| 205 | + | var ( | |||
| 206 | + | remoteName string | |||
| 207 | + | username string | |||
| 208 | + | ) | |||
| 209 | + | ||||
| 210 | + | cmd := &cobra.Command{ | |||
| 211 | + | Use: "login", | |||
| 212 | + | Short: "Authenticate with a Gordon server", | |||
| 213 | + | Long: `Authenticate with a Gordon server using password authentication. | |||
| 214 | + | ||||
| 215 | + | This command prompts for your username and password, authenticates with the | |||
| 216 | + | remote server's /auth/password endpoint, and stores the returned token. | |||
| 217 | + | ||||
| 218 | + | The token is stored in your remote configuration and used for subsequent | |||
| 219 | + | CLI operations. | |||
| 220 | + | ||||
| 221 | + | Note: This command only works with servers configured for password authentication. | |||
| 222 | + | For servers using token-based auth, use 'gordon remote set-token' instead. | |||
| 223 | + | ||||
| 224 | + | Examples: | |||
| 225 | + | gordon auth login Login using active remote | |||
| 226 | + | gordon auth login --remote prod Login to specific remote | |||
| 227 | + | gordon auth login --username admin Pre-fill username`, | |||
| 228 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 229 | + | return runAuthLogin(remoteName, username) | |||
| 230 | + | }, | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |||
| 234 | + | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | |||
| 235 | + | ||||
| 236 | + | return cmd | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // runAuthLogin authenticates with a remote Gordon server. | |||
| 240 | + | func runAuthLogin(remoteName, username string) error { | |||
| 241 | + | // Load remotes config | |||
| 242 | + | config, err := remote.LoadRemotes("") | |||
| 243 | + | if err != nil { | |||
| 244 | + | return fmt.Errorf("failed to load remotes: %w", err) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Determine which remote to use | |||
| 248 | + | if remoteName == "" { | |||
| 249 | + | remoteName = config.Active | |||
| 250 | + | } | |||
| 251 | + | if remoteName == "" { | |||
| 252 | + | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | remoteConfig, ok := config.Remotes[remoteName] | |||
| 256 | + | if !ok { | |||
| 257 | + | return fmt.Errorf("remote '%s' not found", remoteName) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Prompt for username if not provided | |||
| 261 | + | if username == "" { | |||
| 262 | + | fmt.Print("Username: ") | |||
| 263 | + | reader := bufio.NewReader(os.Stdin) | |||
| 264 | + | input, err := reader.ReadString('\n') | |||
| 265 | + | if err != nil { | |||
| 266 | + | return fmt.Errorf("failed to read username: %w", err) | |||
| 267 | + | } | |||
| 268 | + | username = strings.TrimSpace(input) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | if username == "" { | |||
| 272 | + | return fmt.Errorf("username cannot be empty") | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Prompt for password (hidden input) | |||
| 276 | + | fmt.Print("Password: ") | |||
| 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |||
| 278 | + | if err != nil { | |||
| 279 | + | // Fallback for non-terminal input | |||
| 280 | + | reader := bufio.NewReader(os.Stdin) | |||
| 281 | + | password, readErr := reader.ReadString('\n') | |||
| 282 | + | if readErr != nil { | |||
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |||
| 284 | + | } | |||
| 285 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |||
| 286 | + | } | |||
| 287 | + | fmt.Println() // newline after hidden password input | |||
| 288 | + | ||||
| 289 | + | if len(passwordBytes) == 0 { | |||
| 290 | + | return fmt.Errorf("password cannot be empty") | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Create remote client | |||
| 294 | + | client := remote.NewClient(remoteConfig.URL) | |||
| 295 | + | ||||
| 296 | + | // Authenticate | |||
| 297 | + | ctx := context.Background() | |||
| 298 | + | fmt.Printf("Authenticating with %s...\n", remoteName) | |||
| 299 | + | ||||
| 300 | + | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return fmt.Errorf("authentication failed: %w", err) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // Update remote config with new token | |||
| 306 | + | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | |||
| 307 | + | return fmt.Errorf("failed to save token: %w", err) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | fmt.Println() | |||
| 311 | + | fmt.Println(styles.RenderSuccess("Authentication successful!")) | |||
| 312 | + | fmt.Printf("Token stored for remote '%s'\n", remoteName) | |||
| 313 | + | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |||
| 314 | + | ||||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 200 | // runShowInternalAuth displays the internal registry credentials. | 318 | // runShowInternalAuth displays the internal registry credentials. | ||
| 201 | func runShowInternalAuth() error { | 319 | func runShowInternalAuth() error { | ||
| 202 | creds, err := app.GetInternalCredentials() | 320 | creds, err := app.GetInternalCredentials() | ||
A internal/adapters/in/cli/remote/auth.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // PasswordRequest represents the request body for POST /auth/password. | |
| 13 | + | type PasswordRequest struct { | |
| 14 | + | Username string `json:"username"` | |
| 15 | + | Password string `json:"password"` | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // PasswordResponse represents the response from POST /auth/password. | |
| 19 | + | type PasswordResponse struct { | |
| 20 | + | Token string `json:"token"` | |
| 21 | + | ExpiresIn int `json:"expires_in"` | |
| 22 | + | IssuedAt string `json:"issued_at"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Authenticate calls POST /auth/password and returns a token. | |
| 26 | + | // This method does NOT require an existing token since it's used to obtain one. | |
| 27 | + | func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) { | |
| 28 | + | url := c.baseURL + "/auth/password" | |
| 29 | + | ||
| 30 | + | reqBody := PasswordRequest{ | |
| 31 | + | Username: username, | |
| 32 | + | Password: password, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | jsonBody, err := json.Marshal(reqBody) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) | |
| 41 | + | if err != nil { | |
| 42 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | req.Header.Set("Content-Type", "application/json") | |
| 46 | + | req.Header.Set("Accept", "application/json") | |
| 47 | + | ||
| 48 | + | resp, err := c.httpClient.Do(req) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, fmt.Errorf("failed to connect: %w", err) | |
| 51 | + | } | |
| 52 | + | defer resp.Body.Close() | |
| 53 | + | ||
| 54 | + | if resp.StatusCode >= 400 { | |
| 55 | + | body, _ := io.ReadAll(resp.Body) | |
| 56 | + | var errResp struct { | |
| 57 | + | Error string `json:"error"` | |
| 58 | + | } | |
| 59 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 60 | + | return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 61 | + | } | |
| 62 | + | return nil, fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | var result PasswordResponse | |
| 66 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 67 | + | return nil, fmt.Errorf("failed to decode response: %w", err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | return &result, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // UpdateRemoteToken updates the token for a named remote. | |
| 74 | + | func UpdateRemoteToken(name, token string) error { | |
| 75 | + | config, err := LoadRemotes("") | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | remote, ok := config.Remotes[name] | |
| 81 | + | if !ok { | |
| 82 | + | return fmt.Errorf("remote '%s' not found", name) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | remote.Token = token | |
| 86 | + | remote.TokenEnv = "" // Clear token_env when setting token directly | |
| 87 | + | config.Remotes[name] = remote | |
| 88 | + | ||
| 89 | + | return SaveRemotes("", config) | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // PasswordRequest represents the request body for POST /auth/password. | |||
| 13 | + | type PasswordRequest struct { | |||
| 14 | + | Username string `json:"username"` | |||
| 15 | + | Password string `json:"password"` | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // PasswordResponse represents the response from POST /auth/password. | |||
| 19 | + | type PasswordResponse struct { | |||
| 20 | + | Token string `json:"token"` | |||
| 21 | + | ExpiresIn int `json:"expires_in"` | |||
| 22 | + | IssuedAt string `json:"issued_at"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Authenticate calls POST /auth/password and returns a token. | |||
| 26 | + | // This method does NOT require an existing token since it's used to obtain one. | |||
| 27 | + | func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) { | |||
| 28 | + | url := c.baseURL + "/auth/password" | |||
| 29 | + | ||||
| 30 | + | reqBody := PasswordRequest{ | |||
| 31 | + | Username: username, | |||
| 32 | + | Password: password, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | jsonBody, err := json.Marshal(reqBody) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | req.Header.Set("Content-Type", "application/json") | |||
| 46 | + | req.Header.Set("Accept", "application/json") | |||
| 47 | + | ||||
| 48 | + | resp, err := c.httpClient.Do(req) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, fmt.Errorf("failed to connect: %w", err) | |||
| 51 | + | } | |||
| 52 | + | defer resp.Body.Close() | |||
| 53 | + | ||||
| 54 | + | if resp.StatusCode >= 400 { | |||
| 55 | + | body, _ := io.ReadAll(resp.Body) | |||
| 56 | + | var errResp struct { | |||
| 57 | + | Error string `json:"error"` | |||
| 58 | + | } | |||
| 59 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 60 | + | return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 61 | + | } | |||
| 62 | + | return nil, fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | var result PasswordResponse | |||
| 66 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |||
| 67 | + | return nil, fmt.Errorf("failed to decode response: %w", err) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | return &result, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // UpdateRemoteToken updates the token for a named remote. | |||
| 74 | + | func UpdateRemoteToken(name, token string) error { | |||
| 75 | + | config, err := LoadRemotes("") | |||
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | remote, ok := config.Remotes[name] | |||
| 81 | + | if !ok { | |||
| 82 | + | return fmt.Errorf("remote '%s' not found", name) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | remote.Token = token | |||
| 86 | + | remote.TokenEnv = "" // Clear token_env when setting token directly | |||
| 87 | + | config.Remotes[name] = remote | |||
| 88 | + | ||||
| 89 | + | return SaveRemotes("", config) | |||
| 90 | + | } | |||
M internal/adapters/in/cli/remotes.go +44 -0
| @@ -28,6 +28,7 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||
| 28 | 28 | cmd.AddCommand(newRemotesAddCmd()) | |
| 29 | 29 | cmd.AddCommand(newRemotesRemoveCmd()) | |
| 30 | 30 | cmd.AddCommand(newRemotesUseCmd()) | |
| 31 | + | cmd.AddCommand(newRemotesSetTokenCmd()) | |
| 31 | 32 | ||
| 32 | 33 | return cmd | |
| 33 | 34 | } | |
| @@ -246,6 +247,49 @@ Examples: | |||
| 246 | 247 | } | |
| 247 | 248 | } | |
| 248 | 249 | ||
| 250 | + | // newRemotesSetTokenCmd creates the remotes set-token command. | |
| 251 | + | func newRemotesSetTokenCmd() *cobra.Command { | |
| 252 | + | return &cobra.Command{ | |
| 253 | + | Use: "set-token <name> <token>", | |
| 254 | + | Short: "Set the token for a remote", | |
| 255 | + | Long: `Set or update the authentication token for a saved remote. | |
| 256 | + | ||
| 257 | + | This is useful when: | |
| 258 | + | - The server uses token-based authentication (not password auth) | |
| 259 | + | - You have a pre-generated token from 'gordon auth token generate' | |
| 260 | + | - You want to update an expired token | |
| 261 | + | ||
| 262 | + | For servers with password authentication, use 'gordon auth login' instead. | |
| 263 | + | ||
| 264 | + | Examples: | |
| 265 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |
| 266 | + | gordon remotes set-token staging $(cat token.txt)`, | |
| 267 | + | Args: cobra.ExactArgs(2), | |
| 268 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 269 | + | name := args[0] | |
| 270 | + | token := args[1] | |
| 271 | + | ||
| 272 | + | // Verify remote exists | |
| 273 | + | remotes, _, err := remote.ListRemotes() | |
| 274 | + | if err != nil { | |
| 275 | + | return fmt.Errorf("failed to list remotes: %w", err) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | if _, exists := remotes[name]; !exists { | |
| 279 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | if err := remote.UpdateRemoteToken(name, token); err != nil { | |
| 284 | + | return fmt.Errorf("failed to update token: %w", err) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |
| 288 | + | return nil | |
| 289 | + | }, | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 249 | 293 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | |
| 250 | 294 | func normalizeURL(url string) string { | |
| 251 | 295 | if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { | |
| @@ -28,6 +28,7 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||||
| 28 | cmd.AddCommand(newRemotesAddCmd()) | 28 | cmd.AddCommand(newRemotesAddCmd()) | ||
| 29 | cmd.AddCommand(newRemotesRemoveCmd()) | 29 | cmd.AddCommand(newRemotesRemoveCmd()) | ||
| 30 | cmd.AddCommand(newRemotesUseCmd()) | 30 | cmd.AddCommand(newRemotesUseCmd()) | ||
| 31 | + | cmd.AddCommand(newRemotesSetTokenCmd()) | |||
| 31 | 32 | ||||
| 32 | return cmd | 33 | return cmd | ||
| 33 | } | 34 | } | ||
| @@ -246,6 +247,49 @@ Examples: | |||||
| 246 | } | 247 | } | ||
| 247 | } | 248 | } | ||
| 248 | 249 | ||||
| 250 | + | // newRemotesSetTokenCmd creates the remotes set-token command. | |||
| 251 | + | func newRemotesSetTokenCmd() *cobra.Command { | |||
| 252 | + | return &cobra.Command{ | |||
| 253 | + | Use: "set-token <name> <token>", | |||
| 254 | + | Short: "Set the token for a remote", | |||
| 255 | + | Long: `Set or update the authentication token for a saved remote. | |||
| 256 | + | ||||
| 257 | + | This is useful when: | |||
| 258 | + | - The server uses token-based authentication (not password auth) | |||
| 259 | + | - You have a pre-generated token from 'gordon auth token generate' | |||
| 260 | + | - You want to update an expired token | |||
| 261 | + | ||||
| 262 | + | For servers with password authentication, use 'gordon auth login' instead. | |||
| 263 | + | ||||
| 264 | + | Examples: | |||
| 265 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |||
| 266 | + | gordon remotes set-token staging $(cat token.txt)`, | |||
| 267 | + | Args: cobra.ExactArgs(2), | |||
| 268 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 269 | + | name := args[0] | |||
| 270 | + | token := args[1] | |||
| 271 | + | ||||
| 272 | + | // Verify remote exists | |||
| 273 | + | remotes, _, err := remote.ListRemotes() | |||
| 274 | + | if err != nil { | |||
| 275 | + | return fmt.Errorf("failed to list remotes: %w", err) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | if _, exists := remotes[name]; !exists { | |||
| 279 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | if err := remote.UpdateRemoteToken(name, token); err != nil { | |||
| 284 | + | return fmt.Errorf("failed to update token: %w", err) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |||
| 288 | + | return nil | |||
| 289 | + | }, | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 249 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | 293 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | ||
| 250 | func normalizeURL(url string) string { | 294 | func normalizeURL(url string) string { | ||
| 251 | if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { | 295 | if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { | ||
M internal/adapters/in/http/admin/middleware.go +26 -5
| @@ -3,14 +3,16 @@ package admin | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "net" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | - | "golang.org/x/time/rate" | |
| 11 | 11 | ||
| 12 | 12 | "gordon/internal/adapters/dto" | |
| 13 | + | "gordon/internal/adapters/in/http/middleware" | |
| 13 | 14 | "gordon/internal/boundaries/in" | |
| 15 | + | "gordon/internal/boundaries/out" | |
| 14 | 16 | "gordon/internal/domain" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| @@ -25,14 +27,21 @@ const ( | |||
| 25 | 27 | ) | |
| 26 | 28 | ||
| 27 | 29 | // AuthMiddleware creates middleware that validates admin API authentication. | |
| 28 | - | // The limiter parameter is optional and can be nil to disable rate limiting. | |
| 29 | - | func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 30 | + | // Both globalLimiter and ipLimiter can be nil to disable rate limiting. | |
| 31 | + | // The trustedNets parameter is used for proper IP extraction behind reverse proxies. | |
| 32 | + | func AuthMiddleware( | |
| 33 | + | authSvc in.AuthService, | |
| 34 | + | globalLimiter out.RateLimiter, | |
| 35 | + | ipLimiter out.RateLimiter, | |
| 36 | + | trustedNets []*net.IPNet, | |
| 37 | + | log zerowrap.Logger, | |
| 38 | + | ) func(http.Handler) http.Handler { | |
| 30 | 39 | return func(next http.Handler) http.Handler { | |
| 31 | 40 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 32 | 41 | ctx := r.Context() | |
| 33 | 42 | ||
| 34 | - | // Rate limit check (before any auth processing) | |
| 35 | - | if limiter != nil && !limiter.Allow() { | |
| 43 | + | // Check global rate limit | |
| 44 | + | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { | |
| 36 | 45 | w.Header().Set("Content-Type", "application/json") | |
| 37 | 46 | w.Header().Set("Retry-After", "1") | |
| 38 | 47 | w.WriteHeader(http.StatusTooManyRequests) | |
| @@ -40,6 +49,18 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap. | |||
| 40 | 49 | return | |
| 41 | 50 | } | |
| 42 | 51 | ||
| 52 | + | // Check per-IP rate limit | |
| 53 | + | if ipLimiter != nil { | |
| 54 | + | ip := middleware.GetClientIP(r, trustedNets) | |
| 55 | + | if !ipLimiter.Allow(ctx, "ip:"+ip) { | |
| 56 | + | w.Header().Set("Content-Type", "application/json") | |
| 57 | + | w.Header().Set("Retry-After", "1") | |
| 58 | + | w.WriteHeader(http.StatusTooManyRequests) | |
| 59 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 43 | 64 | // Check if auth is enabled | |
| 44 | 65 | if !authSvc.IsEnabled() { | |
| 45 | 66 | // Auth disabled, log warning and allow all requests | |
| @@ -3,14 +3,16 @@ package admin | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "net" | |||
| 6 | "net/http" | 7 | "net/http" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 10 | - | "golang.org/x/time/rate" | |||
| 11 | 11 | ||||
| 12 | "gordon/internal/adapters/dto" | 12 | "gordon/internal/adapters/dto" | ||
| 13 | + | "gordon/internal/adapters/in/http/middleware" | |||
| 13 | "gordon/internal/boundaries/in" | 14 | "gordon/internal/boundaries/in" | ||
| 15 | + | "gordon/internal/boundaries/out" | |||
| 14 | "gordon/internal/domain" | 16 | "gordon/internal/domain" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| @@ -25,14 +27,21 @@ const ( | |||||
| 25 | ) | 27 | ) | ||
| 26 | 28 | ||||
| 27 | // AuthMiddleware creates middleware that validates admin API authentication. | 29 | // AuthMiddleware creates middleware that validates admin API authentication. | ||
| 28 | - | // The limiter parameter is optional and can be nil to disable rate limiting. | 30 | + | // Both globalLimiter and ipLimiter can be nil to disable rate limiting. |
| 29 | - | func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler { | 31 | + | // The trustedNets parameter is used for proper IP extraction behind reverse proxies. |
| 32 | + | func AuthMiddleware( | |||
| 33 | + | authSvc in.AuthService, | |||
| 34 | + | globalLimiter out.RateLimiter, | |||
| 35 | + | ipLimiter out.RateLimiter, | |||
| 36 | + | trustedNets []*net.IPNet, | |||
| 37 | + | log zerowrap.Logger, | |||
| 38 | + | ) func(http.Handler) http.Handler { | |||
| 30 | return func(next http.Handler) http.Handler { | 39 | return func(next http.Handler) http.Handler { | ||
| 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 40 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 32 | ctx := r.Context() | 41 | ctx := r.Context() | ||
| 33 | 42 | ||||
| 34 | - | // Rate limit check (before any auth processing) | 43 | + | // Check global rate limit |
| 35 | - | if limiter != nil && !limiter.Allow() { | 44 | + | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { |
| 36 | w.Header().Set("Content-Type", "application/json") | 45 | w.Header().Set("Content-Type", "application/json") | ||
| 37 | w.Header().Set("Retry-After", "1") | 46 | w.Header().Set("Retry-After", "1") | ||
| 38 | w.WriteHeader(http.StatusTooManyRequests) | 47 | w.WriteHeader(http.StatusTooManyRequests) | ||
| @@ -40,6 +49,18 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap. | |||||
| 40 | return | 49 | return | ||
| 41 | } | 50 | } | ||
| 42 | 51 | ||||
| 52 | + | // Check per-IP rate limit | |||
| 53 | + | if ipLimiter != nil { | |||
| 54 | + | ip := middleware.GetClientIP(r, trustedNets) | |||
| 55 | + | if !ipLimiter.Allow(ctx, "ip:"+ip) { | |||
| 56 | + | w.Header().Set("Content-Type", "application/json") | |||
| 57 | + | w.Header().Set("Retry-After", "1") | |||
| 58 | + | w.WriteHeader(http.StatusTooManyRequests) | |||
| 59 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 43 | // Check if auth is enabled | 64 | // Check if auth is enabled | ||
| 44 | if !authSvc.IsEnabled() { | 65 | if !authSvc.IsEnabled() { | ||
| 45 | // Auth disabled, log warning and allow all requests | 66 | // Auth disabled, log warning and allow all requests | ||
A internal/adapters/in/http/admin/middleware_test.go +362 -0
| @@ -0,0 +1,362 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | ||
| 13 | + | "gordon/internal/adapters/in/http/middleware" | |
| 14 | + | inmocks "gordon/internal/boundaries/in/mocks" | |
| 15 | + | outmocks "gordon/internal/boundaries/out/mocks" | |
| 16 | + | "gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func adminTestLogger() zerowrap.Logger { | |
| 20 | + | return zerowrap.Default() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) { | |
| 24 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 25 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 26 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 27 | + | ||
| 28 | + | // Global limiter returns false (rate limited) | |
| 29 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) | |
| 30 | + | ||
| 31 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 32 | + | w.WriteHeader(http.StatusOK) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 36 | + | wrappedHandler := mw(handler) | |
| 37 | + | ||
| 38 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 39 | + | rec := httptest.NewRecorder() | |
| 40 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 41 | + | ||
| 42 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 43 | + | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |
| 47 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 48 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 49 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 50 | + | ||
| 51 | + | // Global limiter allows, per-IP limiter denies | |
| 52 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 53 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false) | |
| 54 | + | ||
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | + | w.WriteHeader(http.StatusOK) | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 60 | + | wrappedHandler := mw(handler) | |
| 61 | + | ||
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 63 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 64 | + | rec := httptest.NewRecorder() | |
| 65 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 66 | + | ||
| 67 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 68 | + | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |
| 72 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 73 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 74 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 75 | + | ||
| 76 | + | // Request from trusted proxy, should use X-Forwarded-For IP | |
| 77 | + | trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"}) | |
| 78 | + | ||
| 79 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 80 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 81 | + | authSvc.EXPECT().IsEnabled().Return(false) | |
| 82 | + | ||
| 83 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 84 | + | w.WriteHeader(http.StatusOK) | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger()) | |
| 88 | + | wrappedHandler := mw(handler) | |
| 89 | + | ||
| 90 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 91 | + | req.RemoteAddr = "127.0.0.1:12345" | |
| 92 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |
| 93 | + | rec := httptest.NewRecorder() | |
| 94 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 95 | + | ||
| 96 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |
| 100 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 101 | + | ||
| 102 | + | // Auth disabled, no rate limiters | |
| 103 | + | authSvc.EXPECT().IsEnabled().Return(false) | |
| 104 | + | ||
| 105 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 106 | + | w.WriteHeader(http.StatusOK) | |
| 107 | + | }) | |
| 108 | + | ||
| 109 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |
| 110 | + | wrappedHandler := mw(handler) | |
| 111 | + | ||
| 112 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 113 | + | rec := httptest.NewRecorder() | |
| 114 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 115 | + | ||
| 116 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |
| 120 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 121 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 122 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 123 | + | ||
| 124 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 125 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 126 | + | authSvc.EXPECT().IsEnabled().Return(false) | |
| 127 | + | ||
| 128 | + | handlerCalled := false | |
| 129 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 130 | + | handlerCalled = true | |
| 131 | + | w.WriteHeader(http.StatusOK) | |
| 132 | + | }) | |
| 133 | + | ||
| 134 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 135 | + | wrappedHandler := mw(handler) | |
| 136 | + | ||
| 137 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 138 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 139 | + | rec := httptest.NewRecorder() | |
| 140 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 141 | + | ||
| 142 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 143 | + | assert.True(t, handlerCalled) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | |
| 147 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 148 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 149 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 150 | + | ||
| 151 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 152 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 153 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 154 | + | ||
| 155 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 156 | + | w.WriteHeader(http.StatusOK) | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 160 | + | wrappedHandler := mw(handler) | |
| 161 | + | ||
| 162 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 163 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 164 | + | rec := httptest.NewRecorder() | |
| 165 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 166 | + | ||
| 167 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 168 | + | assert.Contains(t, rec.Body.String(), "missing authorization header") | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestAuthMiddleware_InvalidToken(t *testing.T) { | |
| 172 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 173 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 174 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 175 | + | ||
| 176 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 177 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 178 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 179 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError) | |
| 180 | + | ||
| 181 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 182 | + | w.WriteHeader(http.StatusOK) | |
| 183 | + | }) | |
| 184 | + | ||
| 185 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 186 | + | wrappedHandler := mw(handler) | |
| 187 | + | ||
| 188 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 189 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 190 | + | req.Header.Set("Authorization", "Bearer invalid-token") | |
| 191 | + | rec := httptest.NewRecorder() | |
| 192 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 193 | + | ||
| 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 195 | + | assert.Contains(t, rec.Body.String(), "invalid token") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |
| 199 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 200 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 201 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 202 | + | ||
| 203 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 204 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 205 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 206 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{ | |
| 207 | + | Subject: "user1", | |
| 208 | + | Scopes: []string{"push", "pull"}, // No admin scopes | |
| 209 | + | }, nil) | |
| 210 | + | ||
| 211 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 212 | + | w.WriteHeader(http.StatusOK) | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 216 | + | wrappedHandler := mw(handler) | |
| 217 | + | ||
| 218 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 219 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 220 | + | req.Header.Set("Authorization", "Bearer valid-token") | |
| 221 | + | rec := httptest.NewRecorder() | |
| 222 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 223 | + | ||
| 224 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 225 | + | assert.Contains(t, rec.Body.String(), "admin scope required") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestAuthMiddleware_Success(t *testing.T) { | |
| 229 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 230 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 231 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 232 | + | ||
| 233 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 234 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 235 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 236 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 237 | + | Subject: "admin", | |
| 238 | + | Scopes: []string{"admin:*:*"}, | |
| 239 | + | }, nil) | |
| 240 | + | ||
| 241 | + | var capturedCtx context.Context | |
| 242 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 243 | + | capturedCtx = r.Context() | |
| 244 | + | w.WriteHeader(http.StatusOK) | |
| 245 | + | }) | |
| 246 | + | ||
| 247 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 248 | + | wrappedHandler := mw(handler) | |
| 249 | + | ||
| 250 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 251 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 252 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 253 | + | rec := httptest.NewRecorder() | |
| 254 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 255 | + | ||
| 256 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 257 | + | ||
| 258 | + | // Verify context has scopes and subject | |
| 259 | + | scopes := GetScopes(capturedCtx) | |
| 260 | + | assert.Equal(t, []string{"admin:*:*"}, scopes) | |
| 261 | + | assert.Equal(t, "admin", GetSubject(capturedCtx)) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestAuthMiddleware_DirectToken(t *testing.T) { | |
| 265 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 266 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 267 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 268 | + | ||
| 269 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 270 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 271 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 272 | + | // Token without "Bearer " prefix | |
| 273 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{ | |
| 274 | + | Subject: "admin", | |
| 275 | + | Scopes: []string{"admin:routes:read"}, | |
| 276 | + | }, nil) | |
| 277 | + | ||
| 278 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 279 | + | w.WriteHeader(http.StatusOK) | |
| 280 | + | }) | |
| 281 | + | ||
| 282 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |
| 283 | + | wrappedHandler := mw(handler) | |
| 284 | + | ||
| 285 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 286 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 287 | + | req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix | |
| 288 | + | rec := httptest.NewRecorder() | |
| 289 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 290 | + | ||
| 291 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestRequireScope(t *testing.T) { | |
| 295 | + | tests := []struct { | |
| 296 | + | name string | |
| 297 | + | scopes []string | |
| 298 | + | resource string | |
| 299 | + | action string | |
| 300 | + | wantStatus int | |
| 301 | + | }{ | |
| 302 | + | { | |
| 303 | + | name: "wildcard scope grants access", | |
| 304 | + | scopes: []string{"admin:*:*"}, | |
| 305 | + | resource: "routes", | |
| 306 | + | action: "write", | |
| 307 | + | wantStatus: http.StatusOK, | |
| 308 | + | }, | |
| 309 | + | { | |
| 310 | + | name: "exact scope grants access", | |
| 311 | + | scopes: []string{"admin:routes:read"}, | |
| 312 | + | resource: "routes", | |
| 313 | + | action: "read", | |
| 314 | + | wantStatus: http.StatusOK, | |
| 315 | + | }, | |
| 316 | + | { | |
| 317 | + | name: "wrong resource denies access", | |
| 318 | + | scopes: []string{"admin:secrets:read"}, | |
| 319 | + | resource: "routes", | |
| 320 | + | action: "read", | |
| 321 | + | wantStatus: http.StatusForbidden, | |
| 322 | + | }, | |
| 323 | + | { | |
| 324 | + | name: "wrong action denies access", | |
| 325 | + | scopes: []string{"admin:routes:read"}, | |
| 326 | + | resource: "routes", | |
| 327 | + | action: "write", | |
| 328 | + | wantStatus: http.StatusForbidden, | |
| 329 | + | }, | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | for _, tt := range tests { | |
| 333 | + | t.Run(tt.name, func(t *testing.T) { | |
| 334 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 335 | + | w.WriteHeader(http.StatusOK) | |
| 336 | + | }) | |
| 337 | + | ||
| 338 | + | mw := RequireScope(tt.resource, tt.action) | |
| 339 | + | wrappedHandler := mw(handler) | |
| 340 | + | ||
| 341 | + | req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil) | |
| 342 | + | // Add scopes to context | |
| 343 | + | ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes) | |
| 344 | + | req = req.WithContext(ctx) | |
| 345 | + | ||
| 346 | + | rec := httptest.NewRecorder() | |
| 347 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 348 | + | ||
| 349 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 350 | + | }) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestHasAccess(t *testing.T) { | |
| 355 | + | ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | |
| 356 | + | ||
| 357 | + | assert.True(t, HasAccess(ctx, "routes", "read")) | |
| 358 | + | assert.False(t, HasAccess(ctx, "routes", "write")) | |
| 359 | + | assert.True(t, HasAccess(ctx, "secrets", "read")) | |
| 360 | + | assert.True(t, HasAccess(ctx, "secrets", "write")) | |
| 361 | + | assert.False(t, HasAccess(ctx, "config", "read")) | |
| 362 | + | } | |
| @@ -0,0 +1,362 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | ||||
| 13 | + | "gordon/internal/adapters/in/http/middleware" | |||
| 14 | + | inmocks "gordon/internal/boundaries/in/mocks" | |||
| 15 | + | outmocks "gordon/internal/boundaries/out/mocks" | |||
| 16 | + | "gordon/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func adminTestLogger() zerowrap.Logger { | |||
| 20 | + | return zerowrap.Default() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) { | |||
| 24 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 25 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 26 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 27 | + | ||||
| 28 | + | // Global limiter returns false (rate limited) | |||
| 29 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) | |||
| 30 | + | ||||
| 31 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 32 | + | w.WriteHeader(http.StatusOK) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 36 | + | wrappedHandler := mw(handler) | |||
| 37 | + | ||||
| 38 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 39 | + | rec := httptest.NewRecorder() | |||
| 40 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 41 | + | ||||
| 42 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |||
| 43 | + | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |||
| 47 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 48 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 49 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 50 | + | ||||
| 51 | + | // Global limiter allows, per-IP limiter denies | |||
| 52 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 53 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false) | |||
| 54 | + | ||||
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | + | w.WriteHeader(http.StatusOK) | |||
| 57 | + | }) | |||
| 58 | + | ||||
| 59 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 60 | + | wrappedHandler := mw(handler) | |||
| 61 | + | ||||
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 63 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 64 | + | rec := httptest.NewRecorder() | |||
| 65 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 66 | + | ||||
| 67 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |||
| 68 | + | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 72 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 73 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 74 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 75 | + | ||||
| 76 | + | // Request from trusted proxy, should use X-Forwarded-For IP | |||
| 77 | + | trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"}) | |||
| 78 | + | ||||
| 79 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 80 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |||
| 81 | + | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 82 | + | ||||
| 83 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 84 | + | w.WriteHeader(http.StatusOK) | |||
| 85 | + | }) | |||
| 86 | + | ||||
| 87 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger()) | |||
| 88 | + | wrappedHandler := mw(handler) | |||
| 89 | + | ||||
| 90 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 91 | + | req.RemoteAddr = "127.0.0.1:12345" | |||
| 92 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |||
| 93 | + | rec := httptest.NewRecorder() | |||
| 94 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 95 | + | ||||
| 96 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 100 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 101 | + | ||||
| 102 | + | // Auth disabled, no rate limiters | |||
| 103 | + | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 104 | + | ||||
| 105 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 106 | + | w.WriteHeader(http.StatusOK) | |||
| 107 | + | }) | |||
| 108 | + | ||||
| 109 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |||
| 110 | + | wrappedHandler := mw(handler) | |||
| 111 | + | ||||
| 112 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 113 | + | rec := httptest.NewRecorder() | |||
| 114 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 115 | + | ||||
| 116 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |||
| 120 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 121 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 122 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 123 | + | ||||
| 124 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 125 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 126 | + | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 127 | + | ||||
| 128 | + | handlerCalled := false | |||
| 129 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 130 | + | handlerCalled = true | |||
| 131 | + | w.WriteHeader(http.StatusOK) | |||
| 132 | + | }) | |||
| 133 | + | ||||
| 134 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 135 | + | wrappedHandler := mw(handler) | |||
| 136 | + | ||||
| 137 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 138 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 139 | + | rec := httptest.NewRecorder() | |||
| 140 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 141 | + | ||||
| 142 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 143 | + | assert.True(t, handlerCalled) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | |||
| 147 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 148 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 149 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 150 | + | ||||
| 151 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 152 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 153 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 154 | + | ||||
| 155 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 156 | + | w.WriteHeader(http.StatusOK) | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 160 | + | wrappedHandler := mw(handler) | |||
| 161 | + | ||||
| 162 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 163 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 164 | + | rec := httptest.NewRecorder() | |||
| 165 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 166 | + | ||||
| 167 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 168 | + | assert.Contains(t, rec.Body.String(), "missing authorization header") | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestAuthMiddleware_InvalidToken(t *testing.T) { | |||
| 172 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 173 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 174 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 175 | + | ||||
| 176 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 177 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 178 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 179 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError) | |||
| 180 | + | ||||
| 181 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 182 | + | w.WriteHeader(http.StatusOK) | |||
| 183 | + | }) | |||
| 184 | + | ||||
| 185 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 186 | + | wrappedHandler := mw(handler) | |||
| 187 | + | ||||
| 188 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 189 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 190 | + | req.Header.Set("Authorization", "Bearer invalid-token") | |||
| 191 | + | rec := httptest.NewRecorder() | |||
| 192 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 193 | + | ||||
| 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 195 | + | assert.Contains(t, rec.Body.String(), "invalid token") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |||
| 199 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 200 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 201 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 202 | + | ||||
| 203 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 204 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 205 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 206 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{ | |||
| 207 | + | Subject: "user1", | |||
| 208 | + | Scopes: []string{"push", "pull"}, // No admin scopes | |||
| 209 | + | }, nil) | |||
| 210 | + | ||||
| 211 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 212 | + | w.WriteHeader(http.StatusOK) | |||
| 213 | + | }) | |||
| 214 | + | ||||
| 215 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 216 | + | wrappedHandler := mw(handler) | |||
| 217 | + | ||||
| 218 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 219 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 220 | + | req.Header.Set("Authorization", "Bearer valid-token") | |||
| 221 | + | rec := httptest.NewRecorder() | |||
| 222 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 223 | + | ||||
| 224 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 225 | + | assert.Contains(t, rec.Body.String(), "admin scope required") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestAuthMiddleware_Success(t *testing.T) { | |||
| 229 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 230 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 231 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 232 | + | ||||
| 233 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 234 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 235 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 236 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 237 | + | Subject: "admin", | |||
| 238 | + | Scopes: []string{"admin:*:*"}, | |||
| 239 | + | }, nil) | |||
| 240 | + | ||||
| 241 | + | var capturedCtx context.Context | |||
| 242 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 243 | + | capturedCtx = r.Context() | |||
| 244 | + | w.WriteHeader(http.StatusOK) | |||
| 245 | + | }) | |||
| 246 | + | ||||
| 247 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 248 | + | wrappedHandler := mw(handler) | |||
| 249 | + | ||||
| 250 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 251 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 252 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 253 | + | rec := httptest.NewRecorder() | |||
| 254 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 255 | + | ||||
| 256 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 257 | + | ||||
| 258 | + | // Verify context has scopes and subject | |||
| 259 | + | scopes := GetScopes(capturedCtx) | |||
| 260 | + | assert.Equal(t, []string{"admin:*:*"}, scopes) | |||
| 261 | + | assert.Equal(t, "admin", GetSubject(capturedCtx)) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestAuthMiddleware_DirectToken(t *testing.T) { | |||
| 265 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 266 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 267 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 268 | + | ||||
| 269 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 270 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 271 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 272 | + | // Token without "Bearer " prefix | |||
| 273 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{ | |||
| 274 | + | Subject: "admin", | |||
| 275 | + | Scopes: []string{"admin:routes:read"}, | |||
| 276 | + | }, nil) | |||
| 277 | + | ||||
| 278 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 279 | + | w.WriteHeader(http.StatusOK) | |||
| 280 | + | }) | |||
| 281 | + | ||||
| 282 | + | mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger()) | |||
| 283 | + | wrappedHandler := mw(handler) | |||
| 284 | + | ||||
| 285 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 286 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 287 | + | req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix | |||
| 288 | + | rec := httptest.NewRecorder() | |||
| 289 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 290 | + | ||||
| 291 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestRequireScope(t *testing.T) { | |||
| 295 | + | tests := []struct { | |||
| 296 | + | name string | |||
| 297 | + | scopes []string | |||
| 298 | + | resource string | |||
| 299 | + | action string | |||
| 300 | + | wantStatus int | |||
| 301 | + | }{ | |||
| 302 | + | { | |||
| 303 | + | name: "wildcard scope grants access", | |||
| 304 | + | scopes: []string{"admin:*:*"}, | |||
| 305 | + | resource: "routes", | |||
| 306 | + | action: "write", | |||
| 307 | + | wantStatus: http.StatusOK, | |||
| 308 | + | }, | |||
| 309 | + | { | |||
| 310 | + | name: "exact scope grants access", | |||
| 311 | + | scopes: []string{"admin:routes:read"}, | |||
| 312 | + | resource: "routes", | |||
| 313 | + | action: "read", | |||
| 314 | + | wantStatus: http.StatusOK, | |||
| 315 | + | }, | |||
| 316 | + | { | |||
| 317 | + | name: "wrong resource denies access", | |||
| 318 | + | scopes: []string{"admin:secrets:read"}, | |||
| 319 | + | resource: "routes", | |||
| 320 | + | action: "read", | |||
| 321 | + | wantStatus: http.StatusForbidden, | |||
| 322 | + | }, | |||
| 323 | + | { | |||
| 324 | + | name: "wrong action denies access", | |||
| 325 | + | scopes: []string{"admin:routes:read"}, | |||
| 326 | + | resource: "routes", | |||
| 327 | + | action: "write", | |||
| 328 | + | wantStatus: http.StatusForbidden, | |||
| 329 | + | }, | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | for _, tt := range tests { | |||
| 333 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 334 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 335 | + | w.WriteHeader(http.StatusOK) | |||
| 336 | + | }) | |||
| 337 | + | ||||
| 338 | + | mw := RequireScope(tt.resource, tt.action) | |||
| 339 | + | wrappedHandler := mw(handler) | |||
| 340 | + | ||||
| 341 | + | req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil) | |||
| 342 | + | // Add scopes to context | |||
| 343 | + | ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes) | |||
| 344 | + | req = req.WithContext(ctx) | |||
| 345 | + | ||||
| 346 | + | rec := httptest.NewRecorder() | |||
| 347 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 348 | + | ||||
| 349 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 350 | + | }) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestHasAccess(t *testing.T) { | |||
| 355 | + | ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | |||
| 356 | + | ||||
| 357 | + | assert.True(t, HasAccess(ctx, "routes", "read")) | |||
| 358 | + | assert.False(t, HasAccess(ctx, "routes", "write")) | |||
| 359 | + | assert.True(t, HasAccess(ctx, "secrets", "read")) | |||
| 360 | + | assert.True(t, HasAccess(ctx, "secrets", "write")) | |||
| 361 | + | assert.False(t, HasAccess(ctx, "config", "read")) | |||
| 362 | + | } | |||
R internal/adapters/in/http/registry/token.go → internal/adapters/in/http/auth/handler.go +144 -21 renamed, 51% similar
| @@ -1,4 +1,5 @@ | |||
| 1 | - | package registry | |
| 1 | + | // Package auth implements the HTTP adapter for authentication endpoints. | |
| 2 | + | package auth | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | 5 | "encoding/json" | |
| @@ -19,34 +20,157 @@ type InternalAuth struct { | |||
| 19 | 20 | Password string | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | - | // TokenHandler handles token server requests for Docker Registry authentication. | |
| 23 | - | type TokenHandler struct { | |
| 23 | + | // Handler handles authentication requests at /auth/*. | |
| 24 | + | type Handler struct { | |
| 24 | 25 | authSvc in.AuthService | |
| 25 | 26 | internalAuth InternalAuth | |
| 26 | 27 | log zerowrap.Logger | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | - | // NewTokenHandler creates a new token handler. | |
| 30 | - | func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler { | |
| 31 | - | return &TokenHandler{ | |
| 30 | + | // NewHandler creates a new auth handler. | |
| 31 | + | func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler { | |
| 32 | + | return &Handler{ | |
| 32 | 33 | authSvc: authSvc, | |
| 33 | 34 | internalAuth: internalAuth, | |
| 34 | 35 | log: log, | |
| 35 | 36 | } | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | - | // ServeHTTP implements http.Handler for the token endpoint. | |
| 39 | - | func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 40 | - | h.handleToken(w, r) | |
| 39 | + | // PasswordRequest represents the request body for POST /auth/password. | |
| 40 | + | type PasswordRequest struct { | |
| 41 | + | Username string `json:"username"` | |
| 42 | + | Password string `json:"password"` | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | - | // handleToken handles GET /v2/token requests. | |
| 44 | - | // This endpoint issues short-lived access tokens per the Docker Registry auth spec. | |
| 45 | - | func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |
| 45 | + | // PasswordResponse represents the response from POST /auth/password. | |
| 46 | + | type PasswordResponse struct { | |
| 47 | + | Token string `json:"token"` | |
| 48 | + | ExpiresIn int `json:"expires_in"` | |
| 49 | + | IssuedAt string `json:"issued_at"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // ServeHTTP routes requests to the appropriate handler. | |
| 53 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 54 | + | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | |
| 55 | + | ||
| 56 | + | switch path { | |
| 57 | + | case "/password": | |
| 58 | + | h.handlePassword(w, r) | |
| 59 | + | case "/token": | |
| 60 | + | h.handleToken(w, r) | |
| 61 | + | default: | |
| 62 | + | w.Header().Set("Content-Type", "application/json") | |
| 63 | + | w.WriteHeader(http.StatusNotFound) | |
| 64 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"}) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // handlePassword handles POST /auth/password requests. | |
| 69 | + | // Validates username/password and returns a long-lived JWT (7 days). | |
| 70 | + | // Only works when auth type is "password" - returns error for "token" auth type. | |
| 71 | + | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |
| 72 | + | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 73 | + | zerowrap.FieldLayer: "adapter", | |
| 74 | + | zerowrap.FieldAdapter: "http", | |
| 75 | + | zerowrap.FieldHandler: "auth", | |
| 76 | + | zerowrap.FieldMethod: r.Method, | |
| 77 | + | zerowrap.FieldPath: r.URL.Path, | |
| 78 | + | }) | |
| 79 | + | log := zerowrap.FromCtx(ctx) | |
| 80 | + | ||
| 81 | + | if r.Method != http.MethodPost { | |
| 82 | + | w.Header().Set("Content-Type", "application/json") | |
| 83 | + | w.WriteHeader(http.StatusMethodNotAllowed) | |
| 84 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Check if auth is enabled | |
| 89 | + | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 90 | + | w.Header().Set("Content-Type", "application/json") | |
| 91 | + | w.WriteHeader(http.StatusBadRequest) | |
| 92 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Password endpoint only works with password auth type | |
| 97 | + | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |
| 98 | + | w.Header().Set("Content-Type", "application/json") | |
| 99 | + | w.WriteHeader(http.StatusBadRequest) | |
| 100 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 101 | + | Error: "password authentication not configured, use token-based auth", | |
| 102 | + | }) | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Parse request body | |
| 107 | + | var req PasswordRequest | |
| 108 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 109 | + | w.Header().Set("Content-Type", "application/json") | |
| 110 | + | w.WriteHeader(http.StatusBadRequest) | |
| 111 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | if req.Username == "" || req.Password == "" { | |
| 116 | + | w.Header().Set("Content-Type", "application/json") | |
| 117 | + | w.WriteHeader(http.StatusBadRequest) | |
| 118 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Validate password | |
| 123 | + | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |
| 124 | + | log.Debug(). | |
| 125 | + | Str("username", req.Username). | |
| 126 | + | Msg("password authentication failed") | |
| 127 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |
| 128 | + | w.Header().Set("Content-Type", "application/json") | |
| 129 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 130 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Generate 7-day token with full access (push, pull, admin) | |
| 135 | + | expiry := 7 * 24 * time.Hour | |
| 136 | + | scopes := []string{"push", "pull", "admin:*:*"} | |
| 137 | + | ||
| 138 | + | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |
| 139 | + | if err != nil { | |
| 140 | + | log.Error().Err(err).Msg("failed to generate token") | |
| 141 | + | w.Header().Set("Content-Type", "application/json") | |
| 142 | + | w.WriteHeader(http.StatusInternalServerError) | |
| 143 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | response := PasswordResponse{ | |
| 148 | + | Token: token, | |
| 149 | + | ExpiresIn: int(expiry.Seconds()), | |
| 150 | + | IssuedAt: time.Now().Format(time.RFC3339), | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | w.Header().Set("Content-Type", "application/json") | |
| 154 | + | w.WriteHeader(http.StatusOK) | |
| 155 | + | ||
| 156 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 157 | + | log.Error().Err(err).Msg("failed to encode password response") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | log.Debug(). | |
| 161 | + | Str("username", req.Username). | |
| 162 | + | Int("expires_in", response.ExpiresIn). | |
| 163 | + | Msg("long-lived token issued via password auth") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // handleToken handles GET /auth/token requests. | |
| 167 | + | // This is the Docker Registry v2 token server endpoint. | |
| 168 | + | // Issues short-lived access tokens for registry access. | |
| 169 | + | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |
| 46 | 170 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 47 | 171 | zerowrap.FieldLayer: "adapter", | |
| 48 | 172 | zerowrap.FieldAdapter: "http", | |
| 49 | - | zerowrap.FieldHandler: "token", | |
| 173 | + | zerowrap.FieldHandler: "auth", | |
| 50 | 174 | zerowrap.FieldMethod: r.Method, | |
| 51 | 175 | zerowrap.FieldPath: r.URL.Path, | |
| 52 | 176 | }) | |
| @@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 55 | 179 | if r.Method != http.MethodGet { | |
| 56 | 180 | w.Header().Set("Content-Type", "application/json") | |
| 57 | 181 | w.WriteHeader(http.StatusMethodNotAllowed) | |
| 58 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"}) | |
| 182 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |
| 59 | 183 | return | |
| 60 | 184 | } | |
| 61 | 185 | ||
| @@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 100 | 224 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 101 | 225 | w.Header().Set("Content-Type", "application/json") | |
| 102 | 226 | w.WriteHeader(http.StatusUnauthorized) | |
| 103 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |
| 227 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |
| 104 | 228 | return | |
| 105 | 229 | } | |
| 106 | 230 | ||
| 107 | 231 | // Parse requested scopes from query parameter | |
| 108 | - | // Format: scope=repository:name:action1,action2 (can appear multiple times) | |
| 109 | 232 | requestedScopes := h.parseRequestedScopes(r, log) | |
| 110 | 233 | ||
| 111 | 234 | // Generate a short-lived access token (5 minutes) - not stored | |
| @@ -114,7 +237,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 114 | 237 | log.Error().Err(err).Msg("failed to generate access token") | |
| 115 | 238 | w.Header().Set("Content-Type", "application/json") | |
| 116 | 239 | w.WriteHeader(http.StatusInternalServerError) | |
| 117 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"}) | |
| 240 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"}) | |
| 118 | 241 | return | |
| 119 | 242 | } | |
| 120 | 243 | ||
| @@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 138 | 261 | } | |
| 139 | 262 | ||
| 140 | 263 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | |
| 141 | - | func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |
| 264 | + | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |
| 142 | 265 | // For anonymous access, we issue a very short-lived token with limited scope | |
| 143 | 266 | response := dto.TokenResponse{ | |
| 144 | 267 | Token: "", // Empty token indicates limited access | |
| @@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool { | |||
| 168 | 291 | ||
| 169 | 292 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 170 | 293 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | |
| 171 | - | // Example: GET /v2/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | |
| 172 | - | func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string { | |
| 294 | + | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | |
| 295 | + | func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string { | |
| 173 | 296 | scopeParams := r.URL.Query()["scope"] | |
| 174 | 297 | ||
| 175 | 298 | if len(scopeParams) == 0 { | |
| @@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger | |||
| 215 | 338 | } | |
| 216 | 339 | ||
| 217 | 340 | // isInternalAuth checks if the credentials match internal registry auth. | |
| 218 | - | func (h *TokenHandler) isInternalAuth(username, password string) bool { | |
| 341 | + | func (h *Handler) isInternalAuth(username, password string) bool { | |
| 219 | 342 | if h.internalAuth.Username == "" || h.internalAuth.Password == "" { | |
| 220 | 343 | return false | |
| 221 | 344 | } | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | package registry | 1 | + | // Package auth implements the HTTP adapter for authentication endpoints. |
| 2 | + | package auth | |||
| 2 | 3 | ||||
| 3 | import ( | 4 | import ( | ||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| @@ -19,34 +20,157 @@ type InternalAuth struct { | |||||
| 19 | Password string | 20 | Password string | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | - | // TokenHandler handles token server requests for Docker Registry authentication. | 23 | + | // Handler handles authentication requests at /auth/*. |
| 23 | - | type TokenHandler struct { | 24 | + | type Handler struct { |
| 24 | authSvc in.AuthService | 25 | authSvc in.AuthService | ||
| 25 | internalAuth InternalAuth | 26 | internalAuth InternalAuth | ||
| 26 | log zerowrap.Logger | 27 | log zerowrap.Logger | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | - | // NewTokenHandler creates a new token handler. | 30 | + | // NewHandler creates a new auth handler. |
| 30 | - | func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler { | 31 | + | func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler { |
| 31 | - | return &TokenHandler{ | 32 | + | return &Handler{ |
| 32 | authSvc: authSvc, | 33 | authSvc: authSvc, | ||
| 33 | internalAuth: internalAuth, | 34 | internalAuth: internalAuth, | ||
| 34 | log: log, | 35 | log: log, | ||
| 35 | } | 36 | } | ||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 38 | - | // ServeHTTP implements http.Handler for the token endpoint. | 39 | + | // PasswordRequest represents the request body for POST /auth/password. |
| 39 | - | func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 40 | + | type PasswordRequest struct { |
| 40 | - | h.handleToken(w, r) | 41 | + | Username string `json:"username"` |
| 42 | + | Password string `json:"password"` | |||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 43 | - | // handleToken handles GET /v2/token requests. | 45 | + | // PasswordResponse represents the response from POST /auth/password. |
| 44 | - | // This endpoint issues short-lived access tokens per the Docker Registry auth spec. | 46 | + | type PasswordResponse struct { |
| 45 | - | func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | 47 | + | Token string `json:"token"` |
| 48 | + | ExpiresIn int `json:"expires_in"` | |||
| 49 | + | IssuedAt string `json:"issued_at"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // ServeHTTP routes requests to the appropriate handler. | |||
| 53 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 54 | + | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | |||
| 55 | + | ||||
| 56 | + | switch path { | |||
| 57 | + | case "/password": | |||
| 58 | + | h.handlePassword(w, r) | |||
| 59 | + | case "/token": | |||
| 60 | + | h.handleToken(w, r) | |||
| 61 | + | default: | |||
| 62 | + | w.Header().Set("Content-Type", "application/json") | |||
| 63 | + | w.WriteHeader(http.StatusNotFound) | |||
| 64 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"}) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // handlePassword handles POST /auth/password requests. | |||
| 69 | + | // Validates username/password and returns a long-lived JWT (7 days). | |||
| 70 | + | // Only works when auth type is "password" - returns error for "token" auth type. | |||
| 71 | + | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 72 | + | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |||
| 73 | + | zerowrap.FieldLayer: "adapter", | |||
| 74 | + | zerowrap.FieldAdapter: "http", | |||
| 75 | + | zerowrap.FieldHandler: "auth", | |||
| 76 | + | zerowrap.FieldMethod: r.Method, | |||
| 77 | + | zerowrap.FieldPath: r.URL.Path, | |||
| 78 | + | }) | |||
| 79 | + | log := zerowrap.FromCtx(ctx) | |||
| 80 | + | ||||
| 81 | + | if r.Method != http.MethodPost { | |||
| 82 | + | w.Header().Set("Content-Type", "application/json") | |||
| 83 | + | w.WriteHeader(http.StatusMethodNotAllowed) | |||
| 84 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Check if auth is enabled | |||
| 89 | + | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |||
| 90 | + | w.Header().Set("Content-Type", "application/json") | |||
| 91 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 92 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // Password endpoint only works with password auth type | |||
| 97 | + | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |||
| 98 | + | w.Header().Set("Content-Type", "application/json") | |||
| 99 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 100 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |||
| 101 | + | Error: "password authentication not configured, use token-based auth", | |||
| 102 | + | }) | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Parse request body | |||
| 107 | + | var req PasswordRequest | |||
| 108 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 109 | + | w.Header().Set("Content-Type", "application/json") | |||
| 110 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 111 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | if req.Username == "" || req.Password == "" { | |||
| 116 | + | w.Header().Set("Content-Type", "application/json") | |||
| 117 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 118 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Validate password | |||
| 123 | + | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |||
| 124 | + | log.Debug(). | |||
| 125 | + | Str("username", req.Username). | |||
| 126 | + | Msg("password authentication failed") | |||
| 127 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |||
| 128 | + | w.Header().Set("Content-Type", "application/json") | |||
| 129 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 130 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Generate 7-day token with full access (push, pull, admin) | |||
| 135 | + | expiry := 7 * 24 * time.Hour | |||
| 136 | + | scopes := []string{"push", "pull", "admin:*:*"} | |||
| 137 | + | ||||
| 138 | + | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |||
| 139 | + | if err != nil { | |||
| 140 | + | log.Error().Err(err).Msg("failed to generate token") | |||
| 141 | + | w.Header().Set("Content-Type", "application/json") | |||
| 142 | + | w.WriteHeader(http.StatusInternalServerError) | |||
| 143 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | response := PasswordResponse{ | |||
| 148 | + | Token: token, | |||
| 149 | + | ExpiresIn: int(expiry.Seconds()), | |||
| 150 | + | IssuedAt: time.Now().Format(time.RFC3339), | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | w.Header().Set("Content-Type", "application/json") | |||
| 154 | + | w.WriteHeader(http.StatusOK) | |||
| 155 | + | ||||
| 156 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |||
| 157 | + | log.Error().Err(err).Msg("failed to encode password response") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | log.Debug(). | |||
| 161 | + | Str("username", req.Username). | |||
| 162 | + | Int("expires_in", response.ExpiresIn). | |||
| 163 | + | Msg("long-lived token issued via password auth") | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // handleToken handles GET /auth/token requests. | |||
| 167 | + | // This is the Docker Registry v2 token server endpoint. | |||
| 168 | + | // Issues short-lived access tokens for registry access. | |||
| 169 | + | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 46 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | 170 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | ||
| 47 | zerowrap.FieldLayer: "adapter", | 171 | zerowrap.FieldLayer: "adapter", | ||
| 48 | zerowrap.FieldAdapter: "http", | 172 | zerowrap.FieldAdapter: "http", | ||
| 49 | - | zerowrap.FieldHandler: "token", | 173 | + | zerowrap.FieldHandler: "auth", |
| 50 | zerowrap.FieldMethod: r.Method, | 174 | zerowrap.FieldMethod: r.Method, | ||
| 51 | zerowrap.FieldPath: r.URL.Path, | 175 | zerowrap.FieldPath: r.URL.Path, | ||
| 52 | }) | 176 | }) | ||
| @@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 55 | if r.Method != http.MethodGet { | 179 | if r.Method != http.MethodGet { | ||
| 56 | w.Header().Set("Content-Type", "application/json") | 180 | w.Header().Set("Content-Type", "application/json") | ||
| 57 | w.WriteHeader(http.StatusMethodNotAllowed) | 181 | w.WriteHeader(http.StatusMethodNotAllowed) | ||
| 58 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"}) | 182 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) |
| 59 | return | 183 | return | ||
| 60 | } | 184 | } | ||
| 61 | 185 | ||||
| @@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 100 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | 224 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | ||
| 101 | w.Header().Set("Content-Type", "application/json") | 225 | w.Header().Set("Content-Type", "application/json") | ||
| 102 | w.WriteHeader(http.StatusUnauthorized) | 226 | w.WriteHeader(http.StatusUnauthorized) | ||
| 103 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | 227 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) |
| 104 | return | 228 | return | ||
| 105 | } | 229 | } | ||
| 106 | 230 | ||||
| 107 | // Parse requested scopes from query parameter | 231 | // Parse requested scopes from query parameter | ||
| 108 | - | // Format: scope=repository:name:action1,action2 (can appear multiple times) | |||
| 109 | requestedScopes := h.parseRequestedScopes(r, log) | 232 | requestedScopes := h.parseRequestedScopes(r, log) | ||
| 110 | 233 | ||||
| 111 | // Generate a short-lived access token (5 minutes) - not stored | 234 | // Generate a short-lived access token (5 minutes) - not stored | ||
| @@ -114,7 +237,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 114 | log.Error().Err(err).Msg("failed to generate access token") | 237 | log.Error().Err(err).Msg("failed to generate access token") | ||
| 115 | w.Header().Set("Content-Type", "application/json") | 238 | w.Header().Set("Content-Type", "application/json") | ||
| 116 | w.WriteHeader(http.StatusInternalServerError) | 239 | w.WriteHeader(http.StatusInternalServerError) | ||
| 117 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"}) | 240 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"}) |
| 118 | return | 241 | return | ||
| 119 | } | 242 | } | ||
| 120 | 243 | ||||
| @@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 138 | } | 261 | } | ||
| 139 | 262 | ||||
| 140 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | 263 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | ||
| 141 | - | func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | 264 | + | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { |
| 142 | // For anonymous access, we issue a very short-lived token with limited scope | 265 | // For anonymous access, we issue a very short-lived token with limited scope | ||
| 143 | response := dto.TokenResponse{ | 266 | response := dto.TokenResponse{ | ||
| 144 | Token: "", // Empty token indicates limited access | 267 | Token: "", // Empty token indicates limited access | ||
| @@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool { | |||||
| 168 | 291 | ||||
| 169 | // parseRequestedScopes extracts and validates scope parameters from the request. | 292 | // parseRequestedScopes extracts and validates scope parameters from the request. | ||
| 170 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | 293 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | ||
| 171 | - | // Example: GET /v2/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | 294 | + | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull |
| 172 | - | func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string { | 295 | + | func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string { |
| 173 | scopeParams := r.URL.Query()["scope"] | 296 | scopeParams := r.URL.Query()["scope"] | ||
| 174 | 297 | ||||
| 175 | if len(scopeParams) == 0 { | 298 | if len(scopeParams) == 0 { | ||
| @@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger | |||||
| 215 | } | 338 | } | ||
| 216 | 339 | ||||
| 217 | // isInternalAuth checks if the credentials match internal registry auth. | 340 | // isInternalAuth checks if the credentials match internal registry auth. | ||
| 218 | - | func (h *TokenHandler) isInternalAuth(username, password string) bool { | 341 | + | func (h *Handler) isInternalAuth(username, password string) bool { |
| 219 | if h.internalAuth.Username == "" || h.internalAuth.Password == "" { | 342 | if h.internalAuth.Username == "" || h.internalAuth.Password == "" { | ||
| 220 | return false | 343 | return false | ||
| 221 | } | 344 | } | ||
A internal/adapters/in/http/auth/handler_test.go +415 -0
| @@ -0,0 +1,415 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | ||
| 15 | + | "gordon/internal/boundaries/in/mocks" | |
| 16 | + | "gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func testLogger() zerowrap.Logger { | |
| 20 | + | return zerowrap.Default() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestHandler_Password_Success(t *testing.T) { | |
| 24 | + | authSvc := mocks.NewMockAuthService(t) | |
| 25 | + | ||
| 26 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 27 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 28 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | |
| 30 | + | Return("jwt-token-here", nil) | |
| 31 | + | ||
| 32 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 33 | + | ||
| 34 | + | body := `{"username":"admin","password":"secret"}` | |
| 35 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 36 | + | req.Header.Set("Content-Type", "application/json") | |
| 37 | + | rec := httptest.NewRecorder() | |
| 38 | + | ||
| 39 | + | handler.ServeHTTP(rec, req) | |
| 40 | + | ||
| 41 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 42 | + | ||
| 43 | + | var resp PasswordResponse | |
| 44 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 45 | + | assert.NoError(t, err) | |
| 46 | + | assert.Equal(t, "jwt-token-here", resp.Token) | |
| 47 | + | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | |
| 48 | + | assert.NotEmpty(t, resp.IssuedAt) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |
| 52 | + | authSvc := mocks.NewMockAuthService(t) | |
| 53 | + | ||
| 54 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 55 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 56 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 57 | + | ||
| 58 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 59 | + | ||
| 60 | + | body := `{"username":"admin","password":"wrongpassword"}` | |
| 61 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 62 | + | req.Header.Set("Content-Type", "application/json") | |
| 63 | + | rec := httptest.NewRecorder() | |
| 64 | + | ||
| 65 | + | handler.ServeHTTP(rec, req) | |
| 66 | + | ||
| 67 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 68 | + | assert.Contains(t, rec.Body.String(), "invalid credentials") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |
| 72 | + | authSvc := mocks.NewMockAuthService(t) | |
| 73 | + | ||
| 74 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 75 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 76 | + | ||
| 77 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 78 | + | ||
| 79 | + | body := `{"username":"admin","password":"secret"}` | |
| 80 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 81 | + | req.Header.Set("Content-Type", "application/json") | |
| 82 | + | rec := httptest.NewRecorder() | |
| 83 | + | ||
| 84 | + | handler.ServeHTTP(rec, req) | |
| 85 | + | ||
| 86 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 87 | + | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestHandler_Password_AuthDisabled(t *testing.T) { | |
| 91 | + | authSvc := mocks.NewMockAuthService(t) | |
| 92 | + | ||
| 93 | + | authSvc.EXPECT().IsEnabled().Return(false) | |
| 94 | + | ||
| 95 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 96 | + | ||
| 97 | + | body := `{"username":"admin","password":"secret"}` | |
| 98 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 99 | + | req.Header.Set("Content-Type", "application/json") | |
| 100 | + | rec := httptest.NewRecorder() | |
| 101 | + | ||
| 102 | + | handler.ServeHTTP(rec, req) | |
| 103 | + | ||
| 104 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 105 | + | assert.Contains(t, rec.Body.String(), "authentication is disabled") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestHandler_Password_MissingFields(t *testing.T) { | |
| 109 | + | authSvc := mocks.NewMockAuthService(t) | |
| 110 | + | ||
| 111 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 112 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 113 | + | ||
| 114 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 115 | + | ||
| 116 | + | tests := []struct { | |
| 117 | + | name string | |
| 118 | + | body string | |
| 119 | + | }{ | |
| 120 | + | {"missing username", `{"password":"secret"}`}, | |
| 121 | + | {"missing password", `{"username":"admin"}`}, | |
| 122 | + | {"empty username", `{"username":"","password":"secret"}`}, | |
| 123 | + | {"empty password", `{"username":"admin","password":""}`}, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | for _, tt := range tests { | |
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |
| 128 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |
| 129 | + | req.Header.Set("Content-Type", "application/json") | |
| 130 | + | rec := httptest.NewRecorder() | |
| 131 | + | ||
| 132 | + | handler.ServeHTTP(rec, req) | |
| 133 | + | ||
| 134 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |
| 140 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 141 | + | ||
| 142 | + | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |
| 143 | + | ||
| 144 | + | for _, method := range methods { | |
| 145 | + | t.Run(method, func(t *testing.T) { | |
| 146 | + | req := httptest.NewRequest(method, "/auth/password", nil) | |
| 147 | + | rec := httptest.NewRecorder() | |
| 148 | + | ||
| 149 | + | handler.ServeHTTP(rec, req) | |
| 150 | + | ||
| 151 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestHandler_Token_Success(t *testing.T) { | |
| 157 | + | authSvc := mocks.NewMockAuthService(t) | |
| 158 | + | ||
| 159 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 160 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 161 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 162 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 163 | + | Return("access-token", nil) | |
| 164 | + | ||
| 165 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 166 | + | ||
| 167 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |
| 168 | + | req.SetBasicAuth("admin", "secret") | |
| 169 | + | rec := httptest.NewRecorder() | |
| 170 | + | ||
| 171 | + | handler.ServeHTTP(rec, req) | |
| 172 | + | ||
| 173 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 174 | + | ||
| 175 | + | var resp map[string]any | |
| 176 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 177 | + | assert.NoError(t, err) | |
| 178 | + | assert.Equal(t, "access-token", resp["token"]) | |
| 179 | + | assert.Equal(t, float64(300), resp["expires_in"]) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHandler_Token_Anonymous(t *testing.T) { | |
| 183 | + | authSvc := mocks.NewMockAuthService(t) | |
| 184 | + | ||
| 185 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 186 | + | ||
| 187 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 188 | + | ||
| 189 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 190 | + | rec := httptest.NewRecorder() | |
| 191 | + | ||
| 192 | + | handler.ServeHTTP(rec, req) | |
| 193 | + | ||
| 194 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 195 | + | ||
| 196 | + | var resp map[string]any | |
| 197 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 198 | + | assert.NoError(t, err) | |
| 199 | + | assert.Equal(t, "", resp["token"]) | |
| 200 | + | assert.Equal(t, float64(60), resp["expires_in"]) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestHandler_Token_AuthDisabled(t *testing.T) { | |
| 204 | + | authSvc := mocks.NewMockAuthService(t) | |
| 205 | + | ||
| 206 | + | authSvc.EXPECT().IsEnabled().Return(false) | |
| 207 | + | ||
| 208 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 209 | + | ||
| 210 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 211 | + | rec := httptest.NewRecorder() | |
| 212 | + | ||
| 213 | + | handler.ServeHTTP(rec, req) | |
| 214 | + | ||
| 215 | + | // Should return anonymous token when auth is disabled | |
| 216 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 217 | + | ||
| 218 | + | var resp map[string]any | |
| 219 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 220 | + | assert.NoError(t, err) | |
| 221 | + | assert.Equal(t, "", resp["token"]) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestHandler_Token_InvalidCredentials(t *testing.T) { | |
| 225 | + | authSvc := mocks.NewMockAuthService(t) | |
| 226 | + | ||
| 227 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 228 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 229 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 230 | + | ||
| 231 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 232 | + | ||
| 233 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 234 | + | req.SetBasicAuth("admin", "wrongpassword") | |
| 235 | + | rec := httptest.NewRecorder() | |
| 236 | + | ||
| 237 | + | handler.ServeHTTP(rec, req) | |
| 238 | + | ||
| 239 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestHandler_Token_InternalAuth(t *testing.T) { | |
| 243 | + | authSvc := mocks.NewMockAuthService(t) | |
| 244 | + | ||
| 245 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 246 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute). | |
| 247 | + | Return("internal-access-token", nil) | |
| 248 | + | ||
| 249 | + | internalAuth := InternalAuth{ | |
| 250 | + | Username: "gordon-internal", | |
| 251 | + | Password: "internal-secret", | |
| 252 | + | } | |
| 253 | + | handler := NewHandler(authSvc, internalAuth, testLogger()) | |
| 254 | + | ||
| 255 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 256 | + | req.RemoteAddr = "127.0.0.1:12345" | |
| 257 | + | req.SetBasicAuth("gordon-internal", "internal-secret") | |
| 258 | + | rec := httptest.NewRecorder() | |
| 259 | + | ||
| 260 | + | handler.ServeHTTP(rec, req) | |
| 261 | + | ||
| 262 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 263 | + | ||
| 264 | + | var resp map[string]any | |
| 265 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 266 | + | assert.NoError(t, err) | |
| 267 | + | assert.Equal(t, "internal-access-token", resp["token"]) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestHandler_Token_TokenAuth(t *testing.T) { | |
| 271 | + | authSvc := mocks.NewMockAuthService(t) | |
| 272 | + | ||
| 273 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 274 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 275 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 276 | + | Subject: "myuser", | |
| 277 | + | }, nil) | |
| 278 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | |
| 279 | + | Return("short-lived-token", nil) | |
| 280 | + | ||
| 281 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 282 | + | ||
| 283 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 284 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |
| 285 | + | rec := httptest.NewRecorder() | |
| 286 | + | ||
| 287 | + | handler.ServeHTTP(rec, req) | |
| 288 | + | ||
| 289 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 290 | + | ||
| 291 | + | var resp map[string]any | |
| 292 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 293 | + | assert.NoError(t, err) | |
| 294 | + | assert.Equal(t, "short-lived-token", resp["token"]) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | |
| 298 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 299 | + | ||
| 300 | + | methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} | |
| 301 | + | ||
| 302 | + | for _, method := range methods { | |
| 303 | + | t.Run(method, func(t *testing.T) { | |
| 304 | + | req := httptest.NewRequest(method, "/auth/token", nil) | |
| 305 | + | rec := httptest.NewRecorder() | |
| 306 | + | ||
| 307 | + | handler.ServeHTTP(rec, req) | |
| 308 | + | ||
| 309 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestHandler_NotFound(t *testing.T) { | |
| 315 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 316 | + | ||
| 317 | + | paths := []string{"/auth/unknown", "/auth/", "/auth"} | |
| 318 | + | ||
| 319 | + | for _, path := range paths { | |
| 320 | + | t.Run(path, func(t *testing.T) { | |
| 321 | + | req := httptest.NewRequest(http.MethodGet, path, nil) | |
| 322 | + | rec := httptest.NewRecorder() | |
| 323 | + | ||
| 324 | + | handler.ServeHTTP(rec, req) | |
| 325 | + | ||
| 326 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 327 | + | }) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) { | |
| 332 | + | log := testLogger() | |
| 333 | + | h := &Handler{log: log} | |
| 334 | + | ||
| 335 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil) | |
| 336 | + | ||
| 337 | + | scopes := h.parseRequestedScopes(req, log) | |
| 338 | + | ||
| 339 | + | assert.Len(t, scopes, 2) | |
| 340 | + | assert.Contains(t, scopes, "repository:repo1:pull") | |
| 341 | + | assert.Contains(t, scopes, "repository:repo2:push") | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) { | |
| 345 | + | log := testLogger() | |
| 346 | + | h := &Handler{log: log} | |
| 347 | + | ||
| 348 | + | tests := []struct { | |
| 349 | + | name string | |
| 350 | + | query string | |
| 351 | + | wantScopes []string | |
| 352 | + | }{ | |
| 353 | + | { | |
| 354 | + | name: "invalid format is skipped", | |
| 355 | + | query: "scope=invalid-no-colons&scope=repository:valid:pull", | |
| 356 | + | wantScopes: []string{"repository:valid:pull"}, | |
| 357 | + | }, | |
| 358 | + | { | |
| 359 | + | name: "missing actions is skipped", | |
| 360 | + | query: "scope=repository:myrepo&scope=repository:valid:pull", | |
| 361 | + | wantScopes: []string{"repository:valid:pull"}, | |
| 362 | + | }, | |
| 363 | + | { | |
| 364 | + | name: "all invalid returns default", | |
| 365 | + | query: "scope=invalid&scope=also-invalid", | |
| 366 | + | wantScopes: []string{"repository:*:pull"}, | |
| 367 | + | }, | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | for _, tt := range tests { | |
| 371 | + | t.Run(tt.name, func(t *testing.T) { | |
| 372 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil) | |
| 373 | + | scopes := h.parseRequestedScopes(req, log) | |
| 374 | + | ||
| 375 | + | assert.Equal(t, tt.wantScopes, scopes) | |
| 376 | + | }) | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) { | |
| 381 | + | log := testLogger() | |
| 382 | + | h := &Handler{log: log} | |
| 383 | + | ||
| 384 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 385 | + | scopes := h.parseRequestedScopes(req, log) | |
| 386 | + | ||
| 387 | + | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestIsLocalhostRequest(t *testing.T) { | |
| 391 | + | tests := []struct { | |
| 392 | + | name string | |
| 393 | + | remoteAddr string | |
| 394 | + | want bool | |
| 395 | + | }{ | |
| 396 | + | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | |
| 397 | + | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | |
| 398 | + | {"::1 is localhost", "[::1]:12345", true}, | |
| 399 | + | {"::1 without brackets", "::1:12345", true}, | |
| 400 | + | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | |
| 401 | + | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | |
| 402 | + | {"public IP is not localhost", "8.8.8.8:12345", false}, | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | for _, tt := range tests { | |
| 406 | + | t.Run(tt.name, func(t *testing.T) { | |
| 407 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 408 | + | req.RemoteAddr = tt.remoteAddr | |
| 409 | + | ||
| 410 | + | result := isLocalhostRequest(req) | |
| 411 | + | ||
| 412 | + | assert.Equal(t, tt.want, result) | |
| 413 | + | }) | |
| 414 | + | } | |
| 415 | + | } | |
| @@ -0,0 +1,415 @@ | |||||
| 1 | + | package auth | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ||||
| 15 | + | "gordon/internal/boundaries/in/mocks" | |||
| 16 | + | "gordon/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func testLogger() zerowrap.Logger { | |||
| 20 | + | return zerowrap.Default() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestHandler_Password_Success(t *testing.T) { | |||
| 24 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 25 | + | ||||
| 26 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 27 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 28 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |||
| 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | |||
| 30 | + | Return("jwt-token-here", nil) | |||
| 31 | + | ||||
| 32 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 33 | + | ||||
| 34 | + | body := `{"username":"admin","password":"secret"}` | |||
| 35 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 36 | + | req.Header.Set("Content-Type", "application/json") | |||
| 37 | + | rec := httptest.NewRecorder() | |||
| 38 | + | ||||
| 39 | + | handler.ServeHTTP(rec, req) | |||
| 40 | + | ||||
| 41 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 42 | + | ||||
| 43 | + | var resp PasswordResponse | |||
| 44 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 45 | + | assert.NoError(t, err) | |||
| 46 | + | assert.Equal(t, "jwt-token-here", resp.Token) | |||
| 47 | + | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | |||
| 48 | + | assert.NotEmpty(t, resp.IssuedAt) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |||
| 52 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 53 | + | ||||
| 54 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 55 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 56 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 57 | + | ||||
| 58 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 59 | + | ||||
| 60 | + | body := `{"username":"admin","password":"wrongpassword"}` | |||
| 61 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 62 | + | req.Header.Set("Content-Type", "application/json") | |||
| 63 | + | rec := httptest.NewRecorder() | |||
| 64 | + | ||||
| 65 | + | handler.ServeHTTP(rec, req) | |||
| 66 | + | ||||
| 67 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 68 | + | assert.Contains(t, rec.Body.String(), "invalid credentials") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |||
| 72 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 73 | + | ||||
| 74 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 75 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 76 | + | ||||
| 77 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 78 | + | ||||
| 79 | + | body := `{"username":"admin","password":"secret"}` | |||
| 80 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 81 | + | req.Header.Set("Content-Type", "application/json") | |||
| 82 | + | rec := httptest.NewRecorder() | |||
| 83 | + | ||||
| 84 | + | handler.ServeHTTP(rec, req) | |||
| 85 | + | ||||
| 86 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 87 | + | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 91 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 92 | + | ||||
| 93 | + | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 94 | + | ||||
| 95 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 96 | + | ||||
| 97 | + | body := `{"username":"admin","password":"secret"}` | |||
| 98 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 99 | + | req.Header.Set("Content-Type", "application/json") | |||
| 100 | + | rec := httptest.NewRecorder() | |||
| 101 | + | ||||
| 102 | + | handler.ServeHTTP(rec, req) | |||
| 103 | + | ||||
| 104 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 105 | + | assert.Contains(t, rec.Body.String(), "authentication is disabled") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestHandler_Password_MissingFields(t *testing.T) { | |||
| 109 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 110 | + | ||||
| 111 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 112 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 113 | + | ||||
| 114 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 115 | + | ||||
| 116 | + | tests := []struct { | |||
| 117 | + | name string | |||
| 118 | + | body string | |||
| 119 | + | }{ | |||
| 120 | + | {"missing username", `{"password":"secret"}`}, | |||
| 121 | + | {"missing password", `{"username":"admin"}`}, | |||
| 122 | + | {"empty username", `{"username":"","password":"secret"}`}, | |||
| 123 | + | {"empty password", `{"username":"admin","password":""}`}, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | for _, tt := range tests { | |||
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 128 | + | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |||
| 129 | + | req.Header.Set("Content-Type", "application/json") | |||
| 130 | + | rec := httptest.NewRecorder() | |||
| 131 | + | ||||
| 132 | + | handler.ServeHTTP(rec, req) | |||
| 133 | + | ||||
| 134 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |||
| 140 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |||
| 141 | + | ||||
| 142 | + | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |||
| 143 | + | ||||
| 144 | + | for _, method := range methods { | |||
| 145 | + | t.Run(method, func(t *testing.T) { | |||
| 146 | + | req := httptest.NewRequest(method, "/auth/password", nil) | |||
| 147 | + | rec := httptest.NewRecorder() | |||
| 148 | + | ||||
| 149 | + | handler.ServeHTTP(rec, req) | |||
| 150 | + | ||||
| 151 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestHandler_Token_Success(t *testing.T) { | |||
| 157 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 158 | + | ||||
| 159 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 160 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 161 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |||
| 162 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | |||
| 163 | + | Return("access-token", nil) | |||
| 164 | + | ||||
| 165 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 166 | + | ||||
| 167 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |||
| 168 | + | req.SetBasicAuth("admin", "secret") | |||
| 169 | + | rec := httptest.NewRecorder() | |||
| 170 | + | ||||
| 171 | + | handler.ServeHTTP(rec, req) | |||
| 172 | + | ||||
| 173 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 174 | + | ||||
| 175 | + | var resp map[string]any | |||
| 176 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 177 | + | assert.NoError(t, err) | |||
| 178 | + | assert.Equal(t, "access-token", resp["token"]) | |||
| 179 | + | assert.Equal(t, float64(300), resp["expires_in"]) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHandler_Token_Anonymous(t *testing.T) { | |||
| 183 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 184 | + | ||||
| 185 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 186 | + | ||||
| 187 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 188 | + | ||||
| 189 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 190 | + | rec := httptest.NewRecorder() | |||
| 191 | + | ||||
| 192 | + | handler.ServeHTTP(rec, req) | |||
| 193 | + | ||||
| 194 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 195 | + | ||||
| 196 | + | var resp map[string]any | |||
| 197 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 198 | + | assert.NoError(t, err) | |||
| 199 | + | assert.Equal(t, "", resp["token"]) | |||
| 200 | + | assert.Equal(t, float64(60), resp["expires_in"]) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestHandler_Token_AuthDisabled(t *testing.T) { | |||
| 204 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 205 | + | ||||
| 206 | + | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 207 | + | ||||
| 208 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 209 | + | ||||
| 210 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 211 | + | rec := httptest.NewRecorder() | |||
| 212 | + | ||||
| 213 | + | handler.ServeHTTP(rec, req) | |||
| 214 | + | ||||
| 215 | + | // Should return anonymous token when auth is disabled | |||
| 216 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 217 | + | ||||
| 218 | + | var resp map[string]any | |||
| 219 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 220 | + | assert.NoError(t, err) | |||
| 221 | + | assert.Equal(t, "", resp["token"]) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestHandler_Token_InvalidCredentials(t *testing.T) { | |||
| 225 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 226 | + | ||||
| 227 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 228 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 229 | + | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 230 | + | ||||
| 231 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 232 | + | ||||
| 233 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 234 | + | req.SetBasicAuth("admin", "wrongpassword") | |||
| 235 | + | rec := httptest.NewRecorder() | |||
| 236 | + | ||||
| 237 | + | handler.ServeHTTP(rec, req) | |||
| 238 | + | ||||
| 239 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func TestHandler_Token_InternalAuth(t *testing.T) { | |||
| 243 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 244 | + | ||||
| 245 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 246 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute). | |||
| 247 | + | Return("internal-access-token", nil) | |||
| 248 | + | ||||
| 249 | + | internalAuth := InternalAuth{ | |||
| 250 | + | Username: "gordon-internal", | |||
| 251 | + | Password: "internal-secret", | |||
| 252 | + | } | |||
| 253 | + | handler := NewHandler(authSvc, internalAuth, testLogger()) | |||
| 254 | + | ||||
| 255 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 256 | + | req.RemoteAddr = "127.0.0.1:12345" | |||
| 257 | + | req.SetBasicAuth("gordon-internal", "internal-secret") | |||
| 258 | + | rec := httptest.NewRecorder() | |||
| 259 | + | ||||
| 260 | + | handler.ServeHTTP(rec, req) | |||
| 261 | + | ||||
| 262 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 263 | + | ||||
| 264 | + | var resp map[string]any | |||
| 265 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 266 | + | assert.NoError(t, err) | |||
| 267 | + | assert.Equal(t, "internal-access-token", resp["token"]) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 271 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 272 | + | ||||
| 273 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 274 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 275 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |||
| 276 | + | Subject: "myuser", | |||
| 277 | + | }, nil) | |||
| 278 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | |||
| 279 | + | Return("short-lived-token", nil) | |||
| 280 | + | ||||
| 281 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 282 | + | ||||
| 283 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 284 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |||
| 285 | + | rec := httptest.NewRecorder() | |||
| 286 | + | ||||
| 287 | + | handler.ServeHTTP(rec, req) | |||
| 288 | + | ||||
| 289 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 290 | + | ||||
| 291 | + | var resp map[string]any | |||
| 292 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 293 | + | assert.NoError(t, err) | |||
| 294 | + | assert.Equal(t, "short-lived-token", resp["token"]) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | |||
| 298 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |||
| 299 | + | ||||
| 300 | + | methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} | |||
| 301 | + | ||||
| 302 | + | for _, method := range methods { | |||
| 303 | + | t.Run(method, func(t *testing.T) { | |||
| 304 | + | req := httptest.NewRequest(method, "/auth/token", nil) | |||
| 305 | + | rec := httptest.NewRecorder() | |||
| 306 | + | ||||
| 307 | + | handler.ServeHTTP(rec, req) | |||
| 308 | + | ||||
| 309 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 310 | + | }) | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestHandler_NotFound(t *testing.T) { | |||
| 315 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |||
| 316 | + | ||||
| 317 | + | paths := []string{"/auth/unknown", "/auth/", "/auth"} | |||
| 318 | + | ||||
| 319 | + | for _, path := range paths { | |||
| 320 | + | t.Run(path, func(t *testing.T) { | |||
| 321 | + | req := httptest.NewRequest(http.MethodGet, path, nil) | |||
| 322 | + | rec := httptest.NewRecorder() | |||
| 323 | + | ||||
| 324 | + | handler.ServeHTTP(rec, req) | |||
| 325 | + | ||||
| 326 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 327 | + | }) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) { | |||
| 332 | + | log := testLogger() | |||
| 333 | + | h := &Handler{log: log} | |||
| 334 | + | ||||
| 335 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil) | |||
| 336 | + | ||||
| 337 | + | scopes := h.parseRequestedScopes(req, log) | |||
| 338 | + | ||||
| 339 | + | assert.Len(t, scopes, 2) | |||
| 340 | + | assert.Contains(t, scopes, "repository:repo1:pull") | |||
| 341 | + | assert.Contains(t, scopes, "repository:repo2:push") | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) { | |||
| 345 | + | log := testLogger() | |||
| 346 | + | h := &Handler{log: log} | |||
| 347 | + | ||||
| 348 | + | tests := []struct { | |||
| 349 | + | name string | |||
| 350 | + | query string | |||
| 351 | + | wantScopes []string | |||
| 352 | + | }{ | |||
| 353 | + | { | |||
| 354 | + | name: "invalid format is skipped", | |||
| 355 | + | query: "scope=invalid-no-colons&scope=repository:valid:pull", | |||
| 356 | + | wantScopes: []string{"repository:valid:pull"}, | |||
| 357 | + | }, | |||
| 358 | + | { | |||
| 359 | + | name: "missing actions is skipped", | |||
| 360 | + | query: "scope=repository:myrepo&scope=repository:valid:pull", | |||
| 361 | + | wantScopes: []string{"repository:valid:pull"}, | |||
| 362 | + | }, | |||
| 363 | + | { | |||
| 364 | + | name: "all invalid returns default", | |||
| 365 | + | query: "scope=invalid&scope=also-invalid", | |||
| 366 | + | wantScopes: []string{"repository:*:pull"}, | |||
| 367 | + | }, | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | for _, tt := range tests { | |||
| 371 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 372 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil) | |||
| 373 | + | scopes := h.parseRequestedScopes(req, log) | |||
| 374 | + | ||||
| 375 | + | assert.Equal(t, tt.wantScopes, scopes) | |||
| 376 | + | }) | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) { | |||
| 381 | + | log := testLogger() | |||
| 382 | + | h := &Handler{log: log} | |||
| 383 | + | ||||
| 384 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 385 | + | scopes := h.parseRequestedScopes(req, log) | |||
| 386 | + | ||||
| 387 | + | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestIsLocalhostRequest(t *testing.T) { | |||
| 391 | + | tests := []struct { | |||
| 392 | + | name string | |||
| 393 | + | remoteAddr string | |||
| 394 | + | want bool | |||
| 395 | + | }{ | |||
| 396 | + | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | |||
| 397 | + | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | |||
| 398 | + | {"::1 is localhost", "[::1]:12345", true}, | |||
| 399 | + | {"::1 without brackets", "::1:12345", true}, | |||
| 400 | + | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | |||
| 401 | + | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | |||
| 402 | + | {"public IP is not localhost", "8.8.8.8:12345", false}, | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | for _, tt := range tests { | |||
| 406 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 407 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |||
| 408 | + | req.RemoteAddr = tt.remoteAddr | |||
| 409 | + | ||||
| 410 | + | result := isLocalhostRequest(req) | |||
| 411 | + | ||||
| 412 | + | assert.Equal(t, tt.want, result) | |||
| 413 | + | }) | |||
| 414 | + | } | |||
| 415 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +8 -1
| @@ -144,11 +144,18 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 144 | 144 | ctx := r.Context() | |
| 145 | 145 | ||
| 146 | 146 | // Authenticate based on auth type | |
| 147 | + | // When auth type is "password", accept BOTH password and token auth | |
| 148 | + | // (allows CI/CD tokens while still supporting interactive password login) | |
| 147 | 149 | var authenticated bool | |
| 148 | 150 | var tokenClaims *domain.TokenClaims | |
| 149 | 151 | switch authSvc.GetAuthType() { | |
| 150 | 152 | case domain.AuthTypePassword: | |
| 153 | + | // Try password auth first | |
| 151 | 154 | authenticated = authenticatePassword(ctx, r, authSvc, log) | |
| 155 | + | if !authenticated { | |
| 156 | + | // Fall back to token auth (for CI/CD tokens) | |
| 157 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 158 | + | } | |
| 152 | 159 | case domain.AuthTypeToken: | |
| 153 | 160 | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 154 | 161 | default: | |
| @@ -410,7 +417,7 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri | |||
| 410 | 417 | } else if r.TLS != nil { | |
| 411 | 418 | scheme = "https" | |
| 412 | 419 | } | |
| 413 | - | realm := scheme + "://" + realmHost + "/v2/token" | |
| 420 | + | realm := scheme + "://" + realmHost + "/auth/token" | |
| 414 | 421 | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |
| 415 | 422 | default: | |
| 416 | 423 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| @@ -144,11 +144,18 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 144 | ctx := r.Context() | 144 | ctx := r.Context() | ||
| 145 | 145 | ||||
| 146 | // Authenticate based on auth type | 146 | // Authenticate based on auth type | ||
| 147 | + | // When auth type is "password", accept BOTH password and token auth | |||
| 148 | + | // (allows CI/CD tokens while still supporting interactive password login) | |||
| 147 | var authenticated bool | 149 | var authenticated bool | ||
| 148 | var tokenClaims *domain.TokenClaims | 150 | var tokenClaims *domain.TokenClaims | ||
| 149 | switch authSvc.GetAuthType() { | 151 | switch authSvc.GetAuthType() { | ||
| 150 | case domain.AuthTypePassword: | 152 | case domain.AuthTypePassword: | ||
| 153 | + | // Try password auth first | |||
| 151 | authenticated = authenticatePassword(ctx, r, authSvc, log) | 154 | authenticated = authenticatePassword(ctx, r, authSvc, log) | ||
| 155 | + | if !authenticated { | |||
| 156 | + | // Fall back to token auth (for CI/CD tokens) | |||
| 157 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 158 | + | } | |||
| 152 | case domain.AuthTypeToken: | 159 | case domain.AuthTypeToken: | ||
| 153 | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | 160 | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | ||
| 154 | default: | 161 | default: | ||
| @@ -410,7 +417,7 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri | |||||
| 410 | } else if r.TLS != nil { | 417 | } else if r.TLS != nil { | ||
| 411 | scheme = "https" | 418 | scheme = "https" | ||
| 412 | } | 419 | } | ||
| 413 | - | realm := scheme + "://" + realmHost + "/v2/token" | 420 | + | realm := scheme + "://" + realmHost + "/auth/token" |
| 414 | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | 421 | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | ||
| 415 | default: | 422 | default: | ||
| 416 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | 423 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | ||
A internal/adapters/in/http/middleware/clientip.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 10 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 11 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| 12 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |
| 13 | + | var nets []*net.IPNet | |
| 14 | + | for _, proxy := range proxies { | |
| 15 | + | // Try parsing as CIDR | |
| 16 | + | _, ipNet, err := net.ParseCIDR(proxy) | |
| 17 | + | if err == nil { | |
| 18 | + | nets = append(nets, ipNet) | |
| 19 | + | continue | |
| 20 | + | } | |
| 21 | + | // Try parsing as single IP | |
| 22 | + | ip := net.ParseIP(proxy) | |
| 23 | + | if ip != nil { | |
| 24 | + | // Convert single IP to /32 or /128 CIDR | |
| 25 | + | bits := 32 | |
| 26 | + | if ip.To4() == nil { | |
| 27 | + | bits = 128 | |
| 28 | + | } | |
| 29 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | return nets | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | |
| 36 | + | // Returns false if trustedNets is empty or the IP cannot be parsed. | |
| 37 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |
| 38 | + | if len(trustedNets) == 0 { | |
| 39 | + | return false | |
| 40 | + | } | |
| 41 | + | parsedIP := net.ParseIP(ip) | |
| 42 | + | if parsedIP == nil { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | for _, ipNet := range trustedNets { | |
| 46 | + | if ipNet.Contains(parsedIP) { | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // GetClientIP extracts the client IP address from the request. | |
| 54 | + | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |
| 55 | + | // originates from a trusted proxy. This prevents attackers from spoofing | |
| 56 | + | // their IP address when Gordon is exposed directly without a reverse proxy. | |
| 57 | + | func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |
| 58 | + | // Extract the direct connection IP from RemoteAddr | |
| 59 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 60 | + | if err != nil { | |
| 61 | + | remoteIP = r.RemoteAddr | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Only honor proxy headers if the request comes from a trusted proxy | |
| 65 | + | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 66 | + | // Check X-Forwarded-For for proxied requests | |
| 67 | + | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 68 | + | // Take first IP in chain (original client) | |
| 69 | + | if first, _, found := strings.Cut(xff, ","); found { | |
| 70 | + | return strings.TrimSpace(first) | |
| 71 | + | } | |
| 72 | + | return strings.TrimSpace(xff) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Check X-Real-IP | |
| 76 | + | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 77 | + | return xri | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Fall back to RemoteAddr (direct connection IP) | |
| 82 | + | return remoteIP | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |||
| 10 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |||
| 11 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |||
| 12 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |||
| 13 | + | var nets []*net.IPNet | |||
| 14 | + | for _, proxy := range proxies { | |||
| 15 | + | // Try parsing as CIDR | |||
| 16 | + | _, ipNet, err := net.ParseCIDR(proxy) | |||
| 17 | + | if err == nil { | |||
| 18 | + | nets = append(nets, ipNet) | |||
| 19 | + | continue | |||
| 20 | + | } | |||
| 21 | + | // Try parsing as single IP | |||
| 22 | + | ip := net.ParseIP(proxy) | |||
| 23 | + | if ip != nil { | |||
| 24 | + | // Convert single IP to /32 or /128 CIDR | |||
| 25 | + | bits := 32 | |||
| 26 | + | if ip.To4() == nil { | |||
| 27 | + | bits = 128 | |||
| 28 | + | } | |||
| 29 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | return nets | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | |||
| 36 | + | // Returns false if trustedNets is empty or the IP cannot be parsed. | |||
| 37 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |||
| 38 | + | if len(trustedNets) == 0 { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | parsedIP := net.ParseIP(ip) | |||
| 42 | + | if parsedIP == nil { | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | for _, ipNet := range trustedNets { | |||
| 46 | + | if ipNet.Contains(parsedIP) { | |||
| 47 | + | return true | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // GetClientIP extracts the client IP address from the request. | |||
| 54 | + | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |||
| 55 | + | // originates from a trusted proxy. This prevents attackers from spoofing | |||
| 56 | + | // their IP address when Gordon is exposed directly without a reverse proxy. | |||
| 57 | + | func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 58 | + | // Extract the direct connection IP from RemoteAddr | |||
| 59 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 60 | + | if err != nil { | |||
| 61 | + | remoteIP = r.RemoteAddr | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Only honor proxy headers if the request comes from a trusted proxy | |||
| 65 | + | if IsTrustedProxy(remoteIP, trustedNets) { | |||
| 66 | + | // Check X-Forwarded-For for proxied requests | |||
| 67 | + | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |||
| 68 | + | // Take first IP in chain (original client) | |||
| 69 | + | if first, _, found := strings.Cut(xff, ","); found { | |||
| 70 | + | return strings.TrimSpace(first) | |||
| 71 | + | } | |||
| 72 | + | return strings.TrimSpace(xff) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Check X-Real-IP | |||
| 76 | + | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |||
| 77 | + | return xri | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Fall back to RemoteAddr (direct connection IP) | |||
| 82 | + | return remoteIP | |||
| 83 | + | } | |||
A internal/adapters/in/http/middleware/clientip_test.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestGetClientIP(t *testing.T) { | |
| 13 | + | // Parse trusted proxies for tests | |
| 14 | + | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |
| 15 | + | noTrustedNets := []*net.IPNet{} | |
| 16 | + | ||
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | remoteAddr string | |
| 20 | + | xff string | |
| 21 | + | xRealIP string | |
| 22 | + | trustedNets []*net.IPNet | |
| 23 | + | wantIP string | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "from RemoteAddr with port", | |
| 27 | + | remoteAddr: "192.168.1.100:12345", | |
| 28 | + | trustedNets: noTrustedNets, | |
| 29 | + | wantIP: "192.168.1.100", | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "from RemoteAddr without port", | |
| 33 | + | remoteAddr: "192.168.1.100", | |
| 34 | + | trustedNets: noTrustedNets, | |
| 35 | + | wantIP: "192.168.1.100", | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "XFF ignored when no trusted proxies", | |
| 39 | + | remoteAddr: "192.168.1.100:12345", | |
| 40 | + | xff: "203.0.113.50", | |
| 41 | + | trustedNets: noTrustedNets, | |
| 42 | + | wantIP: "192.168.1.100", | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "XFF ignored when remote is not trusted", | |
| 46 | + | remoteAddr: "192.168.1.100:12345", | |
| 47 | + | xff: "203.0.113.50", | |
| 48 | + | trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8 | |
| 49 | + | wantIP: "192.168.1.100", | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | name: "XFF honored from trusted proxy (single IP)", | |
| 53 | + | remoteAddr: "127.0.0.1:12345", | |
| 54 | + | xff: "203.0.113.50", | |
| 55 | + | trustedNets: trustedNets, | |
| 56 | + | wantIP: "203.0.113.50", | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "XFF honored from trusted proxy (multiple IPs)", | |
| 60 | + | remoteAddr: "127.0.0.1:12345", | |
| 61 | + | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 62 | + | trustedNets: trustedNets, | |
| 63 | + | wantIP: "203.0.113.50", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "XFF honored from trusted CIDR", | |
| 67 | + | remoteAddr: "10.1.2.3:12345", | |
| 68 | + | xff: "203.0.113.50", | |
| 69 | + | trustedNets: trustedNets, | |
| 70 | + | wantIP: "203.0.113.50", | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "X-Real-IP honored from trusted proxy", | |
| 74 | + | remoteAddr: "127.0.0.1:12345", | |
| 75 | + | xRealIP: "203.0.113.50", | |
| 76 | + | trustedNets: trustedNets, | |
| 77 | + | wantIP: "203.0.113.50", | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "X-Real-IP ignored when not trusted", | |
| 81 | + | remoteAddr: "192.168.1.100:12345", | |
| 82 | + | xRealIP: "203.0.113.50", | |
| 83 | + | trustedNets: trustedNets, | |
| 84 | + | wantIP: "192.168.1.100", | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "XFF takes precedence over X-Real-IP", | |
| 88 | + | remoteAddr: "127.0.0.1:12345", | |
| 89 | + | xff: "203.0.113.50", | |
| 90 | + | xRealIP: "203.0.113.60", | |
| 91 | + | trustedNets: trustedNets, | |
| 92 | + | wantIP: "203.0.113.50", | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "IPv6 RemoteAddr", | |
| 96 | + | remoteAddr: "[::1]:12345", | |
| 97 | + | trustedNets: noTrustedNets, | |
| 98 | + | wantIP: "::1", | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 105 | + | req.RemoteAddr = tt.remoteAddr | |
| 106 | + | if tt.xff != "" { | |
| 107 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |
| 108 | + | } | |
| 109 | + | if tt.xRealIP != "" { | |
| 110 | + | req.Header.Set("X-Real-IP", tt.xRealIP) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | ip := GetClientIP(req, tt.trustedNets) | |
| 114 | + | assert.Equal(t, tt.wantIP, ip) | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestParseTrustedProxies(t *testing.T) { | |
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | proxies []string | |
| 123 | + | testIP string | |
| 124 | + | want bool | |
| 125 | + | }{ | |
| 126 | + | { | |
| 127 | + | name: "empty list", | |
| 128 | + | proxies: []string{}, | |
| 129 | + | testIP: "192.168.1.1", | |
| 130 | + | want: false, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "single IP match", | |
| 134 | + | proxies: []string{"192.168.1.1"}, | |
| 135 | + | testIP: "192.168.1.1", | |
| 136 | + | want: true, | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "single IP no match", | |
| 140 | + | proxies: []string{"192.168.1.1"}, | |
| 141 | + | testIP: "192.168.1.2", | |
| 142 | + | want: false, | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "CIDR match", | |
| 146 | + | proxies: []string{"10.0.0.0/8"}, | |
| 147 | + | testIP: "10.1.2.3", | |
| 148 | + | want: true, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | name: "CIDR no match", | |
| 152 | + | proxies: []string{"10.0.0.0/8"}, | |
| 153 | + | testIP: "192.168.1.1", | |
| 154 | + | want: false, | |
| 155 | + | }, | |
| 156 | + | { | |
| 157 | + | name: "mixed IP and CIDR", | |
| 158 | + | proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"}, | |
| 159 | + | testIP: "172.20.1.1", | |
| 160 | + | want: true, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | name: "invalid entries ignored", | |
| 164 | + | proxies: []string{"not-an-ip", "10.0.0.0/8"}, | |
| 165 | + | testIP: "10.1.2.3", | |
| 166 | + | want: true, | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | name: "IPv6 single IP", | |
| 170 | + | proxies: []string{"::1"}, | |
| 171 | + | testIP: "::1", | |
| 172 | + | want: true, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "IPv6 CIDR", | |
| 176 | + | proxies: []string{"fd00::/8"}, | |
| 177 | + | testIP: "fd12:3456::1", | |
| 178 | + | want: true, | |
| 179 | + | }, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | for _, tt := range tests { | |
| 183 | + | t.Run(tt.name, func(t *testing.T) { | |
| 184 | + | nets := ParseTrustedProxies(tt.proxies) | |
| 185 | + | got := IsTrustedProxy(tt.testIP, nets) | |
| 186 | + | assert.Equal(t, tt.want, got) | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestIsTrustedProxy(t *testing.T) { | |
| 192 | + | tests := []struct { | |
| 193 | + | name string | |
| 194 | + | ip string | |
| 195 | + | trustedNets []*net.IPNet | |
| 196 | + | want bool | |
| 197 | + | }{ | |
| 198 | + | { | |
| 199 | + | name: "empty trusted nets", | |
| 200 | + | ip: "192.168.1.1", | |
| 201 | + | trustedNets: []*net.IPNet{}, | |
| 202 | + | want: false, | |
| 203 | + | }, | |
| 204 | + | { | |
| 205 | + | name: "nil trusted nets", | |
| 206 | + | ip: "192.168.1.1", | |
| 207 | + | trustedNets: nil, | |
| 208 | + | want: false, | |
| 209 | + | }, | |
| 210 | + | { | |
| 211 | + | name: "invalid IP", | |
| 212 | + | ip: "not-an-ip", | |
| 213 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 214 | + | want: false, | |
| 215 | + | }, | |
| 216 | + | { | |
| 217 | + | name: "empty IP", | |
| 218 | + | ip: "", | |
| 219 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 220 | + | want: false, | |
| 221 | + | }, | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | for _, tt := range tests { | |
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |
| 226 | + | got := IsTrustedProxy(tt.ip, tt.trustedNets) | |
| 227 | + | assert.Equal(t, tt.want, got) | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestGetClientIP(t *testing.T) { | |||
| 13 | + | // Parse trusted proxies for tests | |||
| 14 | + | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |||
| 15 | + | noTrustedNets := []*net.IPNet{} | |||
| 16 | + | ||||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | remoteAddr string | |||
| 20 | + | xff string | |||
| 21 | + | xRealIP string | |||
| 22 | + | trustedNets []*net.IPNet | |||
| 23 | + | wantIP string | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "from RemoteAddr with port", | |||
| 27 | + | remoteAddr: "192.168.1.100:12345", | |||
| 28 | + | trustedNets: noTrustedNets, | |||
| 29 | + | wantIP: "192.168.1.100", | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "from RemoteAddr without port", | |||
| 33 | + | remoteAddr: "192.168.1.100", | |||
| 34 | + | trustedNets: noTrustedNets, | |||
| 35 | + | wantIP: "192.168.1.100", | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "XFF ignored when no trusted proxies", | |||
| 39 | + | remoteAddr: "192.168.1.100:12345", | |||
| 40 | + | xff: "203.0.113.50", | |||
| 41 | + | trustedNets: noTrustedNets, | |||
| 42 | + | wantIP: "192.168.1.100", | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "XFF ignored when remote is not trusted", | |||
| 46 | + | remoteAddr: "192.168.1.100:12345", | |||
| 47 | + | xff: "203.0.113.50", | |||
| 48 | + | trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8 | |||
| 49 | + | wantIP: "192.168.1.100", | |||
| 50 | + | }, | |||
| 51 | + | { | |||
| 52 | + | name: "XFF honored from trusted proxy (single IP)", | |||
| 53 | + | remoteAddr: "127.0.0.1:12345", | |||
| 54 | + | xff: "203.0.113.50", | |||
| 55 | + | trustedNets: trustedNets, | |||
| 56 | + | wantIP: "203.0.113.50", | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "XFF honored from trusted proxy (multiple IPs)", | |||
| 60 | + | remoteAddr: "127.0.0.1:12345", | |||
| 61 | + | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |||
| 62 | + | trustedNets: trustedNets, | |||
| 63 | + | wantIP: "203.0.113.50", | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "XFF honored from trusted CIDR", | |||
| 67 | + | remoteAddr: "10.1.2.3:12345", | |||
| 68 | + | xff: "203.0.113.50", | |||
| 69 | + | trustedNets: trustedNets, | |||
| 70 | + | wantIP: "203.0.113.50", | |||
| 71 | + | }, | |||
| 72 | + | { | |||
| 73 | + | name: "X-Real-IP honored from trusted proxy", | |||
| 74 | + | remoteAddr: "127.0.0.1:12345", | |||
| 75 | + | xRealIP: "203.0.113.50", | |||
| 76 | + | trustedNets: trustedNets, | |||
| 77 | + | wantIP: "203.0.113.50", | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "X-Real-IP ignored when not trusted", | |||
| 81 | + | remoteAddr: "192.168.1.100:12345", | |||
| 82 | + | xRealIP: "203.0.113.50", | |||
| 83 | + | trustedNets: trustedNets, | |||
| 84 | + | wantIP: "192.168.1.100", | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "XFF takes precedence over X-Real-IP", | |||
| 88 | + | remoteAddr: "127.0.0.1:12345", | |||
| 89 | + | xff: "203.0.113.50", | |||
| 90 | + | xRealIP: "203.0.113.60", | |||
| 91 | + | trustedNets: trustedNets, | |||
| 92 | + | wantIP: "203.0.113.50", | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "IPv6 RemoteAddr", | |||
| 96 | + | remoteAddr: "[::1]:12345", | |||
| 97 | + | trustedNets: noTrustedNets, | |||
| 98 | + | wantIP: "::1", | |||
| 99 | + | }, | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 105 | + | req.RemoteAddr = tt.remoteAddr | |||
| 106 | + | if tt.xff != "" { | |||
| 107 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |||
| 108 | + | } | |||
| 109 | + | if tt.xRealIP != "" { | |||
| 110 | + | req.Header.Set("X-Real-IP", tt.xRealIP) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | ip := GetClientIP(req, tt.trustedNets) | |||
| 114 | + | assert.Equal(t, tt.wantIP, ip) | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestParseTrustedProxies(t *testing.T) { | |||
| 120 | + | tests := []struct { | |||
| 121 | + | name string | |||
| 122 | + | proxies []string | |||
| 123 | + | testIP string | |||
| 124 | + | want bool | |||
| 125 | + | }{ | |||
| 126 | + | { | |||
| 127 | + | name: "empty list", | |||
| 128 | + | proxies: []string{}, | |||
| 129 | + | testIP: "192.168.1.1", | |||
| 130 | + | want: false, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "single IP match", | |||
| 134 | + | proxies: []string{"192.168.1.1"}, | |||
| 135 | + | testIP: "192.168.1.1", | |||
| 136 | + | want: true, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "single IP no match", | |||
| 140 | + | proxies: []string{"192.168.1.1"}, | |||
| 141 | + | testIP: "192.168.1.2", | |||
| 142 | + | want: false, | |||
| 143 | + | }, | |||
| 144 | + | { | |||
| 145 | + | name: "CIDR match", | |||
| 146 | + | proxies: []string{"10.0.0.0/8"}, | |||
| 147 | + | testIP: "10.1.2.3", | |||
| 148 | + | want: true, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "CIDR no match", | |||
| 152 | + | proxies: []string{"10.0.0.0/8"}, | |||
| 153 | + | testIP: "192.168.1.1", | |||
| 154 | + | want: false, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "mixed IP and CIDR", | |||
| 158 | + | proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"}, | |||
| 159 | + | testIP: "172.20.1.1", | |||
| 160 | + | want: true, | |||
| 161 | + | }, | |||
| 162 | + | { | |||
| 163 | + | name: "invalid entries ignored", | |||
| 164 | + | proxies: []string{"not-an-ip", "10.0.0.0/8"}, | |||
| 165 | + | testIP: "10.1.2.3", | |||
| 166 | + | want: true, | |||
| 167 | + | }, | |||
| 168 | + | { | |||
| 169 | + | name: "IPv6 single IP", | |||
| 170 | + | proxies: []string{"::1"}, | |||
| 171 | + | testIP: "::1", | |||
| 172 | + | want: true, | |||
| 173 | + | }, | |||
| 174 | + | { | |||
| 175 | + | name: "IPv6 CIDR", | |||
| 176 | + | proxies: []string{"fd00::/8"}, | |||
| 177 | + | testIP: "fd12:3456::1", | |||
| 178 | + | want: true, | |||
| 179 | + | }, | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | for _, tt := range tests { | |||
| 183 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 184 | + | nets := ParseTrustedProxies(tt.proxies) | |||
| 185 | + | got := IsTrustedProxy(tt.testIP, nets) | |||
| 186 | + | assert.Equal(t, tt.want, got) | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestIsTrustedProxy(t *testing.T) { | |||
| 192 | + | tests := []struct { | |||
| 193 | + | name string | |||
| 194 | + | ip string | |||
| 195 | + | trustedNets []*net.IPNet | |||
| 196 | + | want bool | |||
| 197 | + | }{ | |||
| 198 | + | { | |||
| 199 | + | name: "empty trusted nets", | |||
| 200 | + | ip: "192.168.1.1", | |||
| 201 | + | trustedNets: []*net.IPNet{}, | |||
| 202 | + | want: false, | |||
| 203 | + | }, | |||
| 204 | + | { | |||
| 205 | + | name: "nil trusted nets", | |||
| 206 | + | ip: "192.168.1.1", | |||
| 207 | + | trustedNets: nil, | |||
| 208 | + | want: false, | |||
| 209 | + | }, | |||
| 210 | + | { | |||
| 211 | + | name: "invalid IP", | |||
| 212 | + | ip: "not-an-ip", | |||
| 213 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |||
| 214 | + | want: false, | |||
| 215 | + | }, | |||
| 216 | + | { | |||
| 217 | + | name: "empty IP", | |||
| 218 | + | ip: "", | |||
| 219 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |||
| 220 | + | want: false, | |||
| 221 | + | }, | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | for _, tt := range tests { | |||
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 226 | + | got := IsTrustedProxy(tt.ip, tt.trustedNets) | |||
| 227 | + | assert.Equal(t, tt.want, got) | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | } | |||
A internal/adapters/in/http/middleware/security_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSecurityHeaders(t *testing.T) { | |
| 12 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 13 | + | w.WriteHeader(http.StatusOK) | |
| 14 | + | }) | |
| 15 | + | ||
| 16 | + | wrapped := SecurityHeaders(handler) | |
| 17 | + | ||
| 18 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 19 | + | rec := httptest.NewRecorder() | |
| 20 | + | wrapped.ServeHTTP(rec, req) | |
| 21 | + | ||
| 22 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 23 | + | ||
| 24 | + | // Verify all security headers are set | |
| 25 | + | assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) | |
| 26 | + | assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) | |
| 27 | + | assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) | |
| 28 | + | assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy")) | |
| 29 | + | assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy")) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { | |
| 33 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 34 | + | w.Header().Set("Content-Type", "application/json") | |
| 35 | + | w.Header().Set("X-Custom-Header", "custom-value") | |
| 36 | + | w.WriteHeader(http.StatusOK) | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | wrapped := SecurityHeaders(handler) | |
| 40 | + | ||
| 41 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 42 | + | rec := httptest.NewRecorder() | |
| 43 | + | wrapped.ServeHTTP(rec, req) | |
| 44 | + | ||
| 45 | + | // Security headers should be set | |
| 46 | + | assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) | |
| 47 | + | ||
| 48 | + | // Handler's headers should also be present | |
| 49 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 50 | + | assert.Equal(t, "custom-value", rec.Header().Get("X-Custom-Header")) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestSecurityHeaders_CallsNextHandler(t *testing.T) { | |
| 54 | + | called := false | |
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | + | called = true | |
| 57 | + | w.WriteHeader(http.StatusOK) | |
| 58 | + | }) | |
| 59 | + | ||
| 60 | + | wrapped := SecurityHeaders(handler) | |
| 61 | + | ||
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 63 | + | rec := httptest.NewRecorder() | |
| 64 | + | wrapped.ServeHTTP(rec, req) | |
| 65 | + | ||
| 66 | + | assert.True(t, called, "next handler should have been called") | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSecurityHeaders(t *testing.T) { | |||
| 12 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 13 | + | w.WriteHeader(http.StatusOK) | |||
| 14 | + | }) | |||
| 15 | + | ||||
| 16 | + | wrapped := SecurityHeaders(handler) | |||
| 17 | + | ||||
| 18 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 19 | + | rec := httptest.NewRecorder() | |||
| 20 | + | wrapped.ServeHTTP(rec, req) | |||
| 21 | + | ||||
| 22 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 23 | + | ||||
| 24 | + | // Verify all security headers are set | |||
| 25 | + | assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) | |||
| 26 | + | assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) | |||
| 27 | + | assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) | |||
| 28 | + | assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy")) | |||
| 29 | + | assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy")) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { | |||
| 33 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 34 | + | w.Header().Set("Content-Type", "application/json") | |||
| 35 | + | w.Header().Set("X-Custom-Header", "custom-value") | |||
| 36 | + | w.WriteHeader(http.StatusOK) | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | wrapped := SecurityHeaders(handler) | |||
| 40 | + | ||||
| 41 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 42 | + | rec := httptest.NewRecorder() | |||
| 43 | + | wrapped.ServeHTTP(rec, req) | |||
| 44 | + | ||||
| 45 | + | // Security headers should be set | |||
| 46 | + | assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) | |||
| 47 | + | ||||
| 48 | + | // Handler's headers should also be present | |||
| 49 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |||
| 50 | + | assert.Equal(t, "custom-value", rec.Header().Get("X-Custom-Header")) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestSecurityHeaders_CallsNextHandler(t *testing.T) { | |||
| 54 | + | called := false | |||
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | + | called = true | |||
| 57 | + | w.WriteHeader(http.StatusOK) | |||
| 58 | + | }) | |||
| 59 | + | ||||
| 60 | + | wrapped := SecurityHeaders(handler) | |||
| 61 | + | ||||
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 63 | + | rec := httptest.NewRecorder() | |||
| 64 | + | wrapped.ServeHTTP(rec, req) | |||
| 65 | + | ||||
| 66 | + | assert.True(t, called, "next handler should have been called") | |||
| 67 | + | } | |||
M internal/adapters/in/http/registry/ratelimit.go +20 -162
| @@ -3,116 +3,47 @@ package registry | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "encoding/json" | |
| 6 | - | "net" | |
| 7 | 6 | "net/http" | |
| 8 | - | "strings" | |
| 9 | - | "sync" | |
| 10 | 7 | ||
| 11 | - | "golang.org/x/time/rate" | |
| 8 | + | "github.com/bnema/zerowrap" | |
| 12 | 9 | ||
| 13 | 10 | "gordon/internal/adapters/dto" | |
| 11 | + | "gordon/internal/adapters/in/http/middleware" | |
| 12 | + | "gordon/internal/boundaries/out" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | - | // RateLimitConfig holds rate limiting configuration for the registry API. | |
| 17 | - | // These settings help prevent DoS attacks and brute force attempts. | |
| 18 | - | type RateLimitConfig struct { | |
| 19 | - | // Enabled controls whether rate limiting is active. | |
| 20 | - | // Default: true | |
| 21 | - | Enabled bool | |
| 22 | - | ||
| 23 | - | // GlobalRPS is the maximum requests per second across all clients. | |
| 24 | - | // Default: 500 | |
| 25 | - | GlobalRPS float64 | |
| 26 | - | ||
| 27 | - | // PerIPRPS is the maximum requests per second per client IP. | |
| 28 | - | // Default: 50 | |
| 29 | - | PerIPRPS float64 | |
| 30 | - | ||
| 31 | - | // Burst is the maximum burst size for rate limiters. | |
| 32 | - | // Default: 100 | |
| 33 | - | Burst int | |
| 34 | - | ||
| 35 | - | // TrustedProxies is a list of IP addresses or CIDR ranges that are trusted | |
| 36 | - | // to set X-Forwarded-For and X-Real-IP headers. If empty, these headers are | |
| 37 | - | // ignored and RemoteAddr is always used (secure default for direct exposure). | |
| 38 | - | // Example: ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] | |
| 39 | - | TrustedProxies []string | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // DefaultRateLimitConfig returns sensible default rate limiting configuration. | |
| 43 | - | func DefaultRateLimitConfig() RateLimitConfig { | |
| 44 | - | return RateLimitConfig{ | |
| 45 | - | Enabled: true, | |
| 46 | - | GlobalRPS: 500, // 500 requests/second globally | |
| 47 | - | PerIPRPS: 50, // 50 requests/second per IP | |
| 48 | - | Burst: 100, // Allow burst of 100 requests | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // ipRateLimiter manages per-IP rate limiters. | |
| 53 | - | type ipRateLimiter struct { | |
| 54 | - | limiters map[string]*rate.Limiter | |
| 55 | - | mu sync.RWMutex | |
| 56 | - | rps float64 | |
| 57 | - | burst int | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func newIPRateLimiter(rps float64, burst int) *ipRateLimiter { | |
| 61 | - | return &ipRateLimiter{ | |
| 62 | - | limiters: make(map[string]*rate.Limiter), | |
| 63 | - | rps: rps, | |
| 64 | - | burst: burst, | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func (l *ipRateLimiter) getLimiter(ip string) *rate.Limiter { | |
| 69 | - | l.mu.RLock() | |
| 70 | - | limiter, exists := l.limiters[ip] | |
| 71 | - | l.mu.RUnlock() | |
| 72 | - | ||
| 73 | - | if exists { | |
| 74 | - | return limiter | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | l.mu.Lock() | |
| 78 | - | defer l.mu.Unlock() | |
| 79 | - | ||
| 80 | - | // Double-check after acquiring write lock | |
| 81 | - | if limiter, exists = l.limiters[ip]; exists { | |
| 82 | - | return limiter | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | limiter = rate.NewLimiter(rate.Limit(l.rps), l.burst) | |
| 86 | - | l.limiters[ip] = limiter | |
| 87 | - | return limiter | |
| 88 | - | } | |
| 89 | - | ||
| 90 | 15 | // RateLimitMiddleware creates rate limiting middleware for the registry API. | |
| 91 | - | // Returns nil if rate limiting is disabled. | |
| 92 | - | func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler { | |
| 93 | - | if !config.Enabled { | |
| 16 | + | // It uses the provided RateLimiter interfaces for global and per-IP limits. | |
| 17 | + | // IP extraction uses shared middleware.GetClientIP for trusted proxy handling. | |
| 18 | + | func RateLimitMiddleware( | |
| 19 | + | globalLimiter out.RateLimiter, | |
| 20 | + | ipLimiter out.RateLimiter, | |
| 21 | + | trustedProxies []string, | |
| 22 | + | log zerowrap.Logger, | |
| 23 | + | ) func(http.Handler) http.Handler { | |
| 24 | + | // If either limiter is nil, pass through without rate limiting | |
| 25 | + | if globalLimiter == nil || ipLimiter == nil { | |
| 94 | 26 | return func(next http.Handler) http.Handler { | |
| 95 | - | return next // Pass through if disabled | |
| 27 | + | return next | |
| 96 | 28 | } | |
| 97 | 29 | } | |
| 98 | 30 | ||
| 99 | - | globalLimiter := rate.NewLimiter(rate.Limit(config.GlobalRPS), config.Burst) | |
| 100 | - | ipLimiter := newIPRateLimiter(config.PerIPRPS, config.Burst) | |
| 101 | - | ||
| 102 | 31 | // Parse trusted proxy CIDRs once at middleware creation | |
| 103 | - | trustedNets := parseTrustedProxies(config.TrustedProxies) | |
| 32 | + | trustedNets := middleware.ParseTrustedProxies(trustedProxies) | |
| 104 | 33 | ||
| 105 | 34 | return func(next http.Handler) http.Handler { | |
| 106 | 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 36 | + | ctx := r.Context() | |
| 37 | + | ||
| 107 | 38 | // Check global rate limit | |
| 108 | - | if !globalLimiter.Allow() { | |
| 39 | + | if !globalLimiter.Allow(ctx, "global") { | |
| 109 | 40 | sendRateLimitError(w) | |
| 110 | 41 | return | |
| 111 | 42 | } | |
| 112 | 43 | ||
| 113 | 44 | // Check per-IP rate limit | |
| 114 | - | ip := getClientIP(r, trustedNets) | |
| 115 | - | if !ipLimiter.getLimiter(ip).Allow() { | |
| 45 | + | ip := middleware.GetClientIP(r, trustedNets) | |
| 46 | + | if !ipLimiter.Allow(ctx, "ip:"+ip) { | |
| 116 | 47 | sendRateLimitError(w) | |
| 117 | 48 | return | |
| 118 | 49 | } | |
| @@ -122,47 +53,6 @@ func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler | |||
| 122 | 53 | } | |
| 123 | 54 | } | |
| 124 | 55 | ||
| 125 | - | // parseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 126 | - | func parseTrustedProxies(proxies []string) []*net.IPNet { | |
| 127 | - | var nets []*net.IPNet | |
| 128 | - | for _, proxy := range proxies { | |
| 129 | - | // Try parsing as CIDR | |
| 130 | - | _, ipNet, err := net.ParseCIDR(proxy) | |
| 131 | - | if err == nil { | |
| 132 | - | nets = append(nets, ipNet) | |
| 133 | - | continue | |
| 134 | - | } | |
| 135 | - | // Try parsing as single IP | |
| 136 | - | ip := net.ParseIP(proxy) | |
| 137 | - | if ip != nil { | |
| 138 | - | // Convert single IP to /32 or /128 CIDR | |
| 139 | - | bits := 32 | |
| 140 | - | if ip.To4() == nil { | |
| 141 | - | bits = 128 | |
| 142 | - | } | |
| 143 | - | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | return nets | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // isTrustedProxy checks if the given IP is from a trusted proxy. | |
| 150 | - | func isTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |
| 151 | - | if len(trustedNets) == 0 { | |
| 152 | - | return false | |
| 153 | - | } | |
| 154 | - | parsedIP := net.ParseIP(ip) | |
| 155 | - | if parsedIP == nil { | |
| 156 | - | return false | |
| 157 | - | } | |
| 158 | - | for _, ipNet := range trustedNets { | |
| 159 | - | if ipNet.Contains(parsedIP) { | |
| 160 | - | return true | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | return false | |
| 164 | - | } | |
| 165 | - | ||
| 166 | 56 | // sendRateLimitError sends an HTTP 429 response in Docker Registry API format. | |
| 167 | 57 | func sendRateLimitError(w http.ResponseWriter) { | |
| 168 | 58 | w.Header().Set("Content-Type", "application/json") | |
| @@ -176,35 +66,3 @@ func sendRateLimitError(w http.ResponseWriter) { | |||
| 176 | 66 | }}, | |
| 177 | 67 | }) | |
| 178 | 68 | } | |
| 179 | - | ||
| 180 | - | // getClientIP extracts the client IP address from the request. | |
| 181 | - | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |
| 182 | - | // originates from a trusted proxy. This prevents attackers from spoofing | |
| 183 | - | // their IP address when Gordon is exposed directly without a reverse proxy. | |
| 184 | - | func getClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |
| 185 | - | // Extract the direct connection IP from RemoteAddr | |
| 186 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 187 | - | if err != nil { | |
| 188 | - | remoteIP = r.RemoteAddr | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // Only honor proxy headers if the request comes from a trusted proxy | |
| 192 | - | if isTrustedProxy(remoteIP, trustedNets) { | |
| 193 | - | // Check X-Forwarded-For for proxied requests | |
| 194 | - | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 195 | - | // Take first IP in chain (original client) | |
| 196 | - | if first, _, found := strings.Cut(xff, ","); found { | |
| 197 | - | return strings.TrimSpace(first) | |
| 198 | - | } | |
| 199 | - | return strings.TrimSpace(xff) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // Check X-Real-IP | |
| 203 | - | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 204 | - | return xri | |
| 205 | - | } | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // Fall back to RemoteAddr (direct connection IP) | |
| 209 | - | return remoteIP | |
| 210 | - | } | |
| @@ -3,116 +3,47 @@ package registry | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | - | "net" | |||
| 7 | "net/http" | 6 | "net/http" | ||
| 8 | - | "strings" | |||
| 9 | - | "sync" | |||
| 10 | 7 | ||||
| 11 | - | "golang.org/x/time/rate" | 8 | + | "github.com/bnema/zerowrap" |
| 12 | 9 | ||||
| 13 | "gordon/internal/adapters/dto" | 10 | "gordon/internal/adapters/dto" | ||
| 11 | + | "gordon/internal/adapters/in/http/middleware" | |||
| 12 | + | "gordon/internal/boundaries/out" | |||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | - | // RateLimitConfig holds rate limiting configuration for the registry API. | |||
| 17 | - | // These settings help prevent DoS attacks and brute force attempts. | |||
| 18 | - | type RateLimitConfig struct { | |||
| 19 | - | // Enabled controls whether rate limiting is active. | |||
| 20 | - | // Default: true | |||
| 21 | - | Enabled bool | |||
| 22 | - | ||||
| 23 | - | // GlobalRPS is the maximum requests per second across all clients. | |||
| 24 | - | // Default: 500 | |||
| 25 | - | GlobalRPS float64 | |||
| 26 | - | ||||
| 27 | - | // PerIPRPS is the maximum requests per second per client IP. | |||
| 28 | - | // Default: 50 | |||
| 29 | - | PerIPRPS float64 | |||
| 30 | - | ||||
| 31 | - | // Burst is the maximum burst size for rate limiters. | |||
| 32 | - | // Default: 100 | |||
| 33 | - | Burst int | |||
| 34 | - | ||||
| 35 | - | // TrustedProxies is a list of IP addresses or CIDR ranges that are trusted | |||
| 36 | - | // to set X-Forwarded-For and X-Real-IP headers. If empty, these headers are | |||
| 37 | - | // ignored and RemoteAddr is always used (secure default for direct exposure). | |||
| 38 | - | // Example: ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] | |||
| 39 | - | TrustedProxies []string | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // DefaultRateLimitConfig returns sensible default rate limiting configuration. | |||
| 43 | - | func DefaultRateLimitConfig() RateLimitConfig { | |||
| 44 | - | return RateLimitConfig{ | |||
| 45 | - | Enabled: true, | |||
| 46 | - | GlobalRPS: 500, // 500 requests/second globally | |||
| 47 | - | PerIPRPS: 50, // 50 requests/second per IP | |||
| 48 | - | Burst: 100, // Allow burst of 100 requests | |||
| 49 | - | } | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | // ipRateLimiter manages per-IP rate limiters. | |||
| 53 | - | type ipRateLimiter struct { | |||
| 54 | - | limiters map[string]*rate.Limiter | |||
| 55 | - | mu sync.RWMutex | |||
| 56 | - | rps float64 | |||
| 57 | - | burst int | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func newIPRateLimiter(rps float64, burst int) *ipRateLimiter { | |||
| 61 | - | return &ipRateLimiter{ | |||
| 62 | - | limiters: make(map[string]*rate.Limiter), | |||
| 63 | - | rps: rps, | |||
| 64 | - | burst: burst, | |||
| 65 | - | } | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | func (l *ipRateLimiter) getLimiter(ip string) *rate.Limiter { | |||
| 69 | - | l.mu.RLock() | |||
| 70 | - | limiter, exists := l.limiters[ip] | |||
| 71 | - | l.mu.RUnlock() | |||
| 72 | - | ||||
| 73 | - | if exists { | |||
| 74 | - | return limiter | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | l.mu.Lock() | |||
| 78 | - | defer l.mu.Unlock() | |||
| 79 | - | ||||
| 80 | - | // Double-check after acquiring write lock | |||
| 81 | - | if limiter, exists = l.limiters[ip]; exists { | |||
| 82 | - | return limiter | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | limiter = rate.NewLimiter(rate.Limit(l.rps), l.burst) | |||
| 86 | - | l.limiters[ip] = limiter | |||
| 87 | - | return limiter | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | // RateLimitMiddleware creates rate limiting middleware for the registry API. | 15 | // RateLimitMiddleware creates rate limiting middleware for the registry API. | ||
| 91 | - | // Returns nil if rate limiting is disabled. | 16 | + | // It uses the provided RateLimiter interfaces for global and per-IP limits. |
| 92 | - | func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler { | 17 | + | // IP extraction uses shared middleware.GetClientIP for trusted proxy handling. |
| 93 | - | if !config.Enabled { | 18 | + | func RateLimitMiddleware( |
| 19 | + | globalLimiter out.RateLimiter, | |||
| 20 | + | ipLimiter out.RateLimiter, | |||
| 21 | + | trustedProxies []string, | |||
| 22 | + | log zerowrap.Logger, | |||
| 23 | + | ) func(http.Handler) http.Handler { | |||
| 24 | + | // If either limiter is nil, pass through without rate limiting | |||
| 25 | + | if globalLimiter == nil || ipLimiter == nil { | |||
| 94 | return func(next http.Handler) http.Handler { | 26 | return func(next http.Handler) http.Handler { | ||
| 95 | - | return next // Pass through if disabled | 27 | + | return next |
| 96 | } | 28 | } | ||
| 97 | } | 29 | } | ||
| 98 | 30 | ||||
| 99 | - | globalLimiter := rate.NewLimiter(rate.Limit(config.GlobalRPS), config.Burst) | |||
| 100 | - | ipLimiter := newIPRateLimiter(config.PerIPRPS, config.Burst) | |||
| 101 | - | ||||
| 102 | // Parse trusted proxy CIDRs once at middleware creation | 31 | // Parse trusted proxy CIDRs once at middleware creation | ||
| 103 | - | trustedNets := parseTrustedProxies(config.TrustedProxies) | 32 | + | trustedNets := middleware.ParseTrustedProxies(trustedProxies) |
| 104 | 33 | ||||
| 105 | return func(next http.Handler) http.Handler { | 34 | return func(next http.Handler) http.Handler { | ||
| 106 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 36 | + | ctx := r.Context() | |||
| 37 | + | ||||
| 107 | // Check global rate limit | 38 | // Check global rate limit | ||
| 108 | - | if !globalLimiter.Allow() { | 39 | + | if !globalLimiter.Allow(ctx, "global") { |
| 109 | sendRateLimitError(w) | 40 | sendRateLimitError(w) | ||
| 110 | return | 41 | return | ||
| 111 | } | 42 | } | ||
| 112 | 43 | ||||
| 113 | // Check per-IP rate limit | 44 | // Check per-IP rate limit | ||
| 114 | - | ip := getClientIP(r, trustedNets) | 45 | + | ip := middleware.GetClientIP(r, trustedNets) |
| 115 | - | if !ipLimiter.getLimiter(ip).Allow() { | 46 | + | if !ipLimiter.Allow(ctx, "ip:"+ip) { |
| 116 | sendRateLimitError(w) | 47 | sendRateLimitError(w) | ||
| 117 | return | 48 | return | ||
| 118 | } | 49 | } | ||
| @@ -122,47 +53,6 @@ func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler | |||||
| 122 | } | 53 | } | ||
| 123 | } | 54 | } | ||
| 124 | 55 | ||||
| 125 | - | // parseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |||
| 126 | - | func parseTrustedProxies(proxies []string) []*net.IPNet { | |||
| 127 | - | var nets []*net.IPNet | |||
| 128 | - | for _, proxy := range proxies { | |||
| 129 | - | // Try parsing as CIDR | |||
| 130 | - | _, ipNet, err := net.ParseCIDR(proxy) | |||
| 131 | - | if err == nil { | |||
| 132 | - | nets = append(nets, ipNet) | |||
| 133 | - | continue | |||
| 134 | - | } | |||
| 135 | - | // Try parsing as single IP | |||
| 136 | - | ip := net.ParseIP(proxy) | |||
| 137 | - | if ip != nil { | |||
| 138 | - | // Convert single IP to /32 or /128 CIDR | |||
| 139 | - | bits := 32 | |||
| 140 | - | if ip.To4() == nil { | |||
| 141 | - | bits = 128 | |||
| 142 | - | } | |||
| 143 | - | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | return nets | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // isTrustedProxy checks if the given IP is from a trusted proxy. | |||
| 150 | - | func isTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |||
| 151 | - | if len(trustedNets) == 0 { | |||
| 152 | - | return false | |||
| 153 | - | } | |||
| 154 | - | parsedIP := net.ParseIP(ip) | |||
| 155 | - | if parsedIP == nil { | |||
| 156 | - | return false | |||
| 157 | - | } | |||
| 158 | - | for _, ipNet := range trustedNets { | |||
| 159 | - | if ipNet.Contains(parsedIP) { | |||
| 160 | - | return true | |||
| 161 | - | } | |||
| 162 | - | } | |||
| 163 | - | return false | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | // sendRateLimitError sends an HTTP 429 response in Docker Registry API format. | 56 | // sendRateLimitError sends an HTTP 429 response in Docker Registry API format. | ||
| 167 | func sendRateLimitError(w http.ResponseWriter) { | 57 | func sendRateLimitError(w http.ResponseWriter) { | ||
| 168 | w.Header().Set("Content-Type", "application/json") | 58 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -176,35 +66,3 @@ func sendRateLimitError(w http.ResponseWriter) { | |||||
| 176 | }}, | 66 | }}, | ||
| 177 | }) | 67 | }) | ||
| 178 | } | 68 | } | ||
| 179 | - | ||||
| 180 | - | // getClientIP extracts the client IP address from the request. | |||
| 181 | - | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |||
| 182 | - | // originates from a trusted proxy. This prevents attackers from spoofing | |||
| 183 | - | // their IP address when Gordon is exposed directly without a reverse proxy. | |||
| 184 | - | func getClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 185 | - | // Extract the direct connection IP from RemoteAddr | |||
| 186 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 187 | - | if err != nil { | |||
| 188 | - | remoteIP = r.RemoteAddr | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | // Only honor proxy headers if the request comes from a trusted proxy | |||
| 192 | - | if isTrustedProxy(remoteIP, trustedNets) { | |||
| 193 | - | // Check X-Forwarded-For for proxied requests | |||
| 194 | - | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |||
| 195 | - | // Take first IP in chain (original client) | |||
| 196 | - | if first, _, found := strings.Cut(xff, ","); found { | |||
| 197 | - | return strings.TrimSpace(first) | |||
| 198 | - | } | |||
| 199 | - | return strings.TrimSpace(xff) | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | // Check X-Real-IP | |||
| 203 | - | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |||
| 204 | - | return xri | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // Fall back to RemoteAddr (direct connection IP) | |||
| 209 | - | return remoteIP | |||
| 210 | - | } | |||
M internal/adapters/in/http/registry/ratelimit_test.go +169 -85
| @@ -1,29 +1,30 @@ | |||
| 1 | 1 | package registry | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "net" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "gordon/internal/adapters/in/http/middleware" | |
| 13 | + | "gordon/internal/adapters/out/ratelimit" | |
| 14 | + | outmocks "gordon/internal/boundaries/out/mocks" | |
| 11 | 15 | ) | |
| 12 | 16 | ||
| 13 | 17 | func TestRateLimitMiddleware_Disabled(t *testing.T) { | |
| 14 | - | config := RateLimitConfig{ | |
| 15 | - | Enabled: false, | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 18 | + | // When limiters are nil, middleware should pass through | |
| 19 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 19 | 20 | w.WriteHeader(http.StatusOK) | |
| 20 | 21 | }) | |
| 21 | 22 | ||
| 22 | - | middleware := RateLimitMiddleware(config) | |
| 23 | + | middleware := RateLimitMiddleware(nil, nil, nil, testLogger()) | |
| 23 | 24 | wrappedHandler := middleware(handler) | |
| 24 | 25 | ||
| 25 | 26 | // Should pass through without rate limiting | |
| 26 | - | for i := 0; i < 1000; i++ { | |
| 27 | + | for i := 0; i < 100; i++ { | |
| 27 | 28 | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 28 | 29 | rec := httptest.NewRecorder() | |
| 29 | 30 | wrappedHandler.ServeHTTP(rec, req) | |
| @@ -31,104 +32,196 @@ func TestRateLimitMiddleware_Disabled(t *testing.T) { | |||
| 31 | 32 | } | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | - | func TestRateLimitMiddleware_PerIPLimit(t *testing.T) { | |
| 35 | - | config := RateLimitConfig{ | |
| 36 | - | Enabled: true, | |
| 37 | - | GlobalRPS: 1000, // High global limit | |
| 38 | - | PerIPRPS: 1, // Very low per-IP limit for testing | |
| 39 | - | Burst: 100, // High burst to not interfere | |
| 40 | - | } | |
| 35 | + | func TestRateLimitMiddleware_GlobalLimitHit(t *testing.T) { | |
| 36 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 37 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 38 | + | ||
| 39 | + | // Global limiter returns false (rate limited) | |
| 40 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) | |
| 41 | 41 | ||
| 42 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 42 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 43 | 43 | w.WriteHeader(http.StatusOK) | |
| 44 | 44 | }) | |
| 45 | 45 | ||
| 46 | - | middleware := RateLimitMiddleware(config) | |
| 46 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |
| 47 | 47 | wrappedHandler := middleware(handler) | |
| 48 | 48 | ||
| 49 | - | // First request should succeed | |
| 50 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 51 | - | req1.RemoteAddr = "192.168.1.100:12345" | |
| 52 | - | rec1 := httptest.NewRecorder() | |
| 53 | - | wrappedHandler.ServeHTTP(rec1, req1) | |
| 54 | - | assert.Equal(t, http.StatusOK, rec1.Code) | |
| 49 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 50 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 51 | + | rec := httptest.NewRecorder() | |
| 52 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 55 | 53 | ||
| 56 | - | // Request from different IP should succeed (independent rate limiter) | |
| 57 | - | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 58 | - | req2.RemoteAddr = "192.168.1.101:12345" | |
| 59 | - | rec2 := httptest.NewRecorder() | |
| 60 | - | wrappedHandler.ServeHTTP(rec2, req2) | |
| 61 | - | assert.Equal(t, http.StatusOK, rec2.Code) | |
| 54 | + | // Should return 429 when global limit hit | |
| 55 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 56 | + | assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS") | |
| 62 | 57 | } | |
| 63 | 58 | ||
| 64 | - | func TestRateLimitMiddleware_GlobalLimit(t *testing.T) { | |
| 65 | - | config := RateLimitConfig{ | |
| 66 | - | Enabled: true, | |
| 67 | - | GlobalRPS: 1, // Very low global limit for testing | |
| 68 | - | PerIPRPS: 100, | |
| 69 | - | Burst: 1, | |
| 70 | - | } | |
| 59 | + | func TestRateLimitMiddleware_PerIPLimitHit(t *testing.T) { | |
| 60 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 61 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 62 | + | ||
| 63 | + | // Global limiter allows, IP limiter denies | |
| 64 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 65 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false) | |
| 71 | 66 | ||
| 72 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 67 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 73 | 68 | w.WriteHeader(http.StatusOK) | |
| 74 | 69 | }) | |
| 75 | 70 | ||
| 76 | - | middleware := RateLimitMiddleware(config) | |
| 71 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |
| 77 | 72 | wrappedHandler := middleware(handler) | |
| 78 | 73 | ||
| 79 | - | // First request should succeed | |
| 80 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 81 | - | req1.RemoteAddr = "192.168.1.100:12345" | |
| 82 | - | rec1 := httptest.NewRecorder() | |
| 83 | - | wrappedHandler.ServeHTTP(rec1, req1) | |
| 84 | - | assert.Equal(t, http.StatusOK, rec1.Code) | |
| 74 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 75 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 76 | + | rec := httptest.NewRecorder() | |
| 77 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 85 | 78 | ||
| 86 | - | // Second request should hit global limit even from different IP | |
| 87 | - | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 88 | - | req2.RemoteAddr = "192.168.1.101:12345" | |
| 89 | - | rec2 := httptest.NewRecorder() | |
| 90 | - | wrappedHandler.ServeHTTP(rec2, req2) | |
| 91 | - | assert.Equal(t, http.StatusTooManyRequests, rec2.Code) | |
| 79 | + | // Should return 429 when per-IP limit hit | |
| 80 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestRateLimitMiddleware_BothLimitsPass(t *testing.T) { | |
| 84 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 85 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 86 | + | ||
| 87 | + | // Both limiters allow | |
| 88 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 89 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 90 | + | ||
| 91 | + | handlerCalled := false | |
| 92 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 93 | + | handlerCalled = true | |
| 94 | + | w.WriteHeader(http.StatusOK) | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |
| 98 | + | wrappedHandler := middleware(handler) | |
| 99 | + | ||
| 100 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 101 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 102 | + | rec := httptest.NewRecorder() | |
| 103 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 104 | + | ||
| 105 | + | // Should pass through to handler | |
| 106 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 107 | + | assert.True(t, handlerCalled, "handler should have been called") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestRateLimitMiddleware_TrustedProxy(t *testing.T) { | |
| 111 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 112 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 113 | + | ||
| 114 | + | // Request comes from trusted proxy (127.0.0.1) with XFF header | |
| 115 | + | // Should use the X-Forwarded-For IP (203.0.113.50) for rate limiting | |
| 116 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 117 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 118 | + | ||
| 119 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 120 | + | w.WriteHeader(http.StatusOK) | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger()) | |
| 124 | + | wrappedHandler := middleware(handler) | |
| 125 | + | ||
| 126 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 127 | + | req.RemoteAddr = "127.0.0.1:12345" | |
| 128 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |
| 129 | + | rec := httptest.NewRecorder() | |
| 130 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 131 | + | ||
| 132 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestRateLimitMiddleware_UntrustedProxy(t *testing.T) { | |
| 136 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 137 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 138 | + | ||
| 139 | + | // Request from untrusted IP with XFF header should use RemoteAddr IP | |
| 140 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 141 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 142 | + | ||
| 143 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 144 | + | w.WriteHeader(http.StatusOK) | |
| 145 | + | }) | |
| 146 | + | ||
| 147 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger()) | |
| 148 | + | wrappedHandler := middleware(handler) | |
| 149 | + | ||
| 150 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 151 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 152 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") // Should be ignored | |
| 153 | + | rec := httptest.NewRecorder() | |
| 154 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 155 | + | ||
| 156 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 92 | 157 | } | |
| 93 | 158 | ||
| 94 | 159 | func TestRateLimitMiddleware_ResponseFormat(t *testing.T) { | |
| 95 | - | config := RateLimitConfig{ | |
| 96 | - | Enabled: true, | |
| 97 | - | GlobalRPS: 1, | |
| 98 | - | PerIPRPS: 1, | |
| 99 | - | Burst: 1, | |
| 100 | - | } | |
| 160 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| 161 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 162 | + | ||
| 163 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) | |
| 164 | + | ||
| 165 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 166 | + | w.WriteHeader(http.StatusOK) | |
| 167 | + | }) | |
| 168 | + | ||
| 169 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |
| 170 | + | wrappedHandler := middleware(handler) | |
| 171 | + | ||
| 172 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 173 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 174 | + | rec := httptest.NewRecorder() | |
| 175 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 176 | + | ||
| 177 | + | // Verify response format | |
| 178 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 179 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 180 | + | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |
| 181 | + | assert.Equal(t, "1", rec.Header().Get("Retry-After")) | |
| 182 | + | assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS") | |
| 183 | + | } | |
| 101 | 184 | ||
| 102 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 185 | + | // Integration test using real MemoryStore implementation | |
| 186 | + | func TestRateLimitMiddleware_Integration(t *testing.T) { | |
| 187 | + | log := testLogger() | |
| 188 | + | globalLimiter := ratelimit.NewMemoryStore(1000, 100, log) // High global limit | |
| 189 | + | ipLimiter := ratelimit.NewMemoryStore(2, 2, log) // Low per-IP limit | |
| 190 | + | ||
| 191 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 103 | 192 | w.WriteHeader(http.StatusOK) | |
| 104 | 193 | }) | |
| 105 | 194 | ||
| 106 | - | middleware := RateLimitMiddleware(config) | |
| 195 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, log) | |
| 107 | 196 | wrappedHandler := middleware(handler) | |
| 108 | 197 | ||
| 109 | - | // First request succeeds | |
| 110 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 111 | - | req1.RemoteAddr = "192.168.1.100:12345" | |
| 112 | - | rec1 := httptest.NewRecorder() | |
| 113 | - | wrappedHandler.ServeHTTP(rec1, req1) | |
| 198 | + | // First 2 requests from same IP should succeed (burst) | |
| 199 | + | for i := 0; i < 2; i++ { | |
| 200 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 201 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 202 | + | rec := httptest.NewRecorder() | |
| 203 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 204 | + | assert.Equal(t, http.StatusOK, rec.Code, "request %d should succeed", i+1) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // 3rd request from same IP should be rate limited | |
| 208 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 209 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 210 | + | rec := httptest.NewRecorder() | |
| 211 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 212 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code, "3rd request should be rate limited") | |
| 114 | 213 | ||
| 115 | - | // Second request gets rate limited | |
| 214 | + | // Request from different IP should still succeed (independent limit) | |
| 116 | 215 | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 117 | - | req2.RemoteAddr = "192.168.1.100:12346" | |
| 216 | + | req2.RemoteAddr = "192.168.1.101:12345" | |
| 118 | 217 | rec2 := httptest.NewRecorder() | |
| 119 | 218 | wrappedHandler.ServeHTTP(rec2, req2) | |
| 120 | - | ||
| 121 | - | // Verify response format | |
| 122 | - | assert.Equal(t, http.StatusTooManyRequests, rec2.Code) | |
| 123 | - | assert.Equal(t, "application/json", rec2.Header().Get("Content-Type")) | |
| 124 | - | assert.Equal(t, "registry/2.0", rec2.Header().Get("Docker-Distribution-API-Version")) | |
| 125 | - | assert.Equal(t, "1", rec2.Header().Get("Retry-After")) | |
| 126 | - | assert.Contains(t, rec2.Body.String(), "TOOMANYREQUESTS") | |
| 219 | + | assert.Equal(t, http.StatusOK, rec2.Code, "different IP should succeed") | |
| 127 | 220 | } | |
| 128 | 221 | ||
| 129 | 222 | func TestGetClientIP(t *testing.T) { | |
| 130 | 223 | // Parse trusted proxies for tests | |
| 131 | - | trustedNets := parseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |
| 224 | + | trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |
| 132 | 225 | noTrustedNets := []*net.IPNet{} | |
| 133 | 226 | ||
| 134 | 227 | tests := []struct { | |
| @@ -221,7 +314,7 @@ func TestGetClientIP(t *testing.T) { | |||
| 221 | 314 | req.Header.Set("X-Real-IP", tt.xRealIP) | |
| 222 | 315 | } | |
| 223 | 316 | ||
| 224 | - | ip := getClientIP(req, tt.trustedNets) | |
| 317 | + | ip := middleware.GetClientIP(req, tt.trustedNets) | |
| 225 | 318 | assert.Equal(t, tt.wantIP, ip) | |
| 226 | 319 | }) | |
| 227 | 320 | } | |
| @@ -280,18 +373,9 @@ func TestParseTrustedProxies(t *testing.T) { | |||
| 280 | 373 | ||
| 281 | 374 | for _, tt := range tests { | |
| 282 | 375 | t.Run(tt.name, func(t *testing.T) { | |
| 283 | - | nets := parseTrustedProxies(tt.proxies) | |
| 284 | - | got := isTrustedProxy(tt.testIP, nets) | |
| 376 | + | nets := middleware.ParseTrustedProxies(tt.proxies) | |
| 377 | + | got := middleware.IsTrustedProxy(tt.testIP, nets) | |
| 285 | 378 | assert.Equal(t, tt.want, got) | |
| 286 | 379 | }) | |
| 287 | 380 | } | |
| 288 | 381 | } | |
| 289 | - | ||
| 290 | - | func TestDefaultRateLimitConfig(t *testing.T) { | |
| 291 | - | config := DefaultRateLimitConfig() | |
| 292 | - | ||
| 293 | - | require.True(t, config.Enabled) | |
| 294 | - | assert.Equal(t, float64(500), config.GlobalRPS) | |
| 295 | - | assert.Equal(t, float64(50), config.PerIPRPS) | |
| 296 | - | assert.Equal(t, 100, config.Burst) | |
| 297 | - | } | |
| @@ -1,29 +1,30 @@ | |||||
| 1 | package registry | 1 | package registry | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "net" | 5 | "net" | ||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 10 | - | "github.com/stretchr/testify/require" | 11 | + | |
| 12 | + | "gordon/internal/adapters/in/http/middleware" | |||
| 13 | + | "gordon/internal/adapters/out/ratelimit" | |||
| 14 | + | outmocks "gordon/internal/boundaries/out/mocks" | |||
| 11 | ) | 15 | ) | ||
| 12 | 16 | ||||
| 13 | func TestRateLimitMiddleware_Disabled(t *testing.T) { | 17 | func TestRateLimitMiddleware_Disabled(t *testing.T) { | ||
| 14 | - | config := RateLimitConfig{ | 18 | + | // When limiters are nil, middleware should pass through |
| 15 | - | Enabled: false, | 19 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 19 | w.WriteHeader(http.StatusOK) | 20 | w.WriteHeader(http.StatusOK) | ||
| 20 | }) | 21 | }) | ||
| 21 | 22 | ||||
| 22 | - | middleware := RateLimitMiddleware(config) | 23 | + | middleware := RateLimitMiddleware(nil, nil, nil, testLogger()) |
| 23 | wrappedHandler := middleware(handler) | 24 | wrappedHandler := middleware(handler) | ||
| 24 | 25 | ||||
| 25 | // Should pass through without rate limiting | 26 | // Should pass through without rate limiting | ||
| 26 | - | for i := 0; i < 1000; i++ { | 27 | + | for i := 0; i < 100; i++ { |
| 27 | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 28 | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | ||
| 28 | rec := httptest.NewRecorder() | 29 | rec := httptest.NewRecorder() | ||
| 29 | wrappedHandler.ServeHTTP(rec, req) | 30 | wrappedHandler.ServeHTTP(rec, req) | ||
| @@ -31,104 +32,196 @@ func TestRateLimitMiddleware_Disabled(t *testing.T) { | |||||
| 31 | } | 32 | } | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | - | func TestRateLimitMiddleware_PerIPLimit(t *testing.T) { | 35 | + | func TestRateLimitMiddleware_GlobalLimitHit(t *testing.T) { |
| 35 | - | config := RateLimitConfig{ | 36 | + | globalLimiter := outmocks.NewMockRateLimiter(t) |
| 36 | - | Enabled: true, | 37 | + | ipLimiter := outmocks.NewMockRateLimiter(t) |
| 37 | - | GlobalRPS: 1000, // High global limit | 38 | + | |
| 38 | - | PerIPRPS: 1, // Very low per-IP limit for testing | 39 | + | // Global limiter returns false (rate limited) |
| 39 | - | Burst: 100, // High burst to not interfere | 40 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) |
| 40 | - | } | |||
| 41 | 41 | ||||
| 42 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 42 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 43 | w.WriteHeader(http.StatusOK) | 43 | w.WriteHeader(http.StatusOK) | ||
| 44 | }) | 44 | }) | ||
| 45 | 45 | ||||
| 46 | - | middleware := RateLimitMiddleware(config) | 46 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) |
| 47 | wrappedHandler := middleware(handler) | 47 | wrappedHandler := middleware(handler) | ||
| 48 | 48 | ||||
| 49 | - | // First request should succeed | 49 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) |
| 50 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 50 | + | req.RemoteAddr = "192.168.1.100:12345" |
| 51 | - | req1.RemoteAddr = "192.168.1.100:12345" | 51 | + | rec := httptest.NewRecorder() |
| 52 | - | rec1 := httptest.NewRecorder() | 52 | + | wrappedHandler.ServeHTTP(rec, req) |
| 53 | - | wrappedHandler.ServeHTTP(rec1, req1) | |||
| 54 | - | assert.Equal(t, http.StatusOK, rec1.Code) | |||
| 55 | 53 | ||||
| 56 | - | // Request from different IP should succeed (independent rate limiter) | 54 | + | // Should return 429 when global limit hit |
| 57 | - | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 55 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) |
| 58 | - | req2.RemoteAddr = "192.168.1.101:12345" | 56 | + | assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS") |
| 59 | - | rec2 := httptest.NewRecorder() | |||
| 60 | - | wrappedHandler.ServeHTTP(rec2, req2) | |||
| 61 | - | assert.Equal(t, http.StatusOK, rec2.Code) | |||
| 62 | } | 57 | } | ||
| 63 | 58 | ||||
| 64 | - | func TestRateLimitMiddleware_GlobalLimit(t *testing.T) { | 59 | + | func TestRateLimitMiddleware_PerIPLimitHit(t *testing.T) { |
| 65 | - | config := RateLimitConfig{ | 60 | + | globalLimiter := outmocks.NewMockRateLimiter(t) |
| 66 | - | Enabled: true, | 61 | + | ipLimiter := outmocks.NewMockRateLimiter(t) |
| 67 | - | GlobalRPS: 1, // Very low global limit for testing | 62 | + | |
| 68 | - | PerIPRPS: 100, | 63 | + | // Global limiter allows, IP limiter denies |
| 69 | - | Burst: 1, | 64 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) |
| 70 | - | } | 65 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false) |
| 71 | 66 | ||||
| 72 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 67 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 73 | w.WriteHeader(http.StatusOK) | 68 | w.WriteHeader(http.StatusOK) | ||
| 74 | }) | 69 | }) | ||
| 75 | 70 | ||||
| 76 | - | middleware := RateLimitMiddleware(config) | 71 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) |
| 77 | wrappedHandler := middleware(handler) | 72 | wrappedHandler := middleware(handler) | ||
| 78 | 73 | ||||
| 79 | - | // First request should succeed | 74 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) |
| 80 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 75 | + | req.RemoteAddr = "192.168.1.100:12345" |
| 81 | - | req1.RemoteAddr = "192.168.1.100:12345" | 76 | + | rec := httptest.NewRecorder() |
| 82 | - | rec1 := httptest.NewRecorder() | 77 | + | wrappedHandler.ServeHTTP(rec, req) |
| 83 | - | wrappedHandler.ServeHTTP(rec1, req1) | |||
| 84 | - | assert.Equal(t, http.StatusOK, rec1.Code) | |||
| 85 | 78 | ||||
| 86 | - | // Second request should hit global limit even from different IP | 79 | + | // Should return 429 when per-IP limit hit |
| 87 | - | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 80 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) |
| 88 | - | req2.RemoteAddr = "192.168.1.101:12345" | 81 | + | } |
| 89 | - | rec2 := httptest.NewRecorder() | 82 | + | |
| 90 | - | wrappedHandler.ServeHTTP(rec2, req2) | 83 | + | func TestRateLimitMiddleware_BothLimitsPass(t *testing.T) { |
| 91 | - | assert.Equal(t, http.StatusTooManyRequests, rec2.Code) | 84 | + | globalLimiter := outmocks.NewMockRateLimiter(t) |
| 85 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 86 | + | ||||
| 87 | + | // Both limiters allow | |||
| 88 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 89 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 90 | + | ||||
| 91 | + | handlerCalled := false | |||
| 92 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 93 | + | handlerCalled = true | |||
| 94 | + | w.WriteHeader(http.StatusOK) | |||
| 95 | + | }) | |||
| 96 | + | ||||
| 97 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |||
| 98 | + | wrappedHandler := middleware(handler) | |||
| 99 | + | ||||
| 100 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 101 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 102 | + | rec := httptest.NewRecorder() | |||
| 103 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 104 | + | ||||
| 105 | + | // Should pass through to handler | |||
| 106 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 107 | + | assert.True(t, handlerCalled, "handler should have been called") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestRateLimitMiddleware_TrustedProxy(t *testing.T) { | |||
| 111 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 112 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 113 | + | ||||
| 114 | + | // Request comes from trusted proxy (127.0.0.1) with XFF header | |||
| 115 | + | // Should use the X-Forwarded-For IP (203.0.113.50) for rate limiting | |||
| 116 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 117 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |||
| 118 | + | ||||
| 119 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 120 | + | w.WriteHeader(http.StatusOK) | |||
| 121 | + | }) | |||
| 122 | + | ||||
| 123 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger()) | |||
| 124 | + | wrappedHandler := middleware(handler) | |||
| 125 | + | ||||
| 126 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 127 | + | req.RemoteAddr = "127.0.0.1:12345" | |||
| 128 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |||
| 129 | + | rec := httptest.NewRecorder() | |||
| 130 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 131 | + | ||||
| 132 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestRateLimitMiddleware_UntrustedProxy(t *testing.T) { | |||
| 136 | + | globalLimiter := outmocks.NewMockRateLimiter(t) | |||
| 137 | + | ipLimiter := outmocks.NewMockRateLimiter(t) | |||
| 138 | + | ||||
| 139 | + | // Request from untrusted IP with XFF header should use RemoteAddr IP | |||
| 140 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |||
| 141 | + | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |||
| 142 | + | ||||
| 143 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 144 | + | w.WriteHeader(http.StatusOK) | |||
| 145 | + | }) | |||
| 146 | + | ||||
| 147 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger()) | |||
| 148 | + | wrappedHandler := middleware(handler) | |||
| 149 | + | ||||
| 150 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 151 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 152 | + | req.Header.Set("X-Forwarded-For", "203.0.113.50") // Should be ignored | |||
| 153 | + | rec := httptest.NewRecorder() | |||
| 154 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 155 | + | ||||
| 156 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 92 | } | 157 | } | ||
| 93 | 158 | ||||
| 94 | func TestRateLimitMiddleware_ResponseFormat(t *testing.T) { | 159 | func TestRateLimitMiddleware_ResponseFormat(t *testing.T) { | ||
| 95 | - | config := RateLimitConfig{ | 160 | + | globalLimiter := outmocks.NewMockRateLimiter(t) |
| 96 | - | Enabled: true, | 161 | + | ipLimiter := outmocks.NewMockRateLimiter(t) |
| 97 | - | GlobalRPS: 1, | 162 | + | |
| 98 | - | PerIPRPS: 1, | 163 | + | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false) |
| 99 | - | Burst: 1, | 164 | + | |
| 100 | - | } | 165 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 166 | + | w.WriteHeader(http.StatusOK) | |||
| 167 | + | }) | |||
| 168 | + | ||||
| 169 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger()) | |||
| 170 | + | wrappedHandler := middleware(handler) | |||
| 171 | + | ||||
| 172 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 173 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 174 | + | rec := httptest.NewRecorder() | |||
| 175 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 176 | + | ||||
| 177 | + | // Verify response format | |||
| 178 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |||
| 179 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |||
| 180 | + | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |||
| 181 | + | assert.Equal(t, "1", rec.Header().Get("Retry-After")) | |||
| 182 | + | assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS") | |||
| 183 | + | } | |||
| 101 | 184 | ||||
| 102 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 185 | + | // Integration test using real MemoryStore implementation |
| 186 | + | func TestRateLimitMiddleware_Integration(t *testing.T) { | |||
| 187 | + | log := testLogger() | |||
| 188 | + | globalLimiter := ratelimit.NewMemoryStore(1000, 100, log) // High global limit | |||
| 189 | + | ipLimiter := ratelimit.NewMemoryStore(2, 2, log) // Low per-IP limit | |||
| 190 | + | ||||
| 191 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 103 | w.WriteHeader(http.StatusOK) | 192 | w.WriteHeader(http.StatusOK) | ||
| 104 | }) | 193 | }) | ||
| 105 | 194 | ||||
| 106 | - | middleware := RateLimitMiddleware(config) | 195 | + | middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, log) |
| 107 | wrappedHandler := middleware(handler) | 196 | wrappedHandler := middleware(handler) | ||
| 108 | 197 | ||||
| 109 | - | // First request succeeds | 198 | + | // First 2 requests from same IP should succeed (burst) |
| 110 | - | req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 199 | + | for i := 0; i < 2; i++ { |
| 111 | - | req1.RemoteAddr = "192.168.1.100:12345" | 200 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) |
| 112 | - | rec1 := httptest.NewRecorder() | 201 | + | req.RemoteAddr = "192.168.1.100:12345" |
| 113 | - | wrappedHandler.ServeHTTP(rec1, req1) | 202 | + | rec := httptest.NewRecorder() |
| 203 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 204 | + | assert.Equal(t, http.StatusOK, rec.Code, "request %d should succeed", i+1) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // 3rd request from same IP should be rate limited | |||
| 208 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 209 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 210 | + | rec := httptest.NewRecorder() | |||
| 211 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 212 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code, "3rd request should be rate limited") | |||
| 114 | 213 | ||||
| 115 | - | // Second request gets rate limited | 214 | + | // Request from different IP should still succeed (independent limit) |
| 116 | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | 215 | req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil) | ||
| 117 | - | req2.RemoteAddr = "192.168.1.100:12346" | 216 | + | req2.RemoteAddr = "192.168.1.101:12345" |
| 118 | rec2 := httptest.NewRecorder() | 217 | rec2 := httptest.NewRecorder() | ||
| 119 | wrappedHandler.ServeHTTP(rec2, req2) | 218 | wrappedHandler.ServeHTTP(rec2, req2) | ||
| 120 | - | 219 | + | assert.Equal(t, http.StatusOK, rec2.Code, "different IP should succeed") | |
| 121 | - | // Verify response format | |||
| 122 | - | assert.Equal(t, http.StatusTooManyRequests, rec2.Code) | |||
| 123 | - | assert.Equal(t, "application/json", rec2.Header().Get("Content-Type")) | |||
| 124 | - | assert.Equal(t, "registry/2.0", rec2.Header().Get("Docker-Distribution-API-Version")) | |||
| 125 | - | assert.Equal(t, "1", rec2.Header().Get("Retry-After")) | |||
| 126 | - | assert.Contains(t, rec2.Body.String(), "TOOMANYREQUESTS") | |||
| 127 | } | 220 | } | ||
| 128 | 221 | ||||
| 129 | func TestGetClientIP(t *testing.T) { | 222 | func TestGetClientIP(t *testing.T) { | ||
| 130 | // Parse trusted proxies for tests | 223 | // Parse trusted proxies for tests | ||
| 131 | - | trustedNets := parseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | 224 | + | trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) |
| 132 | noTrustedNets := []*net.IPNet{} | 225 | noTrustedNets := []*net.IPNet{} | ||
| 133 | 226 | ||||
| 134 | tests := []struct { | 227 | tests := []struct { | ||
| @@ -221,7 +314,7 @@ func TestGetClientIP(t *testing.T) { | |||||
| 221 | req.Header.Set("X-Real-IP", tt.xRealIP) | 314 | req.Header.Set("X-Real-IP", tt.xRealIP) | ||
| 222 | } | 315 | } | ||
| 223 | 316 | ||||
| 224 | - | ip := getClientIP(req, tt.trustedNets) | 317 | + | ip := middleware.GetClientIP(req, tt.trustedNets) |
| 225 | assert.Equal(t, tt.wantIP, ip) | 318 | assert.Equal(t, tt.wantIP, ip) | ||
| 226 | }) | 319 | }) | ||
| 227 | } | 320 | } | ||
| @@ -280,18 +373,9 @@ func TestParseTrustedProxies(t *testing.T) { | |||||
| 280 | 373 | ||||
| 281 | for _, tt := range tests { | 374 | for _, tt := range tests { | ||
| 282 | t.Run(tt.name, func(t *testing.T) { | 375 | t.Run(tt.name, func(t *testing.T) { | ||
| 283 | - | nets := parseTrustedProxies(tt.proxies) | 376 | + | nets := middleware.ParseTrustedProxies(tt.proxies) |
| 284 | - | got := isTrustedProxy(tt.testIP, nets) | 377 | + | got := middleware.IsTrustedProxy(tt.testIP, nets) |
| 285 | assert.Equal(t, tt.want, got) | 378 | assert.Equal(t, tt.want, got) | ||
| 286 | }) | 379 | }) | ||
| 287 | } | 380 | } | ||
| 288 | } | 381 | } | ||
| 289 | - | ||||
| 290 | - | func TestDefaultRateLimitConfig(t *testing.T) { | |||
| 291 | - | config := DefaultRateLimitConfig() | |||
| 292 | - | ||||
| 293 | - | require.True(t, config.Enabled) | |||
| 294 | - | assert.Equal(t, float64(500), config.GlobalRPS) | |||
| 295 | - | assert.Equal(t, float64(50), config.PerIPRPS) | |||
| 296 | - | assert.Equal(t, 100, config.Burst) | |||
| 297 | - | } | |||
D internal/adapters/in/http/registry/token_test.go +0 -192
| @@ -1,192 +0,0 @@ | |||
| 1 | - | package registry | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | "net/http/httptest" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/zerowrap" | |
| 9 | - | "github.com/stretchr/testify/assert" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func tokenTestLogger() zerowrap.Logger { | |
| 13 | - | return zerowrap.Default() | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) { | |
| 17 | - | log := tokenTestLogger() | |
| 18 | - | h := &TokenHandler{log: log} | |
| 19 | - | ||
| 20 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil) | |
| 21 | - | ||
| 22 | - | scopes := h.parseRequestedScopes(req, log) | |
| 23 | - | ||
| 24 | - | assert.Len(t, scopes, 2) | |
| 25 | - | assert.Contains(t, scopes, "repository:repo1:pull") | |
| 26 | - | assert.Contains(t, scopes, "repository:repo2:push") | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) { | |
| 30 | - | log := tokenTestLogger() | |
| 31 | - | h := &TokenHandler{log: log} | |
| 32 | - | ||
| 33 | - | tests := []struct { | |
| 34 | - | name string | |
| 35 | - | query string | |
| 36 | - | wantScopes []string | |
| 37 | - | wantDefault bool | |
| 38 | - | }{ | |
| 39 | - | { | |
| 40 | - | name: "invalid format is skipped", | |
| 41 | - | query: "scope=invalid-no-colons&scope=repository:valid:pull", | |
| 42 | - | wantScopes: []string{"repository:valid:pull"}, | |
| 43 | - | wantDefault: false, | |
| 44 | - | }, | |
| 45 | - | { | |
| 46 | - | name: "missing actions is skipped", | |
| 47 | - | query: "scope=repository:myrepo&scope=repository:valid:pull", | |
| 48 | - | wantScopes: []string{"repository:valid:pull"}, | |
| 49 | - | wantDefault: false, | |
| 50 | - | }, | |
| 51 | - | { | |
| 52 | - | name: "all invalid returns default", | |
| 53 | - | query: "scope=invalid&scope=also-invalid", | |
| 54 | - | wantScopes: []string{"repository:*:pull"}, | |
| 55 | - | wantDefault: true, | |
| 56 | - | }, | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | for _, tt := range tests { | |
| 60 | - | t.Run(tt.name, func(t *testing.T) { | |
| 61 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |
| 62 | - | scopes := h.parseRequestedScopes(req, log) | |
| 63 | - | ||
| 64 | - | assert.Equal(t, tt.wantScopes, scopes) | |
| 65 | - | }) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) { | |
| 70 | - | log := tokenTestLogger() | |
| 71 | - | h := &TokenHandler{log: log} | |
| 72 | - | ||
| 73 | - | tests := []struct { | |
| 74 | - | name string | |
| 75 | - | query string | |
| 76 | - | wantScopes []string | |
| 77 | - | }{ | |
| 78 | - | { | |
| 79 | - | name: "registry scope is rejected", | |
| 80 | - | query: "scope=registry:catalog:*&scope=repository:myrepo:pull", | |
| 81 | - | wantScopes: []string{"repository:myrepo:pull"}, | |
| 82 | - | }, | |
| 83 | - | { | |
| 84 | - | name: "only non-repository scopes returns default", | |
| 85 | - | query: "scope=registry:catalog:*&scope=custom:something:action", | |
| 86 | - | wantScopes: []string{"repository:*:pull"}, | |
| 87 | - | }, | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | for _, tt := range tests { | |
| 91 | - | t.Run(tt.name, func(t *testing.T) { | |
| 92 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |
| 93 | - | scopes := h.parseRequestedScopes(req, log) | |
| 94 | - | ||
| 95 | - | assert.Equal(t, tt.wantScopes, scopes) | |
| 96 | - | }) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) { | |
| 101 | - | log := tokenTestLogger() | |
| 102 | - | h := &TokenHandler{log: log} | |
| 103 | - | ||
| 104 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) | |
| 105 | - | scopes := h.parseRequestedScopes(req, log) | |
| 106 | - | ||
| 107 | - | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) { | |
| 111 | - | log := tokenTestLogger() | |
| 112 | - | h := &TokenHandler{log: log} | |
| 113 | - | ||
| 114 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil) | |
| 115 | - | scopes := h.parseRequestedScopes(req, log) | |
| 116 | - | ||
| 117 | - | // Empty scope param is invalid and should return default | |
| 118 | - | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) { | |
| 122 | - | log := tokenTestLogger() | |
| 123 | - | h := &TokenHandler{log: log} | |
| 124 | - | ||
| 125 | - | tests := []struct { | |
| 126 | - | name string | |
| 127 | - | query string | |
| 128 | - | wantScopes []string | |
| 129 | - | }{ | |
| 130 | - | { | |
| 131 | - | name: "single scope", | |
| 132 | - | query: "scope=repository:myrepo:pull", | |
| 133 | - | wantScopes: []string{"repository:myrepo:pull"}, | |
| 134 | - | }, | |
| 135 | - | { | |
| 136 | - | name: "multiple actions", | |
| 137 | - | query: "scope=repository:myrepo:push,pull", | |
| 138 | - | wantScopes: []string{"repository:myrepo:push,pull"}, | |
| 139 | - | }, | |
| 140 | - | { | |
| 141 | - | name: "wildcard repo", | |
| 142 | - | query: "scope=repository:*:pull", | |
| 143 | - | wantScopes: []string{"repository:*:pull"}, | |
| 144 | - | }, | |
| 145 | - | { | |
| 146 | - | name: "org wildcard", | |
| 147 | - | query: "scope=repository:myorg/*:push,pull", | |
| 148 | - | wantScopes: []string{"repository:myorg/*:push,pull"}, | |
| 149 | - | }, | |
| 150 | - | { | |
| 151 | - | name: "nested repo", | |
| 152 | - | query: "scope=repository:myorg/team/app:pull", | |
| 153 | - | wantScopes: []string{"repository:myorg/team/app:pull"}, | |
| 154 | - | }, | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | for _, tt := range tests { | |
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |
| 159 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |
| 160 | - | scopes := h.parseRequestedScopes(req, log) | |
| 161 | - | ||
| 162 | - | assert.Equal(t, tt.wantScopes, scopes) | |
| 163 | - | }) | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | func TestIsLocalhostRequest(t *testing.T) { | |
| 168 | - | tests := []struct { | |
| 169 | - | name string | |
| 170 | - | remoteAddr string | |
| 171 | - | want bool | |
| 172 | - | }{ | |
| 173 | - | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | |
| 174 | - | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | |
| 175 | - | {"::1 is localhost", "[::1]:12345", true}, | |
| 176 | - | {"::1 without brackets", "::1:12345", true}, | |
| 177 | - | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | |
| 178 | - | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | |
| 179 | - | {"public IP is not localhost", "8.8.8.8:12345", false}, | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | for _, tt := range tests { | |
| 183 | - | t.Run(tt.name, func(t *testing.T) { | |
| 184 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) | |
| 185 | - | req.RemoteAddr = tt.remoteAddr | |
| 186 | - | ||
| 187 | - | result := isLocalhostRequest(req) | |
| 188 | - | ||
| 189 | - | assert.Equal(t, tt.want, result) | |
| 190 | - | }) | |
| 191 | - | } | |
| 192 | - | } | |
| @@ -1,192 +0,0 @@ | |||||
| 1 | - | package registry | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "net/http" | |||
| 5 | - | "net/http/httptest" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/zerowrap" | |||
| 9 | - | "github.com/stretchr/testify/assert" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | func tokenTestLogger() zerowrap.Logger { | |||
| 13 | - | return zerowrap.Default() | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) { | |||
| 17 | - | log := tokenTestLogger() | |||
| 18 | - | h := &TokenHandler{log: log} | |||
| 19 | - | ||||
| 20 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil) | |||
| 21 | - | ||||
| 22 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 23 | - | ||||
| 24 | - | assert.Len(t, scopes, 2) | |||
| 25 | - | assert.Contains(t, scopes, "repository:repo1:pull") | |||
| 26 | - | assert.Contains(t, scopes, "repository:repo2:push") | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) { | |||
| 30 | - | log := tokenTestLogger() | |||
| 31 | - | h := &TokenHandler{log: log} | |||
| 32 | - | ||||
| 33 | - | tests := []struct { | |||
| 34 | - | name string | |||
| 35 | - | query string | |||
| 36 | - | wantScopes []string | |||
| 37 | - | wantDefault bool | |||
| 38 | - | }{ | |||
| 39 | - | { | |||
| 40 | - | name: "invalid format is skipped", | |||
| 41 | - | query: "scope=invalid-no-colons&scope=repository:valid:pull", | |||
| 42 | - | wantScopes: []string{"repository:valid:pull"}, | |||
| 43 | - | wantDefault: false, | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "missing actions is skipped", | |||
| 47 | - | query: "scope=repository:myrepo&scope=repository:valid:pull", | |||
| 48 | - | wantScopes: []string{"repository:valid:pull"}, | |||
| 49 | - | wantDefault: false, | |||
| 50 | - | }, | |||
| 51 | - | { | |||
| 52 | - | name: "all invalid returns default", | |||
| 53 | - | query: "scope=invalid&scope=also-invalid", | |||
| 54 | - | wantScopes: []string{"repository:*:pull"}, | |||
| 55 | - | wantDefault: true, | |||
| 56 | - | }, | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | for _, tt := range tests { | |||
| 60 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 61 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |||
| 62 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 63 | - | ||||
| 64 | - | assert.Equal(t, tt.wantScopes, scopes) | |||
| 65 | - | }) | |||
| 66 | - | } | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) { | |||
| 70 | - | log := tokenTestLogger() | |||
| 71 | - | h := &TokenHandler{log: log} | |||
| 72 | - | ||||
| 73 | - | tests := []struct { | |||
| 74 | - | name string | |||
| 75 | - | query string | |||
| 76 | - | wantScopes []string | |||
| 77 | - | }{ | |||
| 78 | - | { | |||
| 79 | - | name: "registry scope is rejected", | |||
| 80 | - | query: "scope=registry:catalog:*&scope=repository:myrepo:pull", | |||
| 81 | - | wantScopes: []string{"repository:myrepo:pull"}, | |||
| 82 | - | }, | |||
| 83 | - | { | |||
| 84 | - | name: "only non-repository scopes returns default", | |||
| 85 | - | query: "scope=registry:catalog:*&scope=custom:something:action", | |||
| 86 | - | wantScopes: []string{"repository:*:pull"}, | |||
| 87 | - | }, | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | for _, tt := range tests { | |||
| 91 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 92 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |||
| 93 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 94 | - | ||||
| 95 | - | assert.Equal(t, tt.wantScopes, scopes) | |||
| 96 | - | }) | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) { | |||
| 101 | - | log := tokenTestLogger() | |||
| 102 | - | h := &TokenHandler{log: log} | |||
| 103 | - | ||||
| 104 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) | |||
| 105 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 106 | - | ||||
| 107 | - | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) { | |||
| 111 | - | log := tokenTestLogger() | |||
| 112 | - | h := &TokenHandler{log: log} | |||
| 113 | - | ||||
| 114 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil) | |||
| 115 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 116 | - | ||||
| 117 | - | // Empty scope param is invalid and should return default | |||
| 118 | - | assert.Equal(t, []string{"repository:*:pull"}, scopes) | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) { | |||
| 122 | - | log := tokenTestLogger() | |||
| 123 | - | h := &TokenHandler{log: log} | |||
| 124 | - | ||||
| 125 | - | tests := []struct { | |||
| 126 | - | name string | |||
| 127 | - | query string | |||
| 128 | - | wantScopes []string | |||
| 129 | - | }{ | |||
| 130 | - | { | |||
| 131 | - | name: "single scope", | |||
| 132 | - | query: "scope=repository:myrepo:pull", | |||
| 133 | - | wantScopes: []string{"repository:myrepo:pull"}, | |||
| 134 | - | }, | |||
| 135 | - | { | |||
| 136 | - | name: "multiple actions", | |||
| 137 | - | query: "scope=repository:myrepo:push,pull", | |||
| 138 | - | wantScopes: []string{"repository:myrepo:push,pull"}, | |||
| 139 | - | }, | |||
| 140 | - | { | |||
| 141 | - | name: "wildcard repo", | |||
| 142 | - | query: "scope=repository:*:pull", | |||
| 143 | - | wantScopes: []string{"repository:*:pull"}, | |||
| 144 | - | }, | |||
| 145 | - | { | |||
| 146 | - | name: "org wildcard", | |||
| 147 | - | query: "scope=repository:myorg/*:push,pull", | |||
| 148 | - | wantScopes: []string{"repository:myorg/*:push,pull"}, | |||
| 149 | - | }, | |||
| 150 | - | { | |||
| 151 | - | name: "nested repo", | |||
| 152 | - | query: "scope=repository:myorg/team/app:pull", | |||
| 153 | - | wantScopes: []string{"repository:myorg/team/app:pull"}, | |||
| 154 | - | }, | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | for _, tt := range tests { | |||
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil) | |||
| 160 | - | scopes := h.parseRequestedScopes(req, log) | |||
| 161 | - | ||||
| 162 | - | assert.Equal(t, tt.wantScopes, scopes) | |||
| 163 | - | }) | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func TestIsLocalhostRequest(t *testing.T) { | |||
| 168 | - | tests := []struct { | |||
| 169 | - | name string | |||
| 170 | - | remoteAddr string | |||
| 171 | - | want bool | |||
| 172 | - | }{ | |||
| 173 | - | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | |||
| 174 | - | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | |||
| 175 | - | {"::1 is localhost", "[::1]:12345", true}, | |||
| 176 | - | {"::1 without brackets", "::1:12345", true}, | |||
| 177 | - | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | |||
| 178 | - | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | |||
| 179 | - | {"public IP is not localhost", "8.8.8.8:12345", false}, | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | for _, tt := range tests { | |||
| 183 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 184 | - | req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) | |||
| 185 | - | req.RemoteAddr = tt.remoteAddr | |||
| 186 | - | ||||
| 187 | - | result := isLocalhostRequest(req) | |||
| 188 | - | ||||
| 189 | - | assert.Equal(t, tt.want, result) | |||
| 190 | - | }) | |||
| 191 | - | } | |||
| 192 | - | } | |||
A internal/adapters/out/ratelimit/memory.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | // Package ratelimit provides rate limiter implementations. | |
| 2 | + | package ratelimit | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "golang.org/x/time/rate" | |
| 11 | + | ||
| 12 | + | "gordon/internal/boundaries/out" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Ensure MemoryStore implements out.RateLimiter. | |
| 16 | + | var _ out.RateLimiter = (*MemoryStore)(nil) | |
| 17 | + | ||
| 18 | + | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |
| 19 | + | // Each unique key gets its own independent rate limiter. | |
| 20 | + | type MemoryStore struct { | |
| 21 | + | limiters map[string]*rate.Limiter | |
| 22 | + | mu sync.RWMutex | |
| 23 | + | rps float64 | |
| 24 | + | burst int | |
| 25 | + | log zerowrap.Logger | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewMemoryStore creates a new in-memory rate limiter store. | |
| 29 | + | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |
| 30 | + | return &MemoryStore{ | |
| 31 | + | limiters: make(map[string]*rate.Limiter), | |
| 32 | + | rps: rps, | |
| 33 | + | burst: burst, | |
| 34 | + | log: log, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Allow checks if a request identified by key is allowed. | |
| 39 | + | // Returns true if allowed, false if rate limited. | |
| 40 | + | func (s *MemoryStore) Allow(_ context.Context, key string) bool { | |
| 41 | + | return s.getLimiter(key).Allow() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // AllowN checks if n requests identified by key are allowed. | |
| 45 | + | func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |
| 46 | + | return s.getLimiter(key).AllowN(time.Now(), n) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |
| 50 | + | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | |
| 51 | + | s.mu.RLock() | |
| 52 | + | limiter, exists := s.limiters[key] | |
| 53 | + | s.mu.RUnlock() | |
| 54 | + | ||
| 55 | + | if exists { | |
| 56 | + | return limiter | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | s.mu.Lock() | |
| 60 | + | defer s.mu.Unlock() | |
| 61 | + | ||
| 62 | + | // Double-check after acquiring write lock | |
| 63 | + | if limiter, exists = s.limiters[key]; exists { | |
| 64 | + | return limiter | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 68 | + | s.limiters[key] = limiter | |
| 69 | + | return limiter | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | // Package ratelimit provides rate limiter implementations. | |||
| 2 | + | package ratelimit | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "sync" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "golang.org/x/time/rate" | |||
| 11 | + | ||||
| 12 | + | "gordon/internal/boundaries/out" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Ensure MemoryStore implements out.RateLimiter. | |||
| 16 | + | var _ out.RateLimiter = (*MemoryStore)(nil) | |||
| 17 | + | ||||
| 18 | + | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |||
| 19 | + | // Each unique key gets its own independent rate limiter. | |||
| 20 | + | type MemoryStore struct { | |||
| 21 | + | limiters map[string]*rate.Limiter | |||
| 22 | + | mu sync.RWMutex | |||
| 23 | + | rps float64 | |||
| 24 | + | burst int | |||
| 25 | + | log zerowrap.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewMemoryStore creates a new in-memory rate limiter store. | |||
| 29 | + | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |||
| 30 | + | return &MemoryStore{ | |||
| 31 | + | limiters: make(map[string]*rate.Limiter), | |||
| 32 | + | rps: rps, | |||
| 33 | + | burst: burst, | |||
| 34 | + | log: log, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Allow checks if a request identified by key is allowed. | |||
| 39 | + | // Returns true if allowed, false if rate limited. | |||
| 40 | + | func (s *MemoryStore) Allow(_ context.Context, key string) bool { | |||
| 41 | + | return s.getLimiter(key).Allow() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // AllowN checks if n requests identified by key are allowed. | |||
| 45 | + | func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||
| 46 | + | return s.getLimiter(key).AllowN(time.Now(), n) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |||
| 50 | + | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | |||
| 51 | + | s.mu.RLock() | |||
| 52 | + | limiter, exists := s.limiters[key] | |||
| 53 | + | s.mu.RUnlock() | |||
| 54 | + | ||||
| 55 | + | if exists { | |||
| 56 | + | return limiter | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | s.mu.Lock() | |||
| 60 | + | defer s.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | // Double-check after acquiring write lock | |||
| 63 | + | if limiter, exists = s.limiters[key]; exists { | |||
| 64 | + | return limiter | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |||
| 68 | + | s.limiters[key] = limiter | |||
| 69 | + | return limiter | |||
| 70 | + | } | |||
A internal/adapters/out/ratelimit/memory_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testLogger() zerowrap.Logger { | |
| 15 | + | return zerowrap.Default() | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestMemoryStore_Allow_WithinLimit(t *testing.T) { | |
| 19 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |
| 20 | + | ctx := context.Background() | |
| 21 | + | ||
| 22 | + | // Should allow requests up to burst | |
| 23 | + | for i := 0; i < 10; i++ { | |
| 24 | + | assert.True(t, store.Allow(ctx, "test"), "request %d should be allowed", i+1) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestMemoryStore_Allow_ExceedsLimit(t *testing.T) { | |
| 29 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |
| 30 | + | ctx := context.Background() | |
| 31 | + | ||
| 32 | + | // First request should succeed (consumes the burst) | |
| 33 | + | assert.True(t, store.Allow(ctx, "test"), "first request should be allowed") | |
| 34 | + | ||
| 35 | + | // Second request should be rate limited | |
| 36 | + | assert.False(t, store.Allow(ctx, "test"), "second request should be rate limited") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestMemoryStore_Allow_BurstBehavior(t *testing.T) { | |
| 40 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |
| 41 | + | ctx := context.Background() | |
| 42 | + | ||
| 43 | + | // Should allow burst of 5 | |
| 44 | + | for i := 0; i < 5; i++ { | |
| 45 | + | assert.True(t, store.Allow(ctx, "test"), "burst request %d should be allowed", i+1) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // 6th request should be rate limited (burst exhausted) | |
| 49 | + | assert.False(t, store.Allow(ctx, "test"), "request exceeding burst should be rate limited") | |
| 50 | + | ||
| 51 | + | // Wait for token replenishment | |
| 52 | + | time.Sleep(200 * time.Millisecond) | |
| 53 | + | ||
| 54 | + | // Should be able to make at least one request after waiting | |
| 55 | + | assert.True(t, store.Allow(ctx, "test"), "request after waiting should be allowed") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMemoryStore_Allow_IndependentKeys(t *testing.T) { | |
| 59 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |
| 60 | + | ctx := context.Background() | |
| 61 | + | ||
| 62 | + | // First request for key1 should succeed | |
| 63 | + | assert.True(t, store.Allow(ctx, "key1"), "first request for key1 should be allowed") | |
| 64 | + | ||
| 65 | + | // Key1 is now rate limited | |
| 66 | + | assert.False(t, store.Allow(ctx, "key1"), "second request for key1 should be rate limited") | |
| 67 | + | ||
| 68 | + | // Key2 should have its own independent limit | |
| 69 | + | assert.True(t, store.Allow(ctx, "key2"), "first request for key2 should be allowed") | |
| 70 | + | ||
| 71 | + | // Key2 is now rate limited | |
| 72 | + | assert.False(t, store.Allow(ctx, "key2"), "second request for key2 should be rate limited") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestMemoryStore_AllowN_Basic(t *testing.T) { | |
| 76 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |
| 77 | + | ctx := context.Background() | |
| 78 | + | ||
| 79 | + | // Should allow 5 tokens at once | |
| 80 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with burst of 10") | |
| 81 | + | ||
| 82 | + | // Should allow another 5 | |
| 83 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with 5 remaining") | |
| 84 | + | ||
| 85 | + | // Should not allow more | |
| 86 | + | assert.False(t, store.AllowN(ctx, "test", 1), "AllowN(1) should fail with 0 remaining") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestMemoryStore_AllowN_ExceedsBurst(t *testing.T) { | |
| 90 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |
| 91 | + | ctx := context.Background() | |
| 92 | + | ||
| 93 | + | // Should not allow more than burst | |
| 94 | + | assert.False(t, store.AllowN(ctx, "test", 10), "AllowN(10) should fail with burst of 5") | |
| 95 | + | ||
| 96 | + | // But smaller request should work | |
| 97 | + | assert.True(t, store.AllowN(ctx, "test", 3), "AllowN(3) should succeed with burst of 5") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestMemoryStore_Allow_ConcurrentAccess(t *testing.T) { | |
| 101 | + | store := NewMemoryStore(1000, 100, testLogger()) // High RPS for concurrent testing | |
| 102 | + | ctx := context.Background() | |
| 103 | + | ||
| 104 | + | var wg sync.WaitGroup | |
| 105 | + | results := make(chan bool, 200) | |
| 106 | + | ||
| 107 | + | // Run 20 goroutines, each making 10 requests | |
| 108 | + | for i := 0; i < 20; i++ { | |
| 109 | + | wg.Add(1) | |
| 110 | + | go func() { | |
| 111 | + | defer wg.Done() | |
| 112 | + | for j := 0; j < 10; j++ { | |
| 113 | + | results <- store.Allow(ctx, "concurrent") | |
| 114 | + | } | |
| 115 | + | }() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | wg.Wait() | |
| 119 | + | close(results) | |
| 120 | + | ||
| 121 | + | // Count successful requests | |
| 122 | + | allowed := 0 | |
| 123 | + | for result := range results { | |
| 124 | + | if result { | |
| 125 | + | allowed++ | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // At least burst number should be allowed | |
| 130 | + | require.GreaterOrEqual(t, allowed, 100, "at least burst number of requests should be allowed") | |
| 131 | + | // And we shouldn't exceed total requests | |
| 132 | + | require.LessOrEqual(t, allowed, 200, "should not exceed total requests") | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestMemoryStore_IPKeyFormat(t *testing.T) { | |
| 136 | + | store := NewMemoryStore(1, 1, testLogger()) | |
| 137 | + | ctx := context.Background() | |
| 138 | + | ||
| 139 | + | // Test typical IP key formats | |
| 140 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.1"), "should allow first request for IP") | |
| 141 | + | assert.False(t, store.Allow(ctx, "ip:192.168.1.1"), "should rate limit same IP") | |
| 142 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.2"), "should allow first request for different IP") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestMemoryStore_GlobalKey(t *testing.T) { | |
| 146 | + | store := NewMemoryStore(1, 1, testLogger()) | |
| 147 | + | ctx := context.Background() | |
| 148 | + | ||
| 149 | + | // Test global key | |
| 150 | + | assert.True(t, store.Allow(ctx, "global"), "should allow first global request") | |
| 151 | + | assert.False(t, store.Allow(ctx, "global"), "should rate limit global requests") | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func testLogger() zerowrap.Logger { | |||
| 15 | + | return zerowrap.Default() | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestMemoryStore_Allow_WithinLimit(t *testing.T) { | |||
| 19 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |||
| 20 | + | ctx := context.Background() | |||
| 21 | + | ||||
| 22 | + | // Should allow requests up to burst | |||
| 23 | + | for i := 0; i < 10; i++ { | |||
| 24 | + | assert.True(t, store.Allow(ctx, "test"), "request %d should be allowed", i+1) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestMemoryStore_Allow_ExceedsLimit(t *testing.T) { | |||
| 29 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |||
| 30 | + | ctx := context.Background() | |||
| 31 | + | ||||
| 32 | + | // First request should succeed (consumes the burst) | |||
| 33 | + | assert.True(t, store.Allow(ctx, "test"), "first request should be allowed") | |||
| 34 | + | ||||
| 35 | + | // Second request should be rate limited | |||
| 36 | + | assert.False(t, store.Allow(ctx, "test"), "second request should be rate limited") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestMemoryStore_Allow_BurstBehavior(t *testing.T) { | |||
| 40 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |||
| 41 | + | ctx := context.Background() | |||
| 42 | + | ||||
| 43 | + | // Should allow burst of 5 | |||
| 44 | + | for i := 0; i < 5; i++ { | |||
| 45 | + | assert.True(t, store.Allow(ctx, "test"), "burst request %d should be allowed", i+1) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // 6th request should be rate limited (burst exhausted) | |||
| 49 | + | assert.False(t, store.Allow(ctx, "test"), "request exceeding burst should be rate limited") | |||
| 50 | + | ||||
| 51 | + | // Wait for token replenishment | |||
| 52 | + | time.Sleep(200 * time.Millisecond) | |||
| 53 | + | ||||
| 54 | + | // Should be able to make at least one request after waiting | |||
| 55 | + | assert.True(t, store.Allow(ctx, "test"), "request after waiting should be allowed") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestMemoryStore_Allow_IndependentKeys(t *testing.T) { | |||
| 59 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |||
| 60 | + | ctx := context.Background() | |||
| 61 | + | ||||
| 62 | + | // First request for key1 should succeed | |||
| 63 | + | assert.True(t, store.Allow(ctx, "key1"), "first request for key1 should be allowed") | |||
| 64 | + | ||||
| 65 | + | // Key1 is now rate limited | |||
| 66 | + | assert.False(t, store.Allow(ctx, "key1"), "second request for key1 should be rate limited") | |||
| 67 | + | ||||
| 68 | + | // Key2 should have its own independent limit | |||
| 69 | + | assert.True(t, store.Allow(ctx, "key2"), "first request for key2 should be allowed") | |||
| 70 | + | ||||
| 71 | + | // Key2 is now rate limited | |||
| 72 | + | assert.False(t, store.Allow(ctx, "key2"), "second request for key2 should be rate limited") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestMemoryStore_AllowN_Basic(t *testing.T) { | |||
| 76 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |||
| 77 | + | ctx := context.Background() | |||
| 78 | + | ||||
| 79 | + | // Should allow 5 tokens at once | |||
| 80 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with burst of 10") | |||
| 81 | + | ||||
| 82 | + | // Should allow another 5 | |||
| 83 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with 5 remaining") | |||
| 84 | + | ||||
| 85 | + | // Should not allow more | |||
| 86 | + | assert.False(t, store.AllowN(ctx, "test", 1), "AllowN(1) should fail with 0 remaining") | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestMemoryStore_AllowN_ExceedsBurst(t *testing.T) { | |||
| 90 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |||
| 91 | + | ctx := context.Background() | |||
| 92 | + | ||||
| 93 | + | // Should not allow more than burst | |||
| 94 | + | assert.False(t, store.AllowN(ctx, "test", 10), "AllowN(10) should fail with burst of 5") | |||
| 95 | + | ||||
| 96 | + | // But smaller request should work | |||
| 97 | + | assert.True(t, store.AllowN(ctx, "test", 3), "AllowN(3) should succeed with burst of 5") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestMemoryStore_Allow_ConcurrentAccess(t *testing.T) { | |||
| 101 | + | store := NewMemoryStore(1000, 100, testLogger()) // High RPS for concurrent testing | |||
| 102 | + | ctx := context.Background() | |||
| 103 | + | ||||
| 104 | + | var wg sync.WaitGroup | |||
| 105 | + | results := make(chan bool, 200) | |||
| 106 | + | ||||
| 107 | + | // Run 20 goroutines, each making 10 requests | |||
| 108 | + | for i := 0; i < 20; i++ { | |||
| 109 | + | wg.Add(1) | |||
| 110 | + | go func() { | |||
| 111 | + | defer wg.Done() | |||
| 112 | + | for j := 0; j < 10; j++ { | |||
| 113 | + | results <- store.Allow(ctx, "concurrent") | |||
| 114 | + | } | |||
| 115 | + | }() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | wg.Wait() | |||
| 119 | + | close(results) | |||
| 120 | + | ||||
| 121 | + | // Count successful requests | |||
| 122 | + | allowed := 0 | |||
| 123 | + | for result := range results { | |||
| 124 | + | if result { | |||
| 125 | + | allowed++ | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // At least burst number should be allowed | |||
| 130 | + | require.GreaterOrEqual(t, allowed, 100, "at least burst number of requests should be allowed") | |||
| 131 | + | // And we shouldn't exceed total requests | |||
| 132 | + | require.LessOrEqual(t, allowed, 200, "should not exceed total requests") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestMemoryStore_IPKeyFormat(t *testing.T) { | |||
| 136 | + | store := NewMemoryStore(1, 1, testLogger()) | |||
| 137 | + | ctx := context.Background() | |||
| 138 | + | ||||
| 139 | + | // Test typical IP key formats | |||
| 140 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.1"), "should allow first request for IP") | |||
| 141 | + | assert.False(t, store.Allow(ctx, "ip:192.168.1.1"), "should rate limit same IP") | |||
| 142 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.2"), "should allow first request for different IP") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestMemoryStore_GlobalKey(t *testing.T) { | |||
| 146 | + | store := NewMemoryStore(1, 1, testLogger()) | |||
| 147 | + | ctx := context.Background() | |||
| 148 | + | ||||
| 149 | + | // Test global key | |||
| 150 | + | assert.True(t, store.Allow(ctx, "global"), "should allow first global request") | |||
| 151 | + | assert.False(t, store.Allow(ctx, "global"), "should rate limit global requests") | |||
| 152 | + | } | |||
A internal/adapters/out/ratelimit/store.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | ||
| 8 | + | "gordon/internal/boundaries/out" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewStore creates a RateLimiter based on the configured backend. | |
| 12 | + | func NewStore(backend string, rps float64, burst int, log zerowrap.Logger) (out.RateLimiter, error) { | |
| 13 | + | switch backend { | |
| 14 | + | case "memory", "": | |
| 15 | + | return NewMemoryStore(rps, burst, log), nil | |
| 16 | + | case "redis": | |
| 17 | + | return nil, fmt.Errorf("redis backend not yet implemented") | |
| 18 | + | default: | |
| 19 | + | return nil, fmt.Errorf("unknown rate limit backend: %s", backend) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/boundaries/out" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewStore creates a RateLimiter based on the configured backend. | |||
| 12 | + | func NewStore(backend string, rps float64, burst int, log zerowrap.Logger) (out.RateLimiter, error) { | |||
| 13 | + | switch backend { | |||
| 14 | + | case "memory", "": | |||
| 15 | + | return NewMemoryStore(rps, burst, log), nil | |||
| 16 | + | case "redis": | |||
| 17 | + | return nil, fmt.Errorf("redis backend not yet implemented") | |||
| 18 | + | default: | |||
| 19 | + | return nil, fmt.Errorf("unknown rate limit backend: %s", backend) | |||
| 20 | + | } | |||
| 21 | + | } | |||
A internal/adapters/out/ratelimit/store_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNewStore_MemoryBackend(t *testing.T) { | |
| 11 | + | store, err := NewStore("memory", 10, 5, testLogger()) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | require.NotNil(t, store) | |
| 14 | + | ||
| 15 | + | // Verify it's a MemoryStore | |
| 16 | + | _, ok := store.(*MemoryStore) | |
| 17 | + | assert.True(t, ok, "should create a MemoryStore") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestNewStore_EmptyBackend(t *testing.T) { | |
| 21 | + | store, err := NewStore("", 10, 5, testLogger()) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.NotNil(t, store) | |
| 24 | + | ||
| 25 | + | // Empty backend should default to memory | |
| 26 | + | _, ok := store.(*MemoryStore) | |
| 27 | + | assert.True(t, ok, "empty backend should create a MemoryStore") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestNewStore_RedisBackend(t *testing.T) { | |
| 31 | + | store, err := NewStore("redis", 10, 5, testLogger()) | |
| 32 | + | assert.Error(t, err) | |
| 33 | + | assert.Nil(t, store) | |
| 34 | + | assert.Contains(t, err.Error(), "not yet implemented") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestNewStore_UnknownBackend(t *testing.T) { | |
| 38 | + | store, err := NewStore("postgres", 10, 5, testLogger()) | |
| 39 | + | assert.Error(t, err) | |
| 40 | + | assert.Nil(t, store) | |
| 41 | + | assert.Contains(t, err.Error(), "unknown rate limit backend") | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNewStore_MemoryBackend(t *testing.T) { | |||
| 11 | + | store, err := NewStore("memory", 10, 5, testLogger()) | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | require.NotNil(t, store) | |||
| 14 | + | ||||
| 15 | + | // Verify it's a MemoryStore | |||
| 16 | + | _, ok := store.(*MemoryStore) | |||
| 17 | + | assert.True(t, ok, "should create a MemoryStore") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestNewStore_EmptyBackend(t *testing.T) { | |||
| 21 | + | store, err := NewStore("", 10, 5, testLogger()) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.NotNil(t, store) | |||
| 24 | + | ||||
| 25 | + | // Empty backend should default to memory | |||
| 26 | + | _, ok := store.(*MemoryStore) | |||
| 27 | + | assert.True(t, ok, "empty backend should create a MemoryStore") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestNewStore_RedisBackend(t *testing.T) { | |||
| 31 | + | store, err := NewStore("redis", 10, 5, testLogger()) | |||
| 32 | + | assert.Error(t, err) | |||
| 33 | + | assert.Nil(t, store) | |||
| 34 | + | assert.Contains(t, err.Error(), "not yet implemented") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewStore_UnknownBackend(t *testing.T) { | |||
| 38 | + | store, err := NewStore("postgres", 10, 5, testLogger()) | |||
| 39 | + | assert.Error(t, err) | |||
| 40 | + | assert.Nil(t, store) | |||
| 41 | + | assert.Contains(t, err.Error(), "unknown rate limit backend") | |||
| 42 | + | } | |||
M internal/app/run.go +75 -47
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "encoding/json" | |
| 9 | 9 | "fmt" | |
| 10 | + | "net" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "os" | |
| 12 | 13 | "os/signal" | |
| @@ -27,13 +28,13 @@ import ( | |||
| 27 | 28 | "gordon/internal/adapters/out/filesystem" | |
| 28 | 29 | "gordon/internal/adapters/out/httpprober" | |
| 29 | 30 | "gordon/internal/adapters/out/logwriter" | |
| 31 | + | "gordon/internal/adapters/out/ratelimit" | |
| 30 | 32 | "gordon/internal/adapters/out/secrets" | |
| 31 | 33 | "gordon/internal/adapters/out/tokenstore" | |
| 32 | 34 | ||
| 33 | - | "golang.org/x/time/rate" | |
| 34 | - | ||
| 35 | 35 | // Adapters - Input | |
| 36 | 36 | "gordon/internal/adapters/in/http/admin" | |
| 37 | + | authhandler "gordon/internal/adapters/in/http/auth" | |
| 37 | 38 | "gordon/internal/adapters/in/http/middleware" | |
| 38 | 39 | "gordon/internal/adapters/in/http/registry" | |
| 39 | 40 | ||
| @@ -126,7 +127,7 @@ type services struct { | |||
| 126 | 127 | registrySvc *registrySvc.Service | |
| 127 | 128 | proxySvc *proxy.Service | |
| 128 | 129 | authSvc *auth.Service | |
| 129 | - | tokenHandler *registry.TokenHandler | |
| 130 | + | authHandler *authhandler.Handler | |
| 130 | 131 | adminHandler *admin.Handler | |
| 131 | 132 | internalRegUser string | |
| 132 | 133 | internalRegPass string | |
| @@ -300,11 +301,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 300 | 301 | ||
| 301 | 302 | // Create token handler for registry token endpoint | |
| 302 | 303 | if svc.authSvc != nil { | |
| 303 | - | internalAuth := registry.InternalAuth{ | |
| 304 | + | internalAuth := authhandler.InternalAuth{ | |
| 304 | 305 | Username: svc.internalRegUser, | |
| 305 | 306 | Password: svc.internalRegPass, | |
| 306 | 307 | } | |
| 307 | - | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log) | |
| 308 | + | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | |
| 308 | 309 | } | |
| 309 | 310 | ||
| 310 | 311 | // Determine env directory for admin API | |
| @@ -567,11 +568,11 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 567 | 568 | return nil, nil, nil | |
| 568 | 569 | } | |
| 569 | 570 | ||
| 570 | - | authType := resolveAuthType(cfg.Auth.Type) | |
| 571 | + | authType := resolveAuthType(cfg) | |
| 571 | 572 | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |
| 572 | 573 | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 573 | 574 | ||
| 574 | - | store, err := createTokenStore(authType, backend, dataDir, log) | |
| 575 | + | store, err := createTokenStore(backend, dataDir, log) | |
| 575 | 576 | if err != nil { | |
| 576 | 577 | return nil, nil, err | |
| 577 | 578 | } | |
| @@ -591,11 +592,26 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 591 | 592 | return store, authSvc, nil | |
| 592 | 593 | } | |
| 593 | 594 | ||
| 594 | - | func resolveAuthType(authType string) domain.AuthType { | |
| 595 | - | if authType == "token" { | |
| 595 | + | // resolveAuthType determines the auth type from config. | |
| 596 | + | // If password_hash is configured, password auth is available (plus tokens). | |
| 597 | + | // If only token_secret is configured, token-only mode. | |
| 598 | + | // The explicit "type" field is deprecated but still respected for backwards compat. | |
| 599 | + | func resolveAuthType(cfg Config) domain.AuthType { | |
| 600 | + | // Explicit type takes precedence (backwards compatibility) | |
| 601 | + | if cfg.Auth.Type == "token" { | |
| 596 | 602 | return domain.AuthTypeToken | |
| 597 | 603 | } | |
| 598 | - | return domain.AuthTypePassword | |
| 604 | + | if cfg.Auth.Type == "password" { | |
| 605 | + | return domain.AuthTypePassword | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | // Infer from config: password auth if password_hash is configured | |
| 609 | + | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |
| 610 | + | return domain.AuthTypePassword | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // Default to token-only | |
| 614 | + | return domain.AuthTypeToken | |
| 599 | 615 | } | |
| 600 | 616 | ||
| 601 | 617 | func resolveSecretsBackend(backend string) domain.SecretsBackend { | |
| @@ -618,11 +634,8 @@ func resolveDataDir(dataDir string) string { | |||
| 618 | 634 | return dataDir | |
| 619 | 635 | } | |
| 620 | 636 | ||
| 621 | - | func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | |
| 622 | - | if authType != domain.AuthTypeToken { | |
| 623 | - | return nil, nil | |
| 624 | - | } | |
| 625 | - | ||
| 637 | + | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | |
| 638 | + | // Token store is always created since tokens work in both auth modes | |
| 626 | 639 | store, err := tokenstore.NewStore(backend, dataDir, log) | |
| 627 | 640 | if err != nil { | |
| 628 | 641 | return nil, log.WrapErr(err, "failed to create token store") | |
| @@ -637,8 +650,16 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 637 | 650 | Username: cfg.Auth.Username, | |
| 638 | 651 | } | |
| 639 | 652 | ||
| 640 | - | switch authType { | |
| 641 | - | case domain.AuthTypePassword: | |
| 653 | + | // Token config is always required (tokens work in all auth modes) | |
| 654 | + | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | |
| 655 | + | if err != nil { | |
| 656 | + | return auth.Config{}, err | |
| 657 | + | } | |
| 658 | + | authConfig.TokenSecret = secret | |
| 659 | + | authConfig.TokenExpiry = expiry | |
| 660 | + | ||
| 661 | + | // Password config only needed for password auth mode | |
| 662 | + | if authType == domain.AuthTypePassword { | |
| 642 | 663 | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | |
| 643 | 664 | if err != nil { | |
| 644 | 665 | return auth.Config{}, err | |
| @@ -647,13 +668,6 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 647 | 668 | return auth.Config{}, errAuthNotConfigured() | |
| 648 | 669 | } | |
| 649 | 670 | authConfig.PasswordHash = hash | |
| 650 | - | case domain.AuthTypeToken: | |
| 651 | - | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | |
| 652 | - | if err != nil { | |
| 653 | - | return auth.Config{}, err | |
| 654 | - | } | |
| 655 | - | authConfig.TokenSecret = secret | |
| 656 | - | authConfig.TokenExpiry = expiry | |
| 657 | 671 | } | |
| 658 | 672 | ||
| 659 | 673 | return authConfig, nil | |
| @@ -713,7 +727,7 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 713 | 727 | ||
| 714 | 728 | // Fall back to config-specified path via secrets backend | |
| 715 | 729 | if cfg.Auth.TokenSecret == "" { | |
| 716 | - | return nil, fmt.Errorf("token_secret is required for token authentication; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar) | |
| 730 | + | return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar) | |
| 717 | 731 | } | |
| 718 | 732 | ||
| 719 | 733 | secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log) | |
| @@ -871,17 +885,23 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 871 | 885 | registryMiddlewares := []func(http.Handler) http.Handler{ | |
| 872 | 886 | middleware.PanicRecovery(log), | |
| 873 | 887 | middleware.RequestLogger(log), | |
| 888 | + | middleware.SecurityHeaders, | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | // Create rate limiters using the factory | |
| 892 | + | var rateLimitMiddleware func(http.Handler) http.Handler | |
| 893 | + | if cfg.API.RateLimit.Enabled { | |
| 894 | + | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 895 | + | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 896 | + | rateLimitMiddleware = registry.RateLimitMiddleware( | |
| 897 | + | globalLimiter, | |
| 898 | + | ipLimiter, | |
| 899 | + | cfg.API.RateLimit.TrustedProxies, | |
| 900 | + | log, | |
| 901 | + | ) | |
| 902 | + | } else { | |
| 903 | + | rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log) | |
| 874 | 904 | } | |
| 875 | - | ||
| 876 | - | // Add rate limiting middleware | |
| 877 | - | rateLimitConfig := registry.RateLimitConfig{ | |
| 878 | - | Enabled: cfg.API.RateLimit.Enabled, | |
| 879 | - | GlobalRPS: cfg.API.RateLimit.GlobalRPS, | |
| 880 | - | PerIPRPS: cfg.API.RateLimit.PerIPRPS, | |
| 881 | - | Burst: cfg.API.RateLimit.Burst, | |
| 882 | - | TrustedProxies: cfg.API.RateLimit.TrustedProxies, | |
| 883 | - | } | |
| 884 | - | rateLimitMiddleware := registry.RateLimitMiddleware(rateLimitConfig) | |
| 885 | 905 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |
| 886 | 906 | ||
| 887 | 907 | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| @@ -895,18 +915,21 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 895 | 915 | ||
| 896 | 916 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | |
| 897 | 917 | ||
| 898 | - | // Create a mux that routes /v2/token to the token handler (no auth required) | |
| 899 | - | // and all other /v2/* routes to the registry handler (with auth) | |
| 918 | + | // Create a mux that routes: | |
| 919 | + | // - /auth/* to auth handler (no auth required, for password login and token exchange) | |
| 920 | + | // - /v2/* to registry handler (with auth) | |
| 921 | + | // - /admin/* to admin handler (with auth) | |
| 900 | 922 | registryMux := http.NewServeMux() | |
| 901 | - | if svc.tokenHandler != nil { | |
| 902 | - | // Token endpoint is NOT protected by auth - it's where clients get tokens | |
| 903 | - | // but it still needs rate limiting to prevent brute force attacks | |
| 904 | - | tokenWithMiddleware := middleware.Chain( | |
| 923 | + | if svc.authHandler != nil { | |
| 924 | + | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 925 | + | // but still need rate limiting to prevent brute force attacks | |
| 926 | + | authWithMiddleware := middleware.Chain( | |
| 905 | 927 | middleware.PanicRecovery(log), | |
| 906 | 928 | middleware.RequestLogger(log), | |
| 929 | + | middleware.SecurityHeaders, | |
| 907 | 930 | rateLimitMiddleware, | |
| 908 | - | )(svc.tokenHandler) | |
| 909 | - | registryMux.Handle("/v2/token", tokenWithMiddleware) | |
| 931 | + | )(svc.authHandler) | |
| 932 | + | registryMux.Handle("/auth/", authWithMiddleware) | |
| 910 | 933 | } | |
| 911 | 934 | registryMux.Handle("/v2/", registryWithMiddleware) | |
| 912 | 935 | ||
| @@ -915,15 +938,19 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 915 | 938 | adminMiddlewares := []func(http.Handler) http.Handler{ | |
| 916 | 939 | middleware.PanicRecovery(log), | |
| 917 | 940 | middleware.RequestLogger(log), | |
| 941 | + | middleware.SecurityHeaders, | |
| 918 | 942 | } | |
| 919 | 943 | // Add admin auth middleware if auth is enabled | |
| 920 | 944 | if svc.authSvc != nil { | |
| 921 | - | // Rate limiter for admin API uses per-IP rate from config | |
| 922 | - | var adminLimiter *rate.Limiter | |
| 945 | + | // Create rate limiters for admin API - uses same config as registry | |
| 946 | + | var globalLimiter, ipLimiter out.RateLimiter | |
| 947 | + | var trustedNets []*net.IPNet | |
| 923 | 948 | if cfg.API.RateLimit.Enabled { | |
| 924 | - | adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst) | |
| 949 | + | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 950 | + | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 951 | + | trustedNets = middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| 925 | 952 | } | |
| 926 | - | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log)) | |
| 953 | + | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |
| 927 | 954 | } | |
| 928 | 955 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 929 | 956 | registryMux.Handle("/admin/", adminWithMiddleware) | |
| @@ -932,6 +959,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 932 | 959 | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 933 | 960 | middleware.PanicRecovery(log), | |
| 934 | 961 | middleware.RequestLogger(log), | |
| 962 | + | middleware.SecurityHeaders, | |
| 935 | 963 | middleware.CORS, | |
| 936 | 964 | } | |
| 937 | 965 | ||
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "encoding/hex" | 7 | "encoding/hex" | ||
| 8 | "encoding/json" | 8 | "encoding/json" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | + | "net" | |||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "os" | 12 | "os" | ||
| 12 | "os/signal" | 13 | "os/signal" | ||
| @@ -27,13 +28,13 @@ import ( | |||||
| 27 | "gordon/internal/adapters/out/filesystem" | 28 | "gordon/internal/adapters/out/filesystem" | ||
| 28 | "gordon/internal/adapters/out/httpprober" | 29 | "gordon/internal/adapters/out/httpprober" | ||
| 29 | "gordon/internal/adapters/out/logwriter" | 30 | "gordon/internal/adapters/out/logwriter" | ||
| 31 | + | "gordon/internal/adapters/out/ratelimit" | |||
| 30 | "gordon/internal/adapters/out/secrets" | 32 | "gordon/internal/adapters/out/secrets" | ||
| 31 | "gordon/internal/adapters/out/tokenstore" | 33 | "gordon/internal/adapters/out/tokenstore" | ||
| 32 | 34 | ||||
| 33 | - | "golang.org/x/time/rate" | |||
| 34 | - | ||||
| 35 | // Adapters - Input | 35 | // Adapters - Input | ||
| 36 | "gordon/internal/adapters/in/http/admin" | 36 | "gordon/internal/adapters/in/http/admin" | ||
| 37 | + | authhandler "gordon/internal/adapters/in/http/auth" | |||
| 37 | "gordon/internal/adapters/in/http/middleware" | 38 | "gordon/internal/adapters/in/http/middleware" | ||
| 38 | "gordon/internal/adapters/in/http/registry" | 39 | "gordon/internal/adapters/in/http/registry" | ||
| 39 | 40 | ||||
| @@ -126,7 +127,7 @@ type services struct { | |||||
| 126 | registrySvc *registrySvc.Service | 127 | registrySvc *registrySvc.Service | ||
| 127 | proxySvc *proxy.Service | 128 | proxySvc *proxy.Service | ||
| 128 | authSvc *auth.Service | 129 | authSvc *auth.Service | ||
| 129 | - | tokenHandler *registry.TokenHandler | 130 | + | authHandler *authhandler.Handler |
| 130 | adminHandler *admin.Handler | 131 | adminHandler *admin.Handler | ||
| 131 | internalRegUser string | 132 | internalRegUser string | ||
| 132 | internalRegPass string | 133 | internalRegPass string | ||
| @@ -300,11 +301,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 300 | 301 | ||||
| 301 | // Create token handler for registry token endpoint | 302 | // Create token handler for registry token endpoint | ||
| 302 | if svc.authSvc != nil { | 303 | if svc.authSvc != nil { | ||
| 303 | - | internalAuth := registry.InternalAuth{ | 304 | + | internalAuth := authhandler.InternalAuth{ |
| 304 | Username: svc.internalRegUser, | 305 | Username: svc.internalRegUser, | ||
| 305 | Password: svc.internalRegPass, | 306 | Password: svc.internalRegPass, | ||
| 306 | } | 307 | } | ||
| 307 | - | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log) | 308 | + | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) |
| 308 | } | 309 | } | ||
| 309 | 310 | ||||
| 310 | // Determine env directory for admin API | 311 | // Determine env directory for admin API | ||
| @@ -567,11 +568,11 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 567 | return nil, nil, nil | 568 | return nil, nil, nil | ||
| 568 | } | 569 | } | ||
| 569 | 570 | ||||
| 570 | - | authType := resolveAuthType(cfg.Auth.Type) | 571 | + | authType := resolveAuthType(cfg) |
| 571 | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | 572 | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | ||
| 572 | dataDir := resolveDataDir(cfg.Server.DataDir) | 573 | dataDir := resolveDataDir(cfg.Server.DataDir) | ||
| 573 | 574 | ||||
| 574 | - | store, err := createTokenStore(authType, backend, dataDir, log) | 575 | + | store, err := createTokenStore(backend, dataDir, log) |
| 575 | if err != nil { | 576 | if err != nil { | ||
| 576 | return nil, nil, err | 577 | return nil, nil, err | ||
| 577 | } | 578 | } | ||
| @@ -591,11 +592,26 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 591 | return store, authSvc, nil | 592 | return store, authSvc, nil | ||
| 592 | } | 593 | } | ||
| 593 | 594 | ||||
| 594 | - | func resolveAuthType(authType string) domain.AuthType { | 595 | + | // resolveAuthType determines the auth type from config. |
| 595 | - | if authType == "token" { | 596 | + | // If password_hash is configured, password auth is available (plus tokens). |
| 597 | + | // If only token_secret is configured, token-only mode. | |||
| 598 | + | // The explicit "type" field is deprecated but still respected for backwards compat. | |||
| 599 | + | func resolveAuthType(cfg Config) domain.AuthType { | |||
| 600 | + | // Explicit type takes precedence (backwards compatibility) | |||
| 601 | + | if cfg.Auth.Type == "token" { | |||
| 596 | return domain.AuthTypeToken | 602 | return domain.AuthTypeToken | ||
| 597 | } | 603 | } | ||
| 598 | - | return domain.AuthTypePassword | 604 | + | if cfg.Auth.Type == "password" { |
| 605 | + | return domain.AuthTypePassword | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // Infer from config: password auth if password_hash is configured | |||
| 609 | + | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |||
| 610 | + | return domain.AuthTypePassword | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | // Default to token-only | |||
| 614 | + | return domain.AuthTypeToken | |||
| 599 | } | 615 | } | ||
| 600 | 616 | ||||
| 601 | func resolveSecretsBackend(backend string) domain.SecretsBackend { | 617 | func resolveSecretsBackend(backend string) domain.SecretsBackend { | ||
| @@ -618,11 +634,8 @@ func resolveDataDir(dataDir string) string { | |||||
| 618 | return dataDir | 634 | return dataDir | ||
| 619 | } | 635 | } | ||
| 620 | 636 | ||||
| 621 | - | func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | 637 | + | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { |
| 622 | - | if authType != domain.AuthTypeToken { | 638 | + | // Token store is always created since tokens work in both auth modes |
| 623 | - | return nil, nil | |||
| 624 | - | } | |||
| 625 | - | ||||
| 626 | store, err := tokenstore.NewStore(backend, dataDir, log) | 639 | store, err := tokenstore.NewStore(backend, dataDir, log) | ||
| 627 | if err != nil { | 640 | if err != nil { | ||
| 628 | return nil, log.WrapErr(err, "failed to create token store") | 641 | return nil, log.WrapErr(err, "failed to create token store") | ||
| @@ -637,8 +650,16 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 637 | Username: cfg.Auth.Username, | 650 | Username: cfg.Auth.Username, | ||
| 638 | } | 651 | } | ||
| 639 | 652 | ||||
| 640 | - | switch authType { | 653 | + | // Token config is always required (tokens work in all auth modes) |
| 641 | - | case domain.AuthTypePassword: | 654 | + | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) |
| 655 | + | if err != nil { | |||
| 656 | + | return auth.Config{}, err | |||
| 657 | + | } | |||
| 658 | + | authConfig.TokenSecret = secret | |||
| 659 | + | authConfig.TokenExpiry = expiry | |||
| 660 | + | ||||
| 661 | + | // Password config only needed for password auth mode | |||
| 662 | + | if authType == domain.AuthTypePassword { | |||
| 642 | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | 663 | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | ||
| 643 | if err != nil { | 664 | if err != nil { | ||
| 644 | return auth.Config{}, err | 665 | return auth.Config{}, err | ||
| @@ -647,13 +668,6 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 647 | return auth.Config{}, errAuthNotConfigured() | 668 | return auth.Config{}, errAuthNotConfigured() | ||
| 648 | } | 669 | } | ||
| 649 | authConfig.PasswordHash = hash | 670 | authConfig.PasswordHash = hash | ||
| 650 | - | case domain.AuthTypeToken: | |||
| 651 | - | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | |||
| 652 | - | if err != nil { | |||
| 653 | - | return auth.Config{}, err | |||
| 654 | - | } | |||
| 655 | - | authConfig.TokenSecret = secret | |||
| 656 | - | authConfig.TokenExpiry = expiry | |||
| 657 | } | 671 | } | ||
| 658 | 672 | ||||
| 659 | return authConfig, nil | 673 | return authConfig, nil | ||
| @@ -713,7 +727,7 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||||
| 713 | 727 | ||||
| 714 | // Fall back to config-specified path via secrets backend | 728 | // Fall back to config-specified path via secrets backend | ||
| 715 | if cfg.Auth.TokenSecret == "" { | 729 | if cfg.Auth.TokenSecret == "" { | ||
| 716 | - | return nil, fmt.Errorf("token_secret is required for token authentication; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar) | 730 | + | return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar) |
| 717 | } | 731 | } | ||
| 718 | 732 | ||||
| 719 | secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log) | 733 | secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log) | ||
| @@ -871,17 +885,23 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 871 | registryMiddlewares := []func(http.Handler) http.Handler{ | 885 | registryMiddlewares := []func(http.Handler) http.Handler{ | ||
| 872 | middleware.PanicRecovery(log), | 886 | middleware.PanicRecovery(log), | ||
| 873 | middleware.RequestLogger(log), | 887 | middleware.RequestLogger(log), | ||
| 888 | + | middleware.SecurityHeaders, | |||
| 889 | + | } | |||
| 890 | + | ||||
| 891 | + | // Create rate limiters using the factory | |||
| 892 | + | var rateLimitMiddleware func(http.Handler) http.Handler | |||
| 893 | + | if cfg.API.RateLimit.Enabled { | |||
| 894 | + | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |||
| 895 | + | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |||
| 896 | + | rateLimitMiddleware = registry.RateLimitMiddleware( | |||
| 897 | + | globalLimiter, | |||
| 898 | + | ipLimiter, | |||
| 899 | + | cfg.API.RateLimit.TrustedProxies, | |||
| 900 | + | log, | |||
| 901 | + | ) | |||
| 902 | + | } else { | |||
| 903 | + | rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log) | |||
| 874 | } | 904 | } | ||
| 875 | - | ||||
| 876 | - | // Add rate limiting middleware | |||
| 877 | - | rateLimitConfig := registry.RateLimitConfig{ | |||
| 878 | - | Enabled: cfg.API.RateLimit.Enabled, | |||
| 879 | - | GlobalRPS: cfg.API.RateLimit.GlobalRPS, | |||
| 880 | - | PerIPRPS: cfg.API.RateLimit.PerIPRPS, | |||
| 881 | - | Burst: cfg.API.RateLimit.Burst, | |||
| 882 | - | TrustedProxies: cfg.API.RateLimit.TrustedProxies, | |||
| 883 | - | } | |||
| 884 | - | rateLimitMiddleware := registry.RateLimitMiddleware(rateLimitConfig) | |||
| 885 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | 905 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | ||
| 886 | 906 | ||||
| 887 | if cfg.Auth.Enabled && svc.authSvc != nil { | 907 | if cfg.Auth.Enabled && svc.authSvc != nil { | ||
| @@ -895,18 +915,21 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 895 | 915 | ||||
| 896 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | 916 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | ||
| 897 | 917 | ||||
| 898 | - | // Create a mux that routes /v2/token to the token handler (no auth required) | 918 | + | // Create a mux that routes: |
| 899 | - | // and all other /v2/* routes to the registry handler (with auth) | 919 | + | // - /auth/* to auth handler (no auth required, for password login and token exchange) |
| 920 | + | // - /v2/* to registry handler (with auth) | |||
| 921 | + | // - /admin/* to admin handler (with auth) | |||
| 900 | registryMux := http.NewServeMux() | 922 | registryMux := http.NewServeMux() | ||
| 901 | - | if svc.tokenHandler != nil { | 923 | + | if svc.authHandler != nil { |
| 902 | - | // Token endpoint is NOT protected by auth - it's where clients get tokens | 924 | + | // Auth endpoints are NOT protected by auth - they're where clients authenticate |
| 903 | - | // but it still needs rate limiting to prevent brute force attacks | 925 | + | // but still need rate limiting to prevent brute force attacks |
| 904 | - | tokenWithMiddleware := middleware.Chain( | 926 | + | authWithMiddleware := middleware.Chain( |
| 905 | middleware.PanicRecovery(log), | 927 | middleware.PanicRecovery(log), | ||
| 906 | middleware.RequestLogger(log), | 928 | middleware.RequestLogger(log), | ||
| 929 | + | middleware.SecurityHeaders, | |||
| 907 | rateLimitMiddleware, | 930 | rateLimitMiddleware, | ||
| 908 | - | )(svc.tokenHandler) | 931 | + | )(svc.authHandler) |
| 909 | - | registryMux.Handle("/v2/token", tokenWithMiddleware) | 932 | + | registryMux.Handle("/auth/", authWithMiddleware) |
| 910 | } | 933 | } | ||
| 911 | registryMux.Handle("/v2/", registryWithMiddleware) | 934 | registryMux.Handle("/v2/", registryWithMiddleware) | ||
| 912 | 935 | ||||
| @@ -915,15 +938,19 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 915 | adminMiddlewares := []func(http.Handler) http.Handler{ | 938 | adminMiddlewares := []func(http.Handler) http.Handler{ | ||
| 916 | middleware.PanicRecovery(log), | 939 | middleware.PanicRecovery(log), | ||
| 917 | middleware.RequestLogger(log), | 940 | middleware.RequestLogger(log), | ||
| 941 | + | middleware.SecurityHeaders, | |||
| 918 | } | 942 | } | ||
| 919 | // Add admin auth middleware if auth is enabled | 943 | // Add admin auth middleware if auth is enabled | ||
| 920 | if svc.authSvc != nil { | 944 | if svc.authSvc != nil { | ||
| 921 | - | // Rate limiter for admin API uses per-IP rate from config | 945 | + | // Create rate limiters for admin API - uses same config as registry |
| 922 | - | var adminLimiter *rate.Limiter | 946 | + | var globalLimiter, ipLimiter out.RateLimiter |
| 947 | + | var trustedNets []*net.IPNet | |||
| 923 | if cfg.API.RateLimit.Enabled { | 948 | if cfg.API.RateLimit.Enabled { | ||
| 924 | - | adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst) | 949 | + | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) |
| 950 | + | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |||
| 951 | + | trustedNets = middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |||
| 925 | } | 952 | } | ||
| 926 | - | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log)) | 953 | + | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) |
| 927 | } | 954 | } | ||
| 928 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | 955 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | ||
| 929 | registryMux.Handle("/admin/", adminWithMiddleware) | 956 | registryMux.Handle("/admin/", adminWithMiddleware) | ||
| @@ -932,6 +959,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 932 | proxyMiddlewares := []func(http.Handler) http.Handler{ | 959 | proxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 933 | middleware.PanicRecovery(log), | 960 | middleware.PanicRecovery(log), | ||
| 934 | middleware.RequestLogger(log), | 961 | middleware.RequestLogger(log), | ||
| 962 | + | middleware.SecurityHeaders, | |||
| 935 | middleware.CORS, | 963 | middleware.CORS, | ||
| 936 | } | 964 | } | ||
| 937 | 965 | ||||
A internal/boundaries/out/mocks/mock_rate_limiter.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockRateLimiter creates a new instance of MockRateLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockRateLimiter(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockRateLimiter { | |
| 19 | + | mock := &MockRateLimiter{} | |
| 20 | + | mock.Mock.Test(t) | |
| 21 | + | ||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 23 | + | ||
| 24 | + | return mock | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // MockRateLimiter is an autogenerated mock type for the RateLimiter type | |
| 28 | + | type MockRateLimiter struct { | |
| 29 | + | mock.Mock | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type MockRateLimiter_Expecter struct { | |
| 33 | + | mock *mock.Mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (_m *MockRateLimiter) EXPECT() *MockRateLimiter_Expecter { | |
| 37 | + | return &MockRateLimiter_Expecter{mock: &_m.Mock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Allow provides a mock function for the type MockRateLimiter | |
| 41 | + | func (_mock *MockRateLimiter) Allow(ctx context.Context, key string) bool { | |
| 42 | + | ret := _mock.Called(ctx, key) | |
| 43 | + | ||
| 44 | + | if len(ret) == 0 { | |
| 45 | + | panic("no return value specified for Allow") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var r0 bool | |
| 49 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |
| 50 | + | r0 = returnFunc(ctx, key) | |
| 51 | + | } else { | |
| 52 | + | r0 = ret.Get(0).(bool) | |
| 53 | + | } | |
| 54 | + | return r0 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // MockRateLimiter_Allow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Allow' | |
| 58 | + | type MockRateLimiter_Allow_Call struct { | |
| 59 | + | *mock.Call | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Allow is a helper method to define mock.On call | |
| 63 | + | // - ctx context.Context | |
| 64 | + | // - key string | |
| 65 | + | func (_e *MockRateLimiter_Expecter) Allow(ctx interface{}, key interface{}) *MockRateLimiter_Allow_Call { | |
| 66 | + | return &MockRateLimiter_Allow_Call{Call: _e.mock.On("Allow", ctx, key)} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (_c *MockRateLimiter_Allow_Call) Run(run func(ctx context.Context, key string)) *MockRateLimiter_Allow_Call { | |
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 71 | + | var arg0 context.Context | |
| 72 | + | if args[0] != nil { | |
| 73 | + | arg0 = args[0].(context.Context) | |
| 74 | + | } | |
| 75 | + | var arg1 string | |
| 76 | + | if args[1] != nil { | |
| 77 | + | arg1 = args[1].(string) | |
| 78 | + | } | |
| 79 | + | run( | |
| 80 | + | arg0, | |
| 81 | + | arg1, | |
| 82 | + | ) | |
| 83 | + | }) | |
| 84 | + | return _c | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (_c *MockRateLimiter_Allow_Call) Return(b bool) *MockRateLimiter_Allow_Call { | |
| 88 | + | _c.Call.Return(b) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *MockRateLimiter_Allow_Call) RunAndReturn(run func(ctx context.Context, key string) bool) *MockRateLimiter_Allow_Call { | |
| 93 | + | _c.Call.Return(run) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // AllowN provides a mock function for the type MockRateLimiter | |
| 98 | + | func (_mock *MockRateLimiter) AllowN(ctx context.Context, key string, n int) bool { | |
| 99 | + | ret := _mock.Called(ctx, key, n) | |
| 100 | + | ||
| 101 | + | if len(ret) == 0 { | |
| 102 | + | panic("no return value specified for AllowN") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | var r0 bool | |
| 106 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) bool); ok { | |
| 107 | + | r0 = returnFunc(ctx, key, n) | |
| 108 | + | } else { | |
| 109 | + | r0 = ret.Get(0).(bool) | |
| 110 | + | } | |
| 111 | + | return r0 | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // MockRateLimiter_AllowN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllowN' | |
| 115 | + | type MockRateLimiter_AllowN_Call struct { | |
| 116 | + | *mock.Call | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // AllowN is a helper method to define mock.On call | |
| 120 | + | // - ctx context.Context | |
| 121 | + | // - key string | |
| 122 | + | // - n int | |
| 123 | + | func (_e *MockRateLimiter_Expecter) AllowN(ctx interface{}, key interface{}, n interface{}) *MockRateLimiter_AllowN_Call { | |
| 124 | + | return &MockRateLimiter_AllowN_Call{Call: _e.mock.On("AllowN", ctx, key, n)} | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (_c *MockRateLimiter_AllowN_Call) Run(run func(ctx context.Context, key string, n int)) *MockRateLimiter_AllowN_Call { | |
| 128 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 129 | + | var arg0 context.Context | |
| 130 | + | if args[0] != nil { | |
| 131 | + | arg0 = args[0].(context.Context) | |
| 132 | + | } | |
| 133 | + | var arg1 string | |
| 134 | + | if args[1] != nil { | |
| 135 | + | arg1 = args[1].(string) | |
| 136 | + | } | |
| 137 | + | var arg2 int | |
| 138 | + | if args[2] != nil { | |
| 139 | + | arg2 = args[2].(int) | |
| 140 | + | } | |
| 141 | + | run( | |
| 142 | + | arg0, | |
| 143 | + | arg1, | |
| 144 | + | arg2, | |
| 145 | + | ) | |
| 146 | + | }) | |
| 147 | + | return _c | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (_c *MockRateLimiter_AllowN_Call) Return(b bool) *MockRateLimiter_AllowN_Call { | |
| 151 | + | _c.Call.Return(b) | |
| 152 | + | return _c | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (_c *MockRateLimiter_AllowN_Call) RunAndReturn(run func(ctx context.Context, key string, n int) bool) *MockRateLimiter_AllowN_Call { | |
| 156 | + | _c.Call.Return(run) | |
| 157 | + | return _c | |
| 158 | + | } | |
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockRateLimiter creates a new instance of MockRateLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockRateLimiter(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockRateLimiter { | |||
| 19 | + | mock := &MockRateLimiter{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockRateLimiter is an autogenerated mock type for the RateLimiter type | |||
| 28 | + | type MockRateLimiter struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockRateLimiter_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockRateLimiter) EXPECT() *MockRateLimiter_Expecter { | |||
| 37 | + | return &MockRateLimiter_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Allow provides a mock function for the type MockRateLimiter | |||
| 41 | + | func (_mock *MockRateLimiter) Allow(ctx context.Context, key string) bool { | |||
| 42 | + | ret := _mock.Called(ctx, key) | |||
| 43 | + | ||||
| 44 | + | if len(ret) == 0 { | |||
| 45 | + | panic("no return value specified for Allow") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var r0 bool | |||
| 49 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |||
| 50 | + | r0 = returnFunc(ctx, key) | |||
| 51 | + | } else { | |||
| 52 | + | r0 = ret.Get(0).(bool) | |||
| 53 | + | } | |||
| 54 | + | return r0 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // MockRateLimiter_Allow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Allow' | |||
| 58 | + | type MockRateLimiter_Allow_Call struct { | |||
| 59 | + | *mock.Call | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Allow is a helper method to define mock.On call | |||
| 63 | + | // - ctx context.Context | |||
| 64 | + | // - key string | |||
| 65 | + | func (_e *MockRateLimiter_Expecter) Allow(ctx interface{}, key interface{}) *MockRateLimiter_Allow_Call { | |||
| 66 | + | return &MockRateLimiter_Allow_Call{Call: _e.mock.On("Allow", ctx, key)} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (_c *MockRateLimiter_Allow_Call) Run(run func(ctx context.Context, key string)) *MockRateLimiter_Allow_Call { | |||
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 71 | + | var arg0 context.Context | |||
| 72 | + | if args[0] != nil { | |||
| 73 | + | arg0 = args[0].(context.Context) | |||
| 74 | + | } | |||
| 75 | + | var arg1 string | |||
| 76 | + | if args[1] != nil { | |||
| 77 | + | arg1 = args[1].(string) | |||
| 78 | + | } | |||
| 79 | + | run( | |||
| 80 | + | arg0, | |||
| 81 | + | arg1, | |||
| 82 | + | ) | |||
| 83 | + | }) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (_c *MockRateLimiter_Allow_Call) Return(b bool) *MockRateLimiter_Allow_Call { | |||
| 88 | + | _c.Call.Return(b) | |||
| 89 | + | return _c | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *MockRateLimiter_Allow_Call) RunAndReturn(run func(ctx context.Context, key string) bool) *MockRateLimiter_Allow_Call { | |||
| 93 | + | _c.Call.Return(run) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // AllowN provides a mock function for the type MockRateLimiter | |||
| 98 | + | func (_mock *MockRateLimiter) AllowN(ctx context.Context, key string, n int) bool { | |||
| 99 | + | ret := _mock.Called(ctx, key, n) | |||
| 100 | + | ||||
| 101 | + | if len(ret) == 0 { | |||
| 102 | + | panic("no return value specified for AllowN") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | var r0 bool | |||
| 106 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) bool); ok { | |||
| 107 | + | r0 = returnFunc(ctx, key, n) | |||
| 108 | + | } else { | |||
| 109 | + | r0 = ret.Get(0).(bool) | |||
| 110 | + | } | |||
| 111 | + | return r0 | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // MockRateLimiter_AllowN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllowN' | |||
| 115 | + | type MockRateLimiter_AllowN_Call struct { | |||
| 116 | + | *mock.Call | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // AllowN is a helper method to define mock.On call | |||
| 120 | + | // - ctx context.Context | |||
| 121 | + | // - key string | |||
| 122 | + | // - n int | |||
| 123 | + | func (_e *MockRateLimiter_Expecter) AllowN(ctx interface{}, key interface{}, n interface{}) *MockRateLimiter_AllowN_Call { | |||
| 124 | + | return &MockRateLimiter_AllowN_Call{Call: _e.mock.On("AllowN", ctx, key, n)} | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (_c *MockRateLimiter_AllowN_Call) Run(run func(ctx context.Context, key string, n int)) *MockRateLimiter_AllowN_Call { | |||
| 128 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 129 | + | var arg0 context.Context | |||
| 130 | + | if args[0] != nil { | |||
| 131 | + | arg0 = args[0].(context.Context) | |||
| 132 | + | } | |||
| 133 | + | var arg1 string | |||
| 134 | + | if args[1] != nil { | |||
| 135 | + | arg1 = args[1].(string) | |||
| 136 | + | } | |||
| 137 | + | var arg2 int | |||
| 138 | + | if args[2] != nil { | |||
| 139 | + | arg2 = args[2].(int) | |||
| 140 | + | } | |||
| 141 | + | run( | |||
| 142 | + | arg0, | |||
| 143 | + | arg1, | |||
| 144 | + | arg2, | |||
| 145 | + | ) | |||
| 146 | + | }) | |||
| 147 | + | return _c | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (_c *MockRateLimiter_AllowN_Call) Return(b bool) *MockRateLimiter_AllowN_Call { | |||
| 151 | + | _c.Call.Return(b) | |||
| 152 | + | return _c | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (_c *MockRateLimiter_AllowN_Call) RunAndReturn(run func(ctx context.Context, key string, n int) bool) *MockRateLimiter_AllowN_Call { | |||
| 156 | + | _c.Call.Return(run) | |||
| 157 | + | return _c | |||
| 158 | + | } | |||
A internal/boundaries/out/ratelimiter.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // RateLimiter defines the contract for rate limiting operations. | |
| 6 | + | // Implementations may use different backends (memory, Redis). | |
| 7 | + | type RateLimiter interface { | |
| 8 | + | // Allow checks if a request identified by key is allowed. | |
| 9 | + | // Returns true if allowed, false if rate limited. | |
| 10 | + | // Key is typically "global" or "ip:<address>". | |
| 11 | + | Allow(ctx context.Context, key string) bool | |
| 12 | + | ||
| 13 | + | // AllowN checks if n requests identified by key are allowed. | |
| 14 | + | AllowN(ctx context.Context, key string, n int) bool | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // RateLimiter defines the contract for rate limiting operations. | |||
| 6 | + | // Implementations may use different backends (memory, Redis). | |||
| 7 | + | type RateLimiter interface { | |||
| 8 | + | // Allow checks if a request identified by key is allowed. | |||
| 9 | + | // Returns true if allowed, false if rate limited. | |||
| 10 | + | // Key is typically "global" or "ip:<address>". | |||
| 11 | + | Allow(ctx context.Context, key string) bool | |||
| 12 | + | ||||
| 13 | + | // AllowN checks if n requests identified by key are allowed. | |||
| 14 | + | AllowN(ctx context.Context, key string, n int) bool | |||
| 15 | + | } | |||
M internal/usecase/proxy/service.go +0 -8
| @@ -39,9 +39,6 @@ var proxyTransport = &http.Transport{ | |||
| 39 | 39 | type Config struct { | |
| 40 | 40 | RegistryDomain string | |
| 41 | 41 | RegistryPort int | |
| 42 | - | // ExposeContainerID controls whether to add X-Container-ID header to responses. | |
| 43 | - | // Default: false (disabled for security - prevents container enumeration) | |
| 44 | - | ExposeContainerID bool | |
| 45 | 42 | } | |
| 46 | 43 | ||
| 47 | 44 | // Service implements the ProxyService interface. | |
| @@ -325,11 +322,6 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 325 | 322 | }, | |
| 326 | 323 | func(resp *http.Response) error { | |
| 327 | 324 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 328 | - | // SECURITY: Only expose container ID if explicitly enabled | |
| 329 | - | // This prevents container enumeration attacks | |
| 330 | - | if s.config.ExposeContainerID && target.ContainerID != "" { | |
| 331 | - | resp.Header.Set("X-Container-ID", target.ContainerID) | |
| 332 | - | } | |
| 333 | 325 | return nil | |
| 334 | 326 | }, | |
| 335 | 327 | ) | |
| @@ -39,9 +39,6 @@ var proxyTransport = &http.Transport{ | |||||
| 39 | type Config struct { | 39 | type Config struct { | ||
| 40 | RegistryDomain string | 40 | RegistryDomain string | ||
| 41 | RegistryPort int | 41 | RegistryPort int | ||
| 42 | - | // ExposeContainerID controls whether to add X-Container-ID header to responses. | |||
| 43 | - | // Default: false (disabled for security - prevents container enumeration) | |||
| 44 | - | ExposeContainerID bool | |||
| 45 | } | 42 | } | ||
| 46 | 43 | ||||
| 47 | // Service implements the ProxyService interface. | 44 | // Service implements the ProxyService interface. | ||
| @@ -325,11 +322,6 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||||
| 325 | }, | 322 | }, | ||
| 326 | func(resp *http.Response) error { | 323 | func(resp *http.Response) error { | ||
| 327 | resp.Header.Set("X-Proxied-By", "Gordon") | 324 | resp.Header.Set("X-Proxied-By", "Gordon") | ||
| 328 | - | // SECURITY: Only expose container ID if explicitly enabled | |||
| 329 | - | // This prevents container enumeration attacks | |||
| 330 | - | if s.config.ExposeContainerID && target.ContainerID != "" { | |||
| 331 | - | resp.Header.Set("X-Container-ID", target.ContainerID) | |||
| 332 | - | } | |||
| 333 | return nil | 325 | return nil | ||
| 334 | }, | 326 | }, | ||
| 335 | ) | 327 | ) | ||
M wiki/examples/index.md +0 -1
| @@ -46,7 +46,6 @@ gordon_domain = "gordon.company.com" | |||
| 46 | 46 | ||
| 47 | 47 | [auth] | |
| 48 | 48 | enabled = true | |
| 49 | - | type = "token" | |
| 50 | 49 | secrets_backend = "pass" | |
| 51 | 50 | token_secret = "gordon/auth/token_secret" | |
| 52 | 51 | ||
| @@ -46,7 +46,6 @@ gordon_domain = "gordon.company.com" | |||||
| 46 | 46 | ||||
| 47 | [auth] | 47 | [auth] | ||
| 48 | enabled = true | 48 | enabled = true | ||
| 49 | - | type = "token" | |||
| 50 | secrets_backend = "pass" | 49 | secrets_backend = "pass" | ||
| 51 | token_secret = "gordon/auth/token_secret" | 50 | token_secret = "gordon/auth/token_secret" | ||
| 52 | 51 | ||||
M wiki/examples/production.md +0 -1
| @@ -22,7 +22,6 @@ gordon_domain = "gordon.company.com" | |||
| 22 | 22 | # Authentication with pass backend (recommended) | |
| 23 | 23 | [auth] | |
| 24 | 24 | enabled = true | |
| 25 | - | type = "token" | |
| 26 | 25 | secrets_backend = "pass" | |
| 27 | 26 | token_secret = "gordon/auth/token_secret" | |
| 28 | 27 | ||
| @@ -22,7 +22,6 @@ gordon_domain = "gordon.company.com" | |||||
| 22 | # Authentication with pass backend (recommended) | 22 | # Authentication with pass backend (recommended) | ||
| 23 | [auth] | 23 | [auth] | ||
| 24 | enabled = true | 24 | enabled = true | ||
| 25 | - | type = "token" | |||
| 26 | secrets_backend = "pass" | 25 | secrets_backend = "pass" | ||
| 27 | token_secret = "gordon/auth/token_secret" | 26 | token_secret = "gordon/auth/token_secret" | ||
| 28 | 27 | ||||
M wiki/guides/remote-cli.md +31 -0
| @@ -151,6 +151,37 @@ gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 151 | 151 | | `--token <TOKEN>` | Store token directly in config | | |
| 152 | 152 | | `--token-env <VAR>` | Store env variable name (token resolved at runtime) | | |
| 153 | 153 | ||
| 154 | + | #### Authenticate with Password Auth | |
| 155 | + | ||
| 156 | + | For servers using password authentication, you can log in interactively: | |
| 157 | + | ||
| 158 | + | ```bash | |
| 159 | + | # Login to active remote | |
| 160 | + | gordon auth login | |
| 161 | + | ||
| 162 | + | # Login to specific remote | |
| 163 | + | gordon auth login --remote prod | |
| 164 | + | ||
| 165 | + | # Pre-fill username | |
| 166 | + | gordon auth login --username admin | |
| 167 | + | ``` | |
| 168 | + | ||
| 169 | + | This prompts for your username and password, authenticates with the remote server, and stores the returned token automatically. | |
| 170 | + | ||
| 171 | + | > **Note:** Only works with servers that have password authentication enabled (`username` + `password_hash` configured). For token-only servers, use `gordon remotes set-token` instead. | |
| 172 | + | ||
| 173 | + | #### Set Token Manually | |
| 174 | + | ||
| 175 | + | For servers using token-based auth, or to update a token manually: | |
| 176 | + | ||
| 177 | + | ```bash | |
| 178 | + | # Set token directly | |
| 179 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |
| 180 | + | ||
| 181 | + | # Set token from file | |
| 182 | + | gordon remotes set-token staging $(cat token.txt) | |
| 183 | + | ``` | |
| 184 | + | ||
| 154 | 185 | #### Remove a Remote | |
| 155 | 186 | ||
| 156 | 187 | ```bash | |
| @@ -151,6 +151,37 @@ gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||||
| 151 | | `--token <TOKEN>` | Store token directly in config | | 151 | | `--token <TOKEN>` | Store token directly in config | | ||
| 152 | | `--token-env <VAR>` | Store env variable name (token resolved at runtime) | | 152 | | `--token-env <VAR>` | Store env variable name (token resolved at runtime) | | ||
| 153 | 153 | ||||
| 154 | + | #### Authenticate with Password Auth | |||
| 155 | + | ||||
| 156 | + | For servers using password authentication, you can log in interactively: | |||
| 157 | + | ||||
| 158 | + | ```bash | |||
| 159 | + | # Login to active remote | |||
| 160 | + | gordon auth login | |||
| 161 | + | ||||
| 162 | + | # Login to specific remote | |||
| 163 | + | gordon auth login --remote prod | |||
| 164 | + | ||||
| 165 | + | # Pre-fill username | |||
| 166 | + | gordon auth login --username admin | |||
| 167 | + | ``` | |||
| 168 | + | ||||
| 169 | + | This prompts for your username and password, authenticates with the remote server, and stores the returned token automatically. | |||
| 170 | + | ||||
| 171 | + | > **Note:** Only works with servers that have password authentication enabled (`username` + `password_hash` configured). For token-only servers, use `gordon remotes set-token` instead. | |||
| 172 | + | ||||
| 173 | + | #### Set Token Manually | |||
| 174 | + | ||||
| 175 | + | For servers using token-based auth, or to update a token manually: | |||
| 176 | + | ||||
| 177 | + | ```bash | |||
| 178 | + | # Set token directly | |||
| 179 | + | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |||
| 180 | + | ||||
| 181 | + | # Set token from file | |||
| 182 | + | gordon remotes set-token staging $(cat token.txt) | |||
| 183 | + | ``` | |||
| 184 | + | ||||
| 154 | #### Remove a Remote | 185 | #### Remove a Remote | ||
| 155 | 186 | ||||
| 156 | ```bash | 187 | ```bash | ||
M wiki/guides/running-in-container.md +3 -3
| @@ -309,11 +309,11 @@ volumes: | |||
| 309 | 309 | ```toml | |
| 310 | 310 | [auth] | |
| 311 | 311 | enabled = true | |
| 312 | - | type = "token" | |
| 313 | 312 | secrets_backend = "pass" | |
| 314 | - | username = "admin" | |
| 315 | - | password_hash = "gordon/auth/password_hash" | |
| 316 | 313 | token_secret = "gordon/auth/token_secret" | |
| 314 | + | # Optional: enable password auth for interactive login | |
| 315 | + | # username = "admin" | |
| 316 | + | # password_hash = "gordon/auth/password_hash" | |
| 317 | 317 | ``` | |
| 318 | 318 | ||
| 319 | 319 | ### GPG Agent (Optional) | |
| @@ -309,11 +309,11 @@ volumes: | |||||
| 309 | ```toml | 309 | ```toml | ||
| 310 | [auth] | 310 | [auth] | ||
| 311 | enabled = true | 311 | enabled = true | ||
| 312 | - | type = "token" | |||
| 313 | secrets_backend = "pass" | 312 | secrets_backend = "pass" | ||
| 314 | - | username = "admin" | |||
| 315 | - | password_hash = "gordon/auth/password_hash" | |||
| 316 | token_secret = "gordon/auth/token_secret" | 313 | token_secret = "gordon/auth/token_secret" | ||
| 314 | + | # Optional: enable password auth for interactive login | |||
| 315 | + | # username = "admin" | |||
| 316 | + | # password_hash = "gordon/auth/password_hash" | |||
| 317 | ``` | 317 | ``` | ||
| 318 | 318 | ||||
| 319 | ### GPG Agent (Optional) | 319 | ### GPG Agent (Optional) | ||
M wiki/guides/secrets-pass.md +3 -3
| @@ -99,11 +99,11 @@ pass generate gordon/auth/token_secret 32 | |||
| 99 | 99 | ```toml | |
| 100 | 100 | [auth] | |
| 101 | 101 | enabled = true | |
| 102 | - | type = "token" | |
| 103 | 102 | secrets_backend = "pass" | |
| 104 | - | username = "admin" | |
| 105 | - | password_hash = "gordon/auth/password_hash" | |
| 106 | 103 | token_secret = "gordon/auth/token_secret" | |
| 104 | + | # Optional: enable password auth for interactive login | |
| 105 | + | # username = "admin" | |
| 106 | + | # password_hash = "gordon/auth/password_hash" | |
| 107 | 107 | ``` | |
| 108 | 108 | ||
| 109 | 109 | ### Using Secrets in Environment Files | |
| @@ -99,11 +99,11 @@ pass generate gordon/auth/token_secret 32 | |||||
| 99 | ```toml | 99 | ```toml | ||
| 100 | [auth] | 100 | [auth] | ||
| 101 | enabled = true | 101 | enabled = true | ||
| 102 | - | type = "token" | |||
| 103 | secrets_backend = "pass" | 102 | secrets_backend = "pass" | ||
| 104 | - | username = "admin" | |||
| 105 | - | password_hash = "gordon/auth/password_hash" | |||
| 106 | token_secret = "gordon/auth/token_secret" | 103 | token_secret = "gordon/auth/token_secret" | ||
| 104 | + | # Optional: enable password auth for interactive login | |||
| 105 | + | # username = "admin" | |||
| 106 | + | # password_hash = "gordon/auth/password_hash" | |||
| 107 | ``` | 107 | ``` | ||
| 108 | 108 | ||||
| 109 | ### Using Secrets in Environment Files | 109 | ### Using Secrets in Environment Files | ||
M wiki/guides/secrets-sops.md +3 -3
| @@ -197,11 +197,11 @@ sops edit secrets.yaml | |||
| 197 | 197 | ```toml | |
| 198 | 198 | [auth] | |
| 199 | 199 | enabled = true | |
| 200 | - | type = "token" | |
| 201 | 200 | secrets_backend = "sops" | |
| 202 | - | username = "admin" | |
| 203 | - | password_hash = "secrets.yaml:auth.password_hash" | |
| 204 | 201 | token_secret = "secrets.yaml:auth.token_secret" | |
| 202 | + | # Optional: enable password auth for interactive login | |
| 203 | + | # username = "admin" | |
| 204 | + | # password_hash = "secrets.yaml:auth.password_hash" | |
| 205 | 205 | ``` | |
| 206 | 206 | ||
| 207 | 207 | The path format is `file:key.path` where: | |
| @@ -197,11 +197,11 @@ sops edit secrets.yaml | |||||
| 197 | ```toml | 197 | ```toml | ||
| 198 | [auth] | 198 | [auth] | ||
| 199 | enabled = true | 199 | enabled = true | ||
| 200 | - | type = "token" | |||
| 201 | secrets_backend = "sops" | 200 | secrets_backend = "sops" | ||
| 202 | - | username = "admin" | |||
| 203 | - | password_hash = "secrets.yaml:auth.password_hash" | |||
| 204 | token_secret = "secrets.yaml:auth.token_secret" | 201 | token_secret = "secrets.yaml:auth.token_secret" | ||
| 202 | + | # Optional: enable password auth for interactive login | |||
| 203 | + | # username = "admin" | |||
| 204 | + | # password_hash = "secrets.yaml:auth.password_hash" | |||
| 205 | ``` | 205 | ``` | ||
| 206 | 206 | ||||
| 207 | The path format is `file:key.path` where: | 207 | The path format is `file:key.path` where: | ||