detached 6247cf8

Merge pull request #34 from bnema/refactor/ratelimit-clean-architecture

refactor: extract rate limiter to clean architecture port/adapter
commit
6247cf86900bee6fde56e18265c29c9a19f66a62
author
bnema · · 2026-01-18 07:14 UTC
committer
GitHub · · 2026-01-18 07:14 UTC
merge of
7797ecc + 9f5497e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
36 files · +2514 -573
M .github/actions/deploy/README.md +1 -2
@@ -122,12 +122,11 @@ In your repository settings, add:
122122
123123 ### 3. Configure Gordon
124124
125-Ensure your Gordon config uses token authentication:
125+Ensure your Gordon config has authentication enabled:
126126
127127 ```toml
128128 [auth]
129129 enabled = true
130-type = "token"
131130 secrets_backend = "pass" # or "sops"
132131 token_secret = "gordon/auth/token_secret"
133132 ```
@@ -122,12 +122,11 @@ In your repository settings, add:
122 122
123 ### 3. Configure Gordon123 ### 3. Configure Gordon
124 124
125-Ensure your Gordon config uses token authentication:125+Ensure your Gordon config has authentication enabled:
126 126
127 ```toml127 ```toml
128 [auth]128 [auth]
129 enabled = true129 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:
2121 ContainerLogWriter:
2222 TokenStore:
2323 DomainSecretStore:
24+ RateLimiter:
2425 gordon/internal/boundaries/in:
2526 interfaces:
2627 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.
88
99 | Subcommand | Description |
1010 |------------|-------------|
11+| `login` | Authenticate with a remote Gordon server |
1112 | `token generate` | Generate a new JWT token |
1213 | `token list` | List all stored tokens |
1314 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
@@ -16,6 +17,56 @@ Manage Gordon server authentication tokens and passwords.
1617
1718 ---
1819
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+
1970 ## gordon auth token generate
2071
2172 Generate a new JWT authentication token for registry access.
@@ -146,8 +197,9 @@ $2a$10$N9qo8uLOickgx2ZMRZoMye...
146197
147198 Then reference the path in your config:
148199 [auth]
149- type = "password"
200+ username = "deploy"
150201 password_hash = "gordon/auth/password_hash"
202+ token_secret = "gordon/auth/token_secret"
151203 ```
152204
153205 ---
@@ -253,13 +305,17 @@ gordon auth password hash
253305 pass insert gordon/auth/password_hash
254306 # Paste: $2a$10$...
255307
308+# Create token secret
309+pass insert gordon/auth/token_secret
310+# Enter a random 32+ character string
311+
256312 # Configure gordon.toml
257313 [auth]
258314 enabled = true
259-type = "password"
260315 secrets_backend = "pass"
261316 username = "deploy"
262317 password_hash = "gordon/auth/password_hash"
318+token_secret = "gordon/auth/token_secret"
263319 ```
264320
265321 ## 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 generate70 ## 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_hash305 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.toml312 # Configure gordon.toml
257 [auth]313 [auth]
258 enabled = true314 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 ## Related321 ## Related
M docs/cli/remotes.md +47 -0
@@ -12,6 +12,7 @@ Manage saved remote Gordon instances for easier CLI usage.
1212 | `add` | Add a new remote |
1313 | `remove` | Remove a saved remote |
1414 | `use` | Set active remote |
15+| `set-token` | Set or update token for a remote |
1516
1617 ---
1718
@@ -165,6 +166,52 @@ gordon remotes use prod
165166
166167 ---
167168
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+
168215 ## Configuration File
169216
170217 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 File215 ## 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
3434 ```toml
3535 [auth]
3636 enabled = true
37-type = "token"
3837 secrets_backend = "pass"
3938 token_secret = "gordon/auth/token_secret"
4039 token_expiry = "30d"
40+
41+# Optional: enable password authentication
42+username = "deploy"
43+password_hash = "gordon/auth/password_hash"
4144 ```
4245
4346 ## Options
@@ -45,12 +48,22 @@ token_expiry = "30d"
4548 | Option | Type | Default | Description |
4649 |--------|------|---------|-------------|
4750 | `enabled` | bool | `true` | Enable authentication |
48-| `type` | string | `"password"` | Auth type: `"password"` or `"token"` |
4951 | `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 |
5353 | `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.
5467
5568 ## Environment Variables
5669
@@ -81,14 +94,13 @@ See [Secret Providers](./secrets.md) for detailed configuration of each backend.
8194
8295 ## Authentication Types
8396
84-### Token Authentication (Recommended)
97+### Token-Only Mode
8598
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:
87100
88101 ```toml
89102 [auth]
90103 enabled = true
91-type = "token"
92104 secrets_backend = "pass"
93105 token_secret = "gordon/auth/token_secret"
94106 token_expiry = "30d"
@@ -121,19 +133,21 @@ gordon auth token revoke <token-id>
121133 gordon auth token revoke --all
122134 ```
123135
124-### Password Authentication
136+### Password + Token Mode
125137
126-Simple username/password authentication:
138+Password authentication for interactive login, plus tokens for CI/CD:
127139
128140 ```toml
129141 [auth]
130142 enabled = true
131-type = "password"
132143 secrets_backend = "pass"
133144 username = "deploy"
134145 password_hash = "gordon/auth/password_hash"
146+token_secret = "gordon/auth/token_secret"
135147 ```
136148
149+When `username` and `password_hash` are configured, both password and token authentication work.
150+
137151 **Setup:**
138152 ```bash
139153 # Generate bcrypt hash
@@ -143,13 +157,36 @@ gordon auth password hash
143157 # Store hash in secrets backend
144158 pass insert gordon/auth/password_hash
145159 # 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
146164 ```
147165
148166 **Usage:**
149167 ```bash
168+# Docker registry access
150169 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
151174 ```
152175
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+
153190 ## Token Scopes
154191
155192 Tokens can have different permission levels:
@@ -232,12 +269,11 @@ enabled = false
232269
233270 ## Examples
234271
235-### CI/CD Setup
272+### CI/CD Setup (Token-Only)
236273
237274 ```toml
238275 [auth]
239276 enabled = true
240-type = "token"
241277 secrets_backend = "pass"
242278 token_secret = "gordon/auth/token_secret"
243279 ```
@@ -253,15 +289,15 @@ In GitHub Actions:
253289 run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }}
254290 ```
255291
256-### Production with Password
292+### Production with Password + Token
257293
258294 ```toml
259295 [auth]
260296 enabled = true
261-type = "password"
262297 secrets_backend = "pass"
263298 username = "deploy"
264299 password_hash = "gordon/auth/password_hash"
300+token_secret = "gordon/auth/token_secret"
265301 ```
266302
267303 ### Enterprise with SOPS
@@ -269,7 +305,6 @@ password_hash = "gordon/auth/password_hash"
269305 ```toml
270306 [auth]
271307 enabled = true
272-type = "token"
273308 secrets_backend = "sops"
274309 token_secret = "gordon/auth/token_secret"
275310 ```
@@ -34,10 +34,13 @@ enabled = false
34 ```toml34 ```toml
35 [auth]35 [auth]
36 enabled = true36 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 ## Options46 ## 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 Variables68 ## Environment Variables
56 69
@@ -81,14 +94,13 @@ See [Secret Providers](./secrets.md) for detailed configuration of each backend.
81 94
82 ## Authentication Types95 ## 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 ```toml101 ```toml
89 [auth]102 [auth]
90 enabled = true103 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 --all133 gordon auth token revoke --all
122 ```134 ```
123 135
124-### Password Authentication136+### Password + Token Mode
125 137
126-Simple username/password authentication:138+Password authentication for interactive login, plus tokens for CI/CD:
127 139
128 ```toml140 ```toml
129 [auth]141 [auth]
130 enabled = true142 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 ```bash152 ```bash
139 # Generate bcrypt hash153 # Generate bcrypt hash
@@ -143,13 +157,36 @@ gordon auth password hash
143 # Store hash in secrets backend157 # Store hash in secrets backend
144 pass insert gordon/auth/password_hash158 pass insert gordon/auth/password_hash
145 # Paste the bcrypt hash159 # 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 ```bash167 ```bash
168+# Docker registry access
150 docker login -u deploy -p <password> registry.mydomain.com169 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 Scopes190 ## 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 ## Examples270 ## Examples
234 271
235-### CI/CD Setup272+### CI/CD Setup (Token-Only)
236 273
237 ```toml274 ```toml
238 [auth]275 [auth]
239 enabled = true276 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 Password292+### Production with Password + Token
257 293
258 ```toml294 ```toml
259 [auth]295 [auth]
260 enabled = true296 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 SOPS303 ### Enterprise with SOPS
@@ -269,7 +305,6 @@ password_hash = "gordon/auth/password_hash"
269 ```toml305 ```toml
270 [auth]306 [auth]
271 enabled = true307 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
3838 # Authentication (includes secrets backend)
3939 [auth]
4040 enabled = true # Enable registry authentication (default: true)
41-type = "token" # "password" or "token"
4241 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)
4445 # username = "deploy"
4546 # 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
4947
5048 # API rate limiting
5149 [api.rate_limit]
@@ -139,7 +137,6 @@ enabled = false # Auto-create routes from image names
139137 | `server.registry_port` | `5000` |
140138 | `server.data_dir` | `~/.gordon` |
141139 | `auth.enabled` | `true` |
142-| `auth.type` | `"password"` |
143140 | `auth.secrets_backend` | `"unsafe"` |
144141 | `auth.token_expiry` | `"30d"` |
145142 | `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 limiting48 # 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
1919 # =============================================================================
2020 [auth]
2121 enabled = true # Enable registry authentication (default: true)
22-type = "password" # "password" or "token"
2322 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)
2724 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
2828
2929 # =============================================================================
3030 # API (applies to both Registry and Admin endpoints)
@@ -126,7 +126,6 @@ preserve = true # Keep volumes when containers are
126126 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
127127 | `server.data_dir` | `~/.gordon` | Data directory |
128128 | `auth.enabled` | `true` | Enable authentication |
129-| `auth.type` | `"password"` | Auth type |
130129 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
131130 | `auth.token_expiry` | `"720h"` | 30 days |
132131 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
@@ -166,7 +165,6 @@ Examples:
166165 GORDON_SERVER_PORT=8080
167166 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
168167 GORDON_AUTH_ENABLED=true
169-GORDON_AUTH_TYPE=token
170168 GORDON_LOGGING_LEVEL=debug
171169 GORDON_NETWORK_ISOLATION_ENABLED=true
172170 ```
@@ -200,13 +198,6 @@ gordon serve
200198 | `"if-tag-changed"` | Pull only if image tag differs from running container |
201199 | `"never"` | Never pull, use local image only |
202200
203-## Auth Type Options
204-
205-| Value | Description |
206-|-------|-------------|
207-| `"password"` | HTTP Basic authentication with username/password |
208-| `"token"` | JWT token authentication |
209-
210201 ## Secrets Backend Options
211202
212203 | 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 auth23+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=8080165 GORDON_SERVER_PORT=8080
167 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com166 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
168 GORDON_AUTH_ENABLED=true167 GORDON_AUTH_ENABLED=true
169-GORDON_AUTH_TYPE=token
170 GORDON_LOGGING_LEVEL=debug168 GORDON_LOGGING_LEVEL=debug
171 GORDON_NETWORK_ISOLATION_ENABLED=true169 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 Options201 ## 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}
139139 ```toml
140140 [auth]
141141 enabled = true
142-type = "token"
143142 secrets_backend = "pass"
144143 token_secret = "gordon/auth/token_secret"
145144 ```
@@ -166,7 +165,6 @@ secrets_backend = "unsafe"
166165 ```toml
167166 [auth]
168167 enabled = true
169-type = "token"
170168 secrets_backend = "sops"
171169 token_secret = "gordon/auth/token_secret"
172170 ```
@@ -139,7 +139,6 @@ API_SECRET=${sops:production.yaml:api.secret.key}
139 ```toml139 ```toml
140 [auth]140 [auth]
141 enabled = true141 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 ```toml165 ```toml
167 [auth]166 [auth]
168 enabled = true167 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 (
1313 "github.com/spf13/viper"
1414 "golang.org/x/term"
1515
16+ "gordon/internal/adapters/in/cli/remote"
17+ "gordon/internal/adapters/in/cli/ui/styles"
1618 "gordon/internal/adapters/out/secrets"
1719 "gordon/internal/adapters/out/tokenstore"
1820 "gordon/internal/app"
@@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command {
3234 cmd.AddCommand(newAuthTokenCmd())
3335 cmd.AddCommand(newAuthPasswordCmd())
3436 cmd.AddCommand(newAuthInternalCmd())
37+ cmd.AddCommand(newAuthLoginCmd())
3538
3639 return cmd
3740 }
@@ -197,6 +200,121 @@ available while Gordon is running.`,
197200 }
198201 }
199202
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+
200318 // runShowInternalAuth displays the internal registry credentials.
201319 func runShowInternalAuth() error {
202320 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 cmd39 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`,
2828 cmd.AddCommand(newRemotesAddCmd())
2929 cmd.AddCommand(newRemotesRemoveCmd())
3030 cmd.AddCommand(newRemotesUseCmd())
31+ cmd.AddCommand(newRemotesSetTokenCmd())
3132
3233 return cmd
3334 }
@@ -246,6 +247,49 @@ Examples:
246247 }
247248 }
248249
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+
249293 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.
250294 func normalizeURL(url string) string {
251295 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 cmd33 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
33 import (
44 "context"
55 "encoding/json"
6+ "net"
67 "net/http"
78 "strings"
89
910 "github.com/bnema/zerowrap"
10- "golang.org/x/time/rate"
1111
1212 "gordon/internal/adapters/dto"
13+ "gordon/internal/adapters/in/http/middleware"
1314 "gordon/internal/boundaries/in"
15+ "gordon/internal/boundaries/out"
1416 "gordon/internal/domain"
1517 )
1618
@@ -25,14 +27,21 @@ const (
2527 )
2628
2729 // 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 {
3039 return func(next http.Handler) http.Handler {
3140 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3241 ctx := r.Context()
3342
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") {
3645 w.Header().Set("Content-Type", "application/json")
3746 w.Header().Set("Retry-After", "1")
3847 w.WriteHeader(http.StatusTooManyRequests)
@@ -40,6 +49,18 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.
4049 return
4150 }
4251
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+
4364 // Check if auth is enabled
4465 if !authSvc.IsEnabled() {
4566 // 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 return49 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 enabled64 // Check if auth is enabled
44 if !authSvc.IsEnabled() {65 if !authSvc.IsEnabled() {
45 // Auth disabled, log warning and allow all requests66 // 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
23
34 import (
45 "encoding/json"
@@ -19,34 +20,157 @@ type InternalAuth struct {
1920 Password string
2021 }
2122
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 {
2425 authSvc in.AuthService
2526 internalAuth InternalAuth
2627 log zerowrap.Logger
2728 }
2829
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{
3233 authSvc: authSvc,
3334 internalAuth: internalAuth,
3435 log: log,
3536 }
3637 }
3738
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"`
4143 }
4244
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) {
46170 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
47171 zerowrap.FieldLayer: "adapter",
48172 zerowrap.FieldAdapter: "http",
49- zerowrap.FieldHandler: "token",
173+ zerowrap.FieldHandler: "auth",
50174 zerowrap.FieldMethod: r.Method,
51175 zerowrap.FieldPath: r.URL.Path,
52176 })
@@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
55179 if r.Method != http.MethodGet {
56180 w.Header().Set("Content-Type", "application/json")
57181 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"})
59183 return
60184 }
61185
@@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
100224 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
101225 w.Header().Set("Content-Type", "application/json")
102226 w.WriteHeader(http.StatusUnauthorized)
103- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
227+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
104228 return
105229 }
106230
107231 // Parse requested scopes from query parameter
108- // Format: scope=repository:name:action1,action2 (can appear multiple times)
109232 requestedScopes := h.parseRequestedScopes(r, log)
110233
111234 // Generate a short-lived access token (5 minutes) - not stored
@@ -114,7 +237,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
114237 log.Error().Err(err).Msg("failed to generate access token")
115238 w.Header().Set("Content-Type", "application/json")
116239 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"})
118241 return
119242 }
120243
@@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
138261 }
139262
140263 // 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) {
142265 // For anonymous access, we issue a very short-lived token with limited scope
143266 response := dto.TokenResponse{
144267 Token: "", // Empty token indicates limited access
@@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool {
168291
169292 // parseRequestedScopes extracts and validates scope parameters from the request.
170293 // 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 {
173296 scopeParams := r.URL.Query()["scope"]
174297
175298 if len(scopeParams) == 0 {
@@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger
215338 }
216339
217340 // 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 {
219342 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
220343 return false
221344 }
@@ -1,4 +1,5 @@
1-package registry1+// 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 string20 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.AuthService25 authSvc in.AuthService
25 internalAuth InternalAuth26 internalAuth InternalAuth
26 log zerowrap.Logger27 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 return183 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 return228 return
105 }229 }
106 230
107 // Parse requested scopes from query parameter231 // 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 stored234 // 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 return241 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 scope265 // 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 access267 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:actions293 // 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:pull294+// 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 false343 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
144144 ctx := r.Context()
145145
146146 // 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)
147149 var authenticated bool
148150 var tokenClaims *domain.TokenClaims
149151 switch authSvc.GetAuthType() {
150152 case domain.AuthTypePassword:
153+ // Try password auth first
151154 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+ }
152159 case domain.AuthTypeToken:
153160 authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
154161 default:
@@ -410,7 +417,7 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
410417 } else if r.TLS != nil {
411418 scheme = "https"
412419 }
413- realm := scheme + "://" + realmHost + "/v2/token"
420+ realm := scheme + "://" + realmHost + "/auth/token"
414421 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
415422 default:
416423 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 type146 // 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 bool149 var authenticated bool
148 var tokenClaims *domain.TokenClaims150 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
33
44 import (
55 "encoding/json"
6- "net"
76 "net/http"
8- "strings"
9- "sync"
107
11- "golang.org/x/time/rate"
8+ "github.com/bnema/zerowrap"
129
1310 "gordon/internal/adapters/dto"
11+ "gordon/internal/adapters/in/http/middleware"
12+ "gordon/internal/boundaries/out"
1413 )
1514
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-
9015 // 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 {
9426 return func(next http.Handler) http.Handler {
95- return next // Pass through if disabled
27+ return next
9628 }
9729 }
9830
99- globalLimiter := rate.NewLimiter(rate.Limit(config.GlobalRPS), config.Burst)
100- ipLimiter := newIPRateLimiter(config.PerIPRPS, config.Burst)
101-
10231 // Parse trusted proxy CIDRs once at middleware creation
103- trustedNets := parseTrustedProxies(config.TrustedProxies)
32+ trustedNets := middleware.ParseTrustedProxies(trustedProxies)
10433
10534 return func(next http.Handler) http.Handler {
10635 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36+ ctx := r.Context()
37+
10738 // Check global rate limit
108- if !globalLimiter.Allow() {
39+ if !globalLimiter.Allow(ctx, "global") {
10940 sendRateLimitError(w)
11041 return
11142 }
11243
11344 // 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) {
11647 sendRateLimitError(w)
11748 return
11849 }
@@ -122,47 +53,6 @@ func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler
12253 }
12354 }
12455
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-
16656 // sendRateLimitError sends an HTTP 429 response in Docker Registry API format.
16757 func sendRateLimitError(w http.ResponseWriter) {
16858 w.Header().Set("Content-Type", "application/json")
@@ -176,35 +66,3 @@ func sendRateLimitError(w http.ResponseWriter) {
17666 }},
17767 })
17868 }
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 disabled27+ 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 creation31 // 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 limit38 // Check global rate limit
108- if !globalLimiter.Allow() {39+ if !globalLimiter.Allow(ctx, "global") {
109 sendRateLimitError(w)40 sendRateLimitError(w)
110 return41 return
111 }42 }
112 43
113 // Check per-IP rate limit44 // 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 return48 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 @@
11 package registry
22
33 import (
4+ "context"
45 "net"
56 "net/http"
67 "net/http/httptest"
78 "testing"
89
910 "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"
1115 )
1216
1317 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) {
1920 w.WriteHeader(http.StatusOK)
2021 })
2122
22- middleware := RateLimitMiddleware(config)
23+ middleware := RateLimitMiddleware(nil, nil, nil, testLogger())
2324 wrappedHandler := middleware(handler)
2425
2526 // Should pass through without rate limiting
26- for i := 0; i < 1000; i++ {
27+ for i := 0; i < 100; i++ {
2728 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
2829 rec := httptest.NewRecorder()
2930 wrappedHandler.ServeHTTP(rec, req)
@@ -31,104 +32,196 @@ func TestRateLimitMiddleware_Disabled(t *testing.T) {
3132 }
3233 }
3334
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)
4141
42- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
4343 w.WriteHeader(http.StatusOK)
4444 })
4545
46- middleware := RateLimitMiddleware(config)
46+ middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
4747 wrappedHandler := middleware(handler)
4848
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)
5553
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")
6257 }
6358
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)
7166
72- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
7368 w.WriteHeader(http.StatusOK)
7469 })
7570
76- middleware := RateLimitMiddleware(config)
71+ middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
7772 wrappedHandler := middleware(handler)
7873
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)
8578
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)
92157 }
93158
94159 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+}
101184
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) {
103192 w.WriteHeader(http.StatusOK)
104193 })
105194
106- middleware := RateLimitMiddleware(config)
195+ middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, log)
107196 wrappedHandler := middleware(handler)
108197
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")
114213
115- // Second request gets rate limited
214+ // Request from different IP should still succeed (independent limit)
116215 req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
117- req2.RemoteAddr = "192.168.1.100:12346"
216+ req2.RemoteAddr = "192.168.1.101:12345"
118217 rec2 := httptest.NewRecorder()
119218 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")
127220 }
128221
129222 func TestGetClientIP(t *testing.T) {
130223 // 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"})
132225 noTrustedNets := []*net.IPNet{}
133226
134227 tests := []struct {
@@ -221,7 +314,7 @@ func TestGetClientIP(t *testing.T) {
221314 req.Header.Set("X-Real-IP", tt.xRealIP)
222315 }
223316
224- ip := getClientIP(req, tt.trustedNets)
317+ ip := middleware.GetClientIP(req, tt.trustedNets)
225318 assert.Equal(t, tt.wantIP, ip)
226319 })
227320 }
@@ -280,18 +373,9 @@ func TestParseTrustedProxies(t *testing.T) {
280373
281374 for _, tt := range tests {
282375 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)
285378 assert.Equal(t, tt.want, got)
286379 })
287380 }
288381 }
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 registry1 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 limiting26 // 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 limit38+
38- PerIPRPS: 1, // Very low per-IP limit for testing39+ // Global limiter returns false (rate limited)
39- Burst: 100, // High burst to not interfere40+ 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 succeed49+ 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 testing62+
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 succeed74+ 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 IP79+ // 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 succeeds198+ // 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 limited214+ // 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 tests223 // 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 (
77 "encoding/hex"
88 "encoding/json"
99 "fmt"
10+ "net"
1011 "net/http"
1112 "os"
1213 "os/signal"
@@ -27,13 +28,13 @@ import (
2728 "gordon/internal/adapters/out/filesystem"
2829 "gordon/internal/adapters/out/httpprober"
2930 "gordon/internal/adapters/out/logwriter"
31+ "gordon/internal/adapters/out/ratelimit"
3032 "gordon/internal/adapters/out/secrets"
3133 "gordon/internal/adapters/out/tokenstore"
3234
33- "golang.org/x/time/rate"
34-
3535 // Adapters - Input
3636 "gordon/internal/adapters/in/http/admin"
37+ authhandler "gordon/internal/adapters/in/http/auth"
3738 "gordon/internal/adapters/in/http/middleware"
3839 "gordon/internal/adapters/in/http/registry"
3940
@@ -126,7 +127,7 @@ type services struct {
126127 registrySvc *registrySvc.Service
127128 proxySvc *proxy.Service
128129 authSvc *auth.Service
129- tokenHandler *registry.TokenHandler
130+ authHandler *authhandler.Handler
130131 adminHandler *admin.Handler
131132 internalRegUser string
132133 internalRegPass string
@@ -300,11 +301,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
300301
301302 // Create token handler for registry token endpoint
302303 if svc.authSvc != nil {
303- internalAuth := registry.InternalAuth{
304+ internalAuth := authhandler.InternalAuth{
304305 Username: svc.internalRegUser,
305306 Password: svc.internalRegPass,
306307 }
307- svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
308+ svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
308309 }
309310
310311 // Determine env directory for admin API
@@ -567,11 +568,11 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
567568 return nil, nil, nil
568569 }
569570
570- authType := resolveAuthType(cfg.Auth.Type)
571+ authType := resolveAuthType(cfg)
571572 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
572573 dataDir := resolveDataDir(cfg.Server.DataDir)
573574
574- store, err := createTokenStore(authType, backend, dataDir, log)
575+ store, err := createTokenStore(backend, dataDir, log)
575576 if err != nil {
576577 return nil, nil, err
577578 }
@@ -591,11 +592,26 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
591592 return store, authSvc, nil
592593 }
593594
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" {
596602 return domain.AuthTypeToken
597603 }
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
599615 }
600616
601617 func resolveSecretsBackend(backend string) domain.SecretsBackend {
@@ -618,11 +634,8 @@ func resolveDataDir(dataDir string) string {
618634 return dataDir
619635 }
620636
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
626639 store, err := tokenstore.NewStore(backend, dataDir, log)
627640 if err != nil {
628641 return nil, log.WrapErr(err, "failed to create token store")
@@ -637,8 +650,16 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
637650 Username: cfg.Auth.Username,
638651 }
639652
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 {
642663 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
643664 if err != nil {
644665 return auth.Config{}, err
@@ -647,13 +668,6 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
647668 return auth.Config{}, errAuthNotConfigured()
648669 }
649670 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
657671 }
658672
659673 return authConfig, nil
@@ -713,7 +727,7 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
713727
714728 // Fall back to config-specified path via secrets backend
715729 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)
717731 }
718732
719733 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
871885 registryMiddlewares := []func(http.Handler) http.Handler{
872886 middleware.PanicRecovery(log),
873887 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)
874904 }
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)
885905 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
886906
887907 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -895,18 +915,21 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
895915
896916 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
897917
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)
900922 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(
905927 middleware.PanicRecovery(log),
906928 middleware.RequestLogger(log),
929+ middleware.SecurityHeaders,
907930 rateLimitMiddleware,
908- )(svc.tokenHandler)
909- registryMux.Handle("/v2/token", tokenWithMiddleware)
931+ )(svc.authHandler)
932+ registryMux.Handle("/auth/", authWithMiddleware)
910933 }
911934 registryMux.Handle("/v2/", registryWithMiddleware)
912935
@@ -915,15 +938,19 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
915938 adminMiddlewares := []func(http.Handler) http.Handler{
916939 middleware.PanicRecovery(log),
917940 middleware.RequestLogger(log),
941+ middleware.SecurityHeaders,
918942 }
919943 // Add admin auth middleware if auth is enabled
920944 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
923948 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)
925952 }
926- adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
953+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
927954 }
928955 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
929956 registryMux.Handle("/admin/", adminWithMiddleware)
@@ -932,6 +959,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
932959 proxyMiddlewares := []func(http.Handler) http.Handler{
933960 middleware.PanicRecovery(log),
934961 middleware.RequestLogger(log),
962+ middleware.SecurityHeaders,
935963 middleware.CORS,
936964 }
937965
@@ -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 - Input35 // 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.Service127 registrySvc *registrySvc.Service
127 proxySvc *proxy.Service128 proxySvc *proxy.Service
128 authSvc *auth.Service129 authSvc *auth.Service
129- tokenHandler *registry.TokenHandler130+ authHandler *authhandler.Handler
130 adminHandler *admin.Handler131 adminHandler *admin.Handler
131 internalRegUser string132 internalRegUser string
132 internalRegPass string133 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 endpoint302 // 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 API311 // 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, nil568 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, err577 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, nil592 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.AuthTypeToken602 return domain.AuthTypeToken
597 }603 }
598- return domain.AuthTypePassword604+ 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 dataDir634 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{}, err665 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 = hash670 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, nil673 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 backend728 // 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 tokens924+ // Auth endpoints are NOT protected by auth - they're where clients authenticate
903- // but it still needs rate limiting to prevent brute force attacks925+ // 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 enabled943 // 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 config945+ // Create rate limiters for admin API - uses same config as registry
922- var adminLimiter *rate.Limiter946+ 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{
3939 type Config struct {
4040 RegistryDomain string
4141 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
4542 }
4643
4744 // Service implements the ProxyService interface.
@@ -325,11 +322,6 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
325322 },
326323 func(resp *http.Response) error {
327324 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- }
333325 return nil
334326 },
335327 )
@@ -39,9 +39,6 @@ var proxyTransport = &http.Transport{
39 type Config struct {39 type Config struct {
40 RegistryDomain string40 RegistryDomain string
41 RegistryPort int41 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 nil325 return nil
334 },326 },
335 )327 )
M wiki/examples/index.md +0 -1
@@ -46,7 +46,6 @@ gordon_domain = "gordon.company.com"
4646
4747 [auth]
4848 enabled = true
49-type = "token"
5049 secrets_backend = "pass"
5150 token_secret = "gordon/auth/token_secret"
5251
@@ -46,7 +46,6 @@ gordon_domain = "gordon.company.com"
46 46
47 [auth]47 [auth]
48 enabled = true48 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"
2222 # Authentication with pass backend (recommended)
2323 [auth]
2424 enabled = true
25-type = "token"
2625 secrets_backend = "pass"
2726 token_secret = "gordon/auth/token_secret"
2827
@@ -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 = true24 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
151151 | `--token <TOKEN>` | Store token directly in config |
152152 | `--token-env <VAR>` | Store env variable name (token resolved at runtime) |
153153
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+
154185 #### Remove a Remote
155186
156187 ```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 Remote185 #### Remove a Remote
155 186
156 ```bash187 ```bash
M wiki/guides/running-in-container.md +3 -3
@@ -309,11 +309,11 @@ volumes:
309309 ```toml
310310 [auth]
311311 enabled = true
312-type = "token"
313312 secrets_backend = "pass"
314-username = "admin"
315-password_hash = "gordon/auth/password_hash"
316313 token_secret = "gordon/auth/token_secret"
314+# Optional: enable password auth for interactive login
315+# username = "admin"
316+# password_hash = "gordon/auth/password_hash"
317317 ```
318318
319319 ### GPG Agent (Optional)
@@ -309,11 +309,11 @@ volumes:
309 ```toml309 ```toml
310 [auth]310 [auth]
311 enabled = true311 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
9999 ```toml
100100 [auth]
101101 enabled = true
102-type = "token"
103102 secrets_backend = "pass"
104-username = "admin"
105-password_hash = "gordon/auth/password_hash"
106103 token_secret = "gordon/auth/token_secret"
104+# Optional: enable password auth for interactive login
105+# username = "admin"
106+# password_hash = "gordon/auth/password_hash"
107107 ```
108108
109109 ### Using Secrets in Environment Files
@@ -99,11 +99,11 @@ pass generate gordon/auth/token_secret 32
99 ```toml99 ```toml
100 [auth]100 [auth]
101 enabled = true101 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 Files109 ### Using Secrets in Environment Files
M wiki/guides/secrets-sops.md +3 -3
@@ -197,11 +197,11 @@ sops edit secrets.yaml
197197 ```toml
198198 [auth]
199199 enabled = true
200-type = "token"
201200 secrets_backend = "sops"
202-username = "admin"
203-password_hash = "secrets.yaml:auth.password_hash"
204201 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"
205205 ```
206206
207207 The path format is `file:key.path` where:
@@ -197,11 +197,11 @@ sops edit secrets.yaml
197 ```toml197 ```toml
198 [auth]198 [auth]
199 enabled = true199 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:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close