feat(adapters/in): add authentication middleware, token endpoint and CLI commands
Add user-facing authentication interfaces: - RegistryAuthV2 middleware supporting password and token auth - /v2/token endpoint for Docker registry token server - CLI commands: gordon auth token generate/list/revoke - CLI command: gordon auth password hash
- commit
- 136d617da93a2ddd40f2192f5cee689fff2b505c
- author
- brice · · 2026-01-12 19:58 UTC
- committer
- brice · · 2026-01-12 17:05 UTC
- parent
- 70e008c
- signature
- unsigned
3 files · +696 -0
A internal/adapters/in/cli/auth.go +385 -0
| @@ -0,0 +1,385 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "syscall" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | "github.com/spf13/viper" | |
| 15 | + | "golang.org/x/term" | |
| 16 | + | ||
| 17 | + | "gordon/internal/adapters/out/tokenstore" | |
| 18 | + | "gordon/internal/domain" | |
| 19 | + | "gordon/internal/usecase/auth" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // newAuthCmd creates the auth command group. | |
| 23 | + | func newAuthCmd() *cobra.Command { | |
| 24 | + | cmd := &cobra.Command{ | |
| 25 | + | Use: "auth", | |
| 26 | + | Short: "Manage registry authentication", | |
| 27 | + | Long: `Commands for managing registry authentication tokens and passwords.`, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | cmd.AddCommand(newAuthTokenCmd()) | |
| 31 | + | cmd.AddCommand(newAuthPasswordCmd()) | |
| 32 | + | ||
| 33 | + | return cmd | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // newAuthTokenCmd creates the token subcommand group. | |
| 37 | + | func newAuthTokenCmd() *cobra.Command { | |
| 38 | + | cmd := &cobra.Command{ | |
| 39 | + | Use: "token", | |
| 40 | + | Short: "Manage authentication tokens", | |
| 41 | + | Long: `Commands for generating, listing, and revoking authentication tokens.`, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | cmd.AddCommand(newTokenGenerateCmd()) | |
| 45 | + | cmd.AddCommand(newTokenListCmd()) | |
| 46 | + | cmd.AddCommand(newTokenRevokeCmd()) | |
| 47 | + | ||
| 48 | + | return cmd | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // newTokenGenerateCmd creates the token generate command. | |
| 52 | + | func newTokenGenerateCmd() *cobra.Command { | |
| 53 | + | var ( | |
| 54 | + | subject string | |
| 55 | + | scopes string | |
| 56 | + | expiry string | |
| 57 | + | configPath string | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | cmd := &cobra.Command{ | |
| 61 | + | Use: "generate", | |
| 62 | + | Short: "Generate a new authentication token", | |
| 63 | + | Long: `Generate a new JWT authentication token for registry access. | |
| 64 | + | ||
| 65 | + | The token will be stored in the configured secrets backend and can be used | |
| 66 | + | for docker login as: docker login -u <subject> -p <token> | |
| 67 | + | ||
| 68 | + | Use --expiry=0 to generate a token that never expires (useful for CI).`, | |
| 69 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 70 | + | return runTokenGenerate(subject, scopes, expiry, configPath) | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | |
| 75 | + | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | |
| 76 | + | cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)") | |
| 77 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 78 | + | ||
| 79 | + | _ = cmd.MarkFlagRequired("subject") | |
| 80 | + | ||
| 81 | + | return cmd | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // newTokenListCmd creates the token list command. | |
| 85 | + | func newTokenListCmd() *cobra.Command { | |
| 86 | + | var configPath string | |
| 87 | + | ||
| 88 | + | cmd := &cobra.Command{ | |
| 89 | + | Use: "list", | |
| 90 | + | Short: "List all authentication tokens", | |
| 91 | + | Long: `List all stored authentication tokens with their subjects and expiry information.`, | |
| 92 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 93 | + | return runTokenList(configPath) | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 98 | + | ||
| 99 | + | return cmd | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // newTokenRevokeCmd creates the token revoke command. | |
| 103 | + | func newTokenRevokeCmd() *cobra.Command { | |
| 104 | + | var configPath string | |
| 105 | + | ||
| 106 | + | cmd := &cobra.Command{ | |
| 107 | + | Use: "revoke <token-id>", | |
| 108 | + | Short: "Revoke an authentication token", | |
| 109 | + | Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`, | |
| 110 | + | Args: cobra.ExactArgs(1), | |
| 111 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 112 | + | return runTokenRevoke(args[0], configPath) | |
| 113 | + | }, | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 117 | + | ||
| 118 | + | return cmd | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // newAuthPasswordCmd creates the password subcommand group. | |
| 122 | + | func newAuthPasswordCmd() *cobra.Command { | |
| 123 | + | cmd := &cobra.Command{ | |
| 124 | + | Use: "password", | |
| 125 | + | Short: "Password utilities", | |
| 126 | + | Long: `Utilities for managing password authentication.`, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | cmd.AddCommand(newPasswordHashCmd()) | |
| 130 | + | ||
| 131 | + | return cmd | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // newPasswordHashCmd creates the password hash command. | |
| 135 | + | func newPasswordHashCmd() *cobra.Command { | |
| 136 | + | return &cobra.Command{ | |
| 137 | + | Use: "hash", | |
| 138 | + | Short: "Generate a bcrypt hash for a password", | |
| 139 | + | Long: `Interactively prompt for a password and output its bcrypt hash. | |
| 140 | + | ||
| 141 | + | The hash can be stored in your secrets backend and referenced in the config: | |
| 142 | + | ||
| 143 | + | [registry_auth] | |
| 144 | + | type = "password" | |
| 145 | + | password_hash = "gordon/registry/password_hash"`, | |
| 146 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 147 | + | return runPasswordHash() | |
| 148 | + | }, | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // runTokenGenerate generates a new authentication token. | |
| 153 | + | func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |
| 154 | + | cfg, err := loadAuthConfig(configPath) | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Parse expiry | |
| 160 | + | var expiry time.Duration | |
| 161 | + | if expiryStr != "0" { | |
| 162 | + | expiry, err = time.ParseDuration(expiryStr) | |
| 163 | + | if err != nil { | |
| 164 | + | return fmt.Errorf("invalid expiry duration: %w", err) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Parse scopes | |
| 169 | + | scopes := strings.Split(scopesStr, ",") | |
| 170 | + | for i := range scopes { | |
| 171 | + | scopes[i] = strings.TrimSpace(scopes[i]) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Create auth service | |
| 175 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |
| 176 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |
| 177 | + | if err != nil { | |
| 178 | + | return err | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Generate token | |
| 182 | + | ctx := context.Background() | |
| 183 | + | token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry) | |
| 184 | + | if err != nil { | |
| 185 | + | return fmt.Errorf("failed to generate token: %w", err) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | fmt.Println("Token generated successfully!") | |
| 189 | + | fmt.Printf("Subject: %s\n", subject) | |
| 190 | + | fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", ")) | |
| 191 | + | if expiry == 0 { | |
| 192 | + | fmt.Println("Expiry: never") | |
| 193 | + | } else { | |
| 194 | + | fmt.Printf("Expiry: %s\n", expiry) | |
| 195 | + | } | |
| 196 | + | fmt.Println() | |
| 197 | + | fmt.Println("Token (use as password with docker login):") | |
| 198 | + | fmt.Println(token) | |
| 199 | + | fmt.Println() | |
| 200 | + | fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject) | |
| 201 | + | ||
| 202 | + | return nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // runTokenList lists all stored tokens. | |
| 206 | + | func runTokenList(configPath string) error { | |
| 207 | + | cfg, err := loadAuthConfig(configPath) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |
| 213 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |
| 214 | + | if err != nil { | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | ctx := context.Background() | |
| 219 | + | tokens, err := authSvc.ListTokens(ctx) | |
| 220 | + | if err != nil { | |
| 221 | + | return fmt.Errorf("failed to list tokens: %w", err) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | if len(tokens) == 0 { | |
| 225 | + | fmt.Println("No tokens found.") | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | |
| 230 | + | fmt.Println(strings.Repeat("-", 90)) | |
| 231 | + | ||
| 232 | + | for _, t := range tokens { | |
| 233 | + | expiry := "never" | |
| 234 | + | if !t.ExpiresAt.IsZero() { | |
| 235 | + | expiry = t.ExpiresAt.Format("2006-01-02 15:04") | |
| 236 | + | } | |
| 237 | + | revoked := "no" | |
| 238 | + | if t.Revoked { | |
| 239 | + | revoked = "yes" | |
| 240 | + | } | |
| 241 | + | fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | return nil | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // runTokenRevoke revokes a token by ID. | |
| 248 | + | func runTokenRevoke(tokenID, configPath string) error { | |
| 249 | + | cfg, err := loadAuthConfig(configPath) | |
| 250 | + | if err != nil { | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |
| 255 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |
| 256 | + | if err != nil { | |
| 257 | + | return err | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | ctx := context.Background() | |
| 261 | + | if err := authSvc.RevokeToken(ctx, tokenID); err != nil { | |
| 262 | + | return fmt.Errorf("failed to revoke token: %w", err) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | fmt.Printf("Token %s has been revoked.\n", tokenID) | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // runPasswordHash generates a bcrypt hash for a password. | |
| 270 | + | func runPasswordHash() error { | |
| 271 | + | fmt.Print("Enter password: ") | |
| 272 | + | ||
| 273 | + | // Read password without echo | |
| 274 | + | passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) | |
| 275 | + | if err != nil { | |
| 276 | + | // Fallback for non-terminal input | |
| 277 | + | reader := bufio.NewReader(os.Stdin) | |
| 278 | + | password, err := reader.ReadString('\n') | |
| 279 | + | if err != nil { | |
| 280 | + | return fmt.Errorf("failed to read password: %w", err) | |
| 281 | + | } | |
| 282 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 283 | + | } | |
| 284 | + | fmt.Println() | |
| 285 | + | ||
| 286 | + | if len(passwordBytes) == 0 { | |
| 287 | + | return fmt.Errorf("password cannot be empty") | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // Create a temporary auth service to generate the hash | |
| 291 | + | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |
| 292 | + | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |
| 293 | + | if err != nil { | |
| 294 | + | return fmt.Errorf("failed to generate hash: %w", err) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | fmt.Println() | |
| 298 | + | fmt.Println("Bcrypt hash (store in your secrets backend):") | |
| 299 | + | fmt.Println(hash) | |
| 300 | + | fmt.Println() | |
| 301 | + | fmt.Println("Then reference the path in your config:") | |
| 302 | + | fmt.Println(" [registry_auth]") | |
| 303 | + | fmt.Println(" type = \"password\"") | |
| 304 | + | fmt.Println(" password_hash = \"gordon/registry/password_hash\"") | |
| 305 | + | ||
| 306 | + | return nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // cliConfig holds the configuration needed for CLI commands. | |
| 310 | + | type cliConfig struct { | |
| 311 | + | Backend domain.SecretsBackend | |
| 312 | + | DataDir string | |
| 313 | + | TokenSecret []byte | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // loadAuthConfig loads the configuration needed for auth CLI commands. | |
| 317 | + | func loadAuthConfig(configPath string) (*cliConfig, error) { | |
| 318 | + | v := viper.New() | |
| 319 | + | ||
| 320 | + | // Set defaults | |
| 321 | + | v.SetDefault("server.data_dir", "/var/lib/gordon") | |
| 322 | + | v.SetDefault("secrets.backend", "unsafe") | |
| 323 | + | ||
| 324 | + | if configPath != "" { | |
| 325 | + | v.SetConfigFile(configPath) | |
| 326 | + | } else { | |
| 327 | + | v.SetConfigName("gordon") | |
| 328 | + | v.SetConfigType("yaml") | |
| 329 | + | v.AddConfigPath("/etc/gordon") | |
| 330 | + | v.AddConfigPath("$HOME/.gordon") | |
| 331 | + | v.AddConfigPath(".") | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | if err := v.ReadInConfig(); err != nil { | |
| 335 | + | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { | |
| 336 | + | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 337 | + | } | |
| 338 | + | // Config file not found is OK for CLI commands | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | cfg := &cliConfig{ | |
| 342 | + | DataDir: v.GetString("server.data_dir"), | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // Determine backend | |
| 346 | + | switch v.GetString("secrets.backend") { | |
| 347 | + | case "pass": | |
| 348 | + | cfg.Backend = domain.SecretsBackendPass | |
| 349 | + | case "sops": | |
| 350 | + | cfg.Backend = domain.SecretsBackendSops | |
| 351 | + | default: | |
| 352 | + | cfg.Backend = domain.SecretsBackendUnsafe | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Load token secret if specified | |
| 356 | + | tokenSecretPath := v.GetString("registry_auth.token_secret") | |
| 357 | + | if tokenSecretPath != "" { | |
| 358 | + | // For CLI, we generate a default secret if not available | |
| 359 | + | // In production, the secret should be loaded from the secrets backend | |
| 360 | + | cfg.TokenSecret = []byte(tokenSecretPath) // Use path as seed for now | |
| 361 | + | } else { | |
| 362 | + | // Generate a default secret for CLI token generation | |
| 363 | + | cfg.TokenSecret = []byte("gordon-cli-default-secret") | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | return cfg, nil | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // createAuthServiceForCLI creates an auth service for CLI commands. | |
| 370 | + | func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) { | |
| 371 | + | // Create token store | |
| 372 | + | store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log) | |
| 373 | + | if err != nil { | |
| 374 | + | return nil, fmt.Errorf("failed to create token store: %w", err) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // Create auth service config | |
| 378 | + | authConfig := auth.Config{ | |
| 379 | + | Enabled: true, | |
| 380 | + | AuthType: domain.AuthTypeToken, | |
| 381 | + | TokenSecret: cfg.TokenSecret, | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | return auth.NewService(authConfig, store, log), nil | |
| 385 | + | } | |
| @@ -0,0 +1,385 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | "github.com/spf13/viper" | |||
| 15 | + | "golang.org/x/term" | |||
| 16 | + | ||||
| 17 | + | "gordon/internal/adapters/out/tokenstore" | |||
| 18 | + | "gordon/internal/domain" | |||
| 19 | + | "gordon/internal/usecase/auth" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // newAuthCmd creates the auth command group. | |||
| 23 | + | func newAuthCmd() *cobra.Command { | |||
| 24 | + | cmd := &cobra.Command{ | |||
| 25 | + | Use: "auth", | |||
| 26 | + | Short: "Manage registry authentication", | |||
| 27 | + | Long: `Commands for managing registry authentication tokens and passwords.`, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | cmd.AddCommand(newAuthTokenCmd()) | |||
| 31 | + | cmd.AddCommand(newAuthPasswordCmd()) | |||
| 32 | + | ||||
| 33 | + | return cmd | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // newAuthTokenCmd creates the token subcommand group. | |||
| 37 | + | func newAuthTokenCmd() *cobra.Command { | |||
| 38 | + | cmd := &cobra.Command{ | |||
| 39 | + | Use: "token", | |||
| 40 | + | Short: "Manage authentication tokens", | |||
| 41 | + | Long: `Commands for generating, listing, and revoking authentication tokens.`, | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | cmd.AddCommand(newTokenGenerateCmd()) | |||
| 45 | + | cmd.AddCommand(newTokenListCmd()) | |||
| 46 | + | cmd.AddCommand(newTokenRevokeCmd()) | |||
| 47 | + | ||||
| 48 | + | return cmd | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // newTokenGenerateCmd creates the token generate command. | |||
| 52 | + | func newTokenGenerateCmd() *cobra.Command { | |||
| 53 | + | var ( | |||
| 54 | + | subject string | |||
| 55 | + | scopes string | |||
| 56 | + | expiry string | |||
| 57 | + | configPath string | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | cmd := &cobra.Command{ | |||
| 61 | + | Use: "generate", | |||
| 62 | + | Short: "Generate a new authentication token", | |||
| 63 | + | Long: `Generate a new JWT authentication token for registry access. | |||
| 64 | + | ||||
| 65 | + | The token will be stored in the configured secrets backend and can be used | |||
| 66 | + | for docker login as: docker login -u <subject> -p <token> | |||
| 67 | + | ||||
| 68 | + | Use --expiry=0 to generate a token that never expires (useful for CI).`, | |||
| 69 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 70 | + | return runTokenGenerate(subject, scopes, expiry, configPath) | |||
| 71 | + | }, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | |||
| 75 | + | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | |||
| 76 | + | cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)") | |||
| 77 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 78 | + | ||||
| 79 | + | _ = cmd.MarkFlagRequired("subject") | |||
| 80 | + | ||||
| 81 | + | return cmd | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // newTokenListCmd creates the token list command. | |||
| 85 | + | func newTokenListCmd() *cobra.Command { | |||
| 86 | + | var configPath string | |||
| 87 | + | ||||
| 88 | + | cmd := &cobra.Command{ | |||
| 89 | + | Use: "list", | |||
| 90 | + | Short: "List all authentication tokens", | |||
| 91 | + | Long: `List all stored authentication tokens with their subjects and expiry information.`, | |||
| 92 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 93 | + | return runTokenList(configPath) | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 98 | + | ||||
| 99 | + | return cmd | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // newTokenRevokeCmd creates the token revoke command. | |||
| 103 | + | func newTokenRevokeCmd() *cobra.Command { | |||
| 104 | + | var configPath string | |||
| 105 | + | ||||
| 106 | + | cmd := &cobra.Command{ | |||
| 107 | + | Use: "revoke <token-id>", | |||
| 108 | + | Short: "Revoke an authentication token", | |||
| 109 | + | Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`, | |||
| 110 | + | Args: cobra.ExactArgs(1), | |||
| 111 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 112 | + | return runTokenRevoke(args[0], configPath) | |||
| 113 | + | }, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 117 | + | ||||
| 118 | + | return cmd | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // newAuthPasswordCmd creates the password subcommand group. | |||
| 122 | + | func newAuthPasswordCmd() *cobra.Command { | |||
| 123 | + | cmd := &cobra.Command{ | |||
| 124 | + | Use: "password", | |||
| 125 | + | Short: "Password utilities", | |||
| 126 | + | Long: `Utilities for managing password authentication.`, | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | cmd.AddCommand(newPasswordHashCmd()) | |||
| 130 | + | ||||
| 131 | + | return cmd | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // newPasswordHashCmd creates the password hash command. | |||
| 135 | + | func newPasswordHashCmd() *cobra.Command { | |||
| 136 | + | return &cobra.Command{ | |||
| 137 | + | Use: "hash", | |||
| 138 | + | Short: "Generate a bcrypt hash for a password", | |||
| 139 | + | Long: `Interactively prompt for a password and output its bcrypt hash. | |||
| 140 | + | ||||
| 141 | + | The hash can be stored in your secrets backend and referenced in the config: | |||
| 142 | + | ||||
| 143 | + | [registry_auth] | |||
| 144 | + | type = "password" | |||
| 145 | + | password_hash = "gordon/registry/password_hash"`, | |||
| 146 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 147 | + | return runPasswordHash() | |||
| 148 | + | }, | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // runTokenGenerate generates a new authentication token. | |||
| 153 | + | func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||
| 154 | + | cfg, err := loadAuthConfig(configPath) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Parse expiry | |||
| 160 | + | var expiry time.Duration | |||
| 161 | + | if expiryStr != "0" { | |||
| 162 | + | expiry, err = time.ParseDuration(expiryStr) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return fmt.Errorf("invalid expiry duration: %w", err) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Parse scopes | |||
| 169 | + | scopes := strings.Split(scopesStr, ",") | |||
| 170 | + | for i := range scopes { | |||
| 171 | + | scopes[i] = strings.TrimSpace(scopes[i]) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // Create auth service | |||
| 175 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |||
| 176 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return err | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Generate token | |||
| 182 | + | ctx := context.Background() | |||
| 183 | + | token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("failed to generate token: %w", err) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | fmt.Println("Token generated successfully!") | |||
| 189 | + | fmt.Printf("Subject: %s\n", subject) | |||
| 190 | + | fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", ")) | |||
| 191 | + | if expiry == 0 { | |||
| 192 | + | fmt.Println("Expiry: never") | |||
| 193 | + | } else { | |||
| 194 | + | fmt.Printf("Expiry: %s\n", expiry) | |||
| 195 | + | } | |||
| 196 | + | fmt.Println() | |||
| 197 | + | fmt.Println("Token (use as password with docker login):") | |||
| 198 | + | fmt.Println(token) | |||
| 199 | + | fmt.Println() | |||
| 200 | + | fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject) | |||
| 201 | + | ||||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // runTokenList lists all stored tokens. | |||
| 206 | + | func runTokenList(configPath string) error { | |||
| 207 | + | cfg, err := loadAuthConfig(configPath) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |||
| 213 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |||
| 214 | + | if err != nil { | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | ctx := context.Background() | |||
| 219 | + | tokens, err := authSvc.ListTokens(ctx) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return fmt.Errorf("failed to list tokens: %w", err) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | if len(tokens) == 0 { | |||
| 225 | + | fmt.Println("No tokens found.") | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | |||
| 230 | + | fmt.Println(strings.Repeat("-", 90)) | |||
| 231 | + | ||||
| 232 | + | for _, t := range tokens { | |||
| 233 | + | expiry := "never" | |||
| 234 | + | if !t.ExpiresAt.IsZero() { | |||
| 235 | + | expiry = t.ExpiresAt.Format("2006-01-02 15:04") | |||
| 236 | + | } | |||
| 237 | + | revoked := "no" | |||
| 238 | + | if t.Revoked { | |||
| 239 | + | revoked = "yes" | |||
| 240 | + | } | |||
| 241 | + | fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | return nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // runTokenRevoke revokes a token by ID. | |||
| 248 | + | func runTokenRevoke(tokenID, configPath string) error { | |||
| 249 | + | cfg, err := loadAuthConfig(configPath) | |||
| 250 | + | if err != nil { | |||
| 251 | + | return err | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |||
| 255 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return err | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | ctx := context.Background() | |||
| 261 | + | if err := authSvc.RevokeToken(ctx, tokenID); err != nil { | |||
| 262 | + | return fmt.Errorf("failed to revoke token: %w", err) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | fmt.Printf("Token %s has been revoked.\n", tokenID) | |||
| 266 | + | return nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // runPasswordHash generates a bcrypt hash for a password. | |||
| 270 | + | func runPasswordHash() error { | |||
| 271 | + | fmt.Print("Enter password: ") | |||
| 272 | + | ||||
| 273 | + | // Read password without echo | |||
| 274 | + | passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) | |||
| 275 | + | if err != nil { | |||
| 276 | + | // Fallback for non-terminal input | |||
| 277 | + | reader := bufio.NewReader(os.Stdin) | |||
| 278 | + | password, err := reader.ReadString('\n') | |||
| 279 | + | if err != nil { | |||
| 280 | + | return fmt.Errorf("failed to read password: %w", err) | |||
| 281 | + | } | |||
| 282 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |||
| 283 | + | } | |||
| 284 | + | fmt.Println() | |||
| 285 | + | ||||
| 286 | + | if len(passwordBytes) == 0 { | |||
| 287 | + | return fmt.Errorf("password cannot be empty") | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // Create a temporary auth service to generate the hash | |||
| 291 | + | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |||
| 292 | + | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return fmt.Errorf("failed to generate hash: %w", err) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | fmt.Println() | |||
| 298 | + | fmt.Println("Bcrypt hash (store in your secrets backend):") | |||
| 299 | + | fmt.Println(hash) | |||
| 300 | + | fmt.Println() | |||
| 301 | + | fmt.Println("Then reference the path in your config:") | |||
| 302 | + | fmt.Println(" [registry_auth]") | |||
| 303 | + | fmt.Println(" type = \"password\"") | |||
| 304 | + | fmt.Println(" password_hash = \"gordon/registry/password_hash\"") | |||
| 305 | + | ||||
| 306 | + | return nil | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // cliConfig holds the configuration needed for CLI commands. | |||
| 310 | + | type cliConfig struct { | |||
| 311 | + | Backend domain.SecretsBackend | |||
| 312 | + | DataDir string | |||
| 313 | + | TokenSecret []byte | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // loadAuthConfig loads the configuration needed for auth CLI commands. | |||
| 317 | + | func loadAuthConfig(configPath string) (*cliConfig, error) { | |||
| 318 | + | v := viper.New() | |||
| 319 | + | ||||
| 320 | + | // Set defaults | |||
| 321 | + | v.SetDefault("server.data_dir", "/var/lib/gordon") | |||
| 322 | + | v.SetDefault("secrets.backend", "unsafe") | |||
| 323 | + | ||||
| 324 | + | if configPath != "" { | |||
| 325 | + | v.SetConfigFile(configPath) | |||
| 326 | + | } else { | |||
| 327 | + | v.SetConfigName("gordon") | |||
| 328 | + | v.SetConfigType("yaml") | |||
| 329 | + | v.AddConfigPath("/etc/gordon") | |||
| 330 | + | v.AddConfigPath("$HOME/.gordon") | |||
| 331 | + | v.AddConfigPath(".") | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | if err := v.ReadInConfig(); err != nil { | |||
| 335 | + | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { | |||
| 336 | + | return nil, fmt.Errorf("failed to read config file: %w", err) | |||
| 337 | + | } | |||
| 338 | + | // Config file not found is OK for CLI commands | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | cfg := &cliConfig{ | |||
| 342 | + | DataDir: v.GetString("server.data_dir"), | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // Determine backend | |||
| 346 | + | switch v.GetString("secrets.backend") { | |||
| 347 | + | case "pass": | |||
| 348 | + | cfg.Backend = domain.SecretsBackendPass | |||
| 349 | + | case "sops": | |||
| 350 | + | cfg.Backend = domain.SecretsBackendSops | |||
| 351 | + | default: | |||
| 352 | + | cfg.Backend = domain.SecretsBackendUnsafe | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // Load token secret if specified | |||
| 356 | + | tokenSecretPath := v.GetString("registry_auth.token_secret") | |||
| 357 | + | if tokenSecretPath != "" { | |||
| 358 | + | // For CLI, we generate a default secret if not available | |||
| 359 | + | // In production, the secret should be loaded from the secrets backend | |||
| 360 | + | cfg.TokenSecret = []byte(tokenSecretPath) // Use path as seed for now | |||
| 361 | + | } else { | |||
| 362 | + | // Generate a default secret for CLI token generation | |||
| 363 | + | cfg.TokenSecret = []byte("gordon-cli-default-secret") | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | return cfg, nil | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | // createAuthServiceForCLI creates an auth service for CLI commands. | |||
| 370 | + | func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) { | |||
| 371 | + | // Create token store | |||
| 372 | + | store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log) | |||
| 373 | + | if err != nil { | |||
| 374 | + | return nil, fmt.Errorf("failed to create token store: %w", err) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // Create auth service config | |||
| 378 | + | authConfig := auth.Config{ | |||
| 379 | + | Enabled: true, | |||
| 380 | + | AuthType: domain.AuthTypeToken, | |||
| 381 | + | TokenSecret: cfg.TokenSecret, | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | return auth.NewService(authConfig, store, log), nil | |||
| 385 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +172 -0
| @@ -1,10 +1,15 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "crypto/subtle" | |
| 5 | 6 | "net/http" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 11 | + | "gordon/internal/boundaries/in" | |
| 12 | + | "gordon/internal/domain" | |
| 8 | 13 | ) | |
| 9 | 14 | ||
| 10 | 15 | // RegistryAuth middleware provides Docker Registry authentication. | |
| @@ -73,3 +78,170 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||
| 73 | 78 | ||
| 74 | 79 | return authenticated | |
| 75 | 80 | } | |
| 81 | + | ||
| 82 | + | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | |
| 83 | + | // supporting both password and token-based authentication. | |
| 84 | + | func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 85 | + | return func(next http.Handler) http.Handler { | |
| 86 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 87 | + | // Warn if not using TLS | |
| 88 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { | |
| 89 | + | log.Warn(). | |
| 90 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 91 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 92 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 93 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 94 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 95 | + | Msg("registry auth over insecure HTTP connection") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Check if auth is enabled | |
| 99 | + | if !authSvc.IsEnabled() { | |
| 100 | + | next.ServeHTTP(w, r) | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | ctx := r.Context() | |
| 105 | + | ||
| 106 | + | // Authenticate based on auth type | |
| 107 | + | var authenticated bool | |
| 108 | + | switch authSvc.GetAuthType() { | |
| 109 | + | case domain.AuthTypePassword: | |
| 110 | + | authenticated = authenticatePassword(ctx, r, authSvc, log) | |
| 111 | + | case domain.AuthTypeToken: | |
| 112 | + | authenticated = authenticateToken(ctx, r, authSvc, log) | |
| 113 | + | default: | |
| 114 | + | log.Error(). | |
| 115 | + | Str("auth_type", string(authSvc.GetAuthType())). | |
| 116 | + | Msg("unknown auth type") | |
| 117 | + | authenticated = false | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | if !authenticated { | |
| 121 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | next.ServeHTTP(w, r) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // authenticatePassword handles password-based authentication. | |
| 131 | + | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |
| 132 | + | username, password, ok := r.BasicAuth() | |
| 133 | + | if !ok { | |
| 134 | + | log.Debug(). | |
| 135 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 136 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 137 | + | Msg("no basic auth provided") | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | if authSvc.ValidatePassword(ctx, username, password) { | |
| 142 | + | log.Debug(). | |
| 143 | + | Str("username", username). | |
| 144 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 145 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 146 | + | Msg("password authentication successful") | |
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | log.Debug(). | |
| 151 | + | Str("provided_username", username). | |
| 152 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 153 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 154 | + | Msg("password authentication failed") | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // authenticateToken handles token-based authentication. | |
| 159 | + | // It supports both Bearer token in Authorization header and token-as-password for CI. | |
| 160 | + | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |
| 161 | + | // First, check for Bearer token | |
| 162 | + | authHeader := r.Header.Get("Authorization") | |
| 163 | + | if strings.HasPrefix(authHeader, "Bearer ") { | |
| 164 | + | token := strings.TrimPrefix(authHeader, "Bearer ") | |
| 165 | + | claims, err := authSvc.ValidateToken(ctx, token) | |
| 166 | + | if err != nil { | |
| 167 | + | log.Debug(). | |
| 168 | + | Err(err). | |
| 169 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 170 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 171 | + | Msg("bearer token validation failed") | |
| 172 | + | return false | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | log.Debug(). | |
| 176 | + | Str("subject", claims.Subject). | |
| 177 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 178 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 179 | + | Msg("bearer token authentication successful") | |
| 180 | + | return true | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // Fall back to token-as-password (for CI/automation) | |
| 184 | + | // Username is the subject, password is the JWT token | |
| 185 | + | username, password, ok := r.BasicAuth() | |
| 186 | + | if !ok { | |
| 187 | + | log.Debug(). | |
| 188 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 189 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 190 | + | Msg("no auth credentials provided") | |
| 191 | + | return false | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Try to validate the password as a JWT token | |
| 195 | + | claims, err := authSvc.ValidateToken(ctx, password) | |
| 196 | + | if err != nil { | |
| 197 | + | log.Debug(). | |
| 198 | + | Err(err). | |
| 199 | + | Str("provided_username", username). | |
| 200 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 201 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 202 | + | Msg("token-as-password validation failed") | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // Verify the username matches the token subject | |
| 207 | + | if claims.Subject != username { | |
| 208 | + | log.Debug(). | |
| 209 | + | Str("provided_username", username). | |
| 210 | + | Str("token_subject", claims.Subject). | |
| 211 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 212 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 213 | + | Msg("username does not match token subject") | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | log.Debug(). | |
| 218 | + | Str("subject", claims.Subject). | |
| 219 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 220 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 221 | + | Msg("token-as-password authentication successful") | |
| 222 | + | return true | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 226 | + | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 227 | + | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 228 | + | ||
| 229 | + | switch authType { | |
| 230 | + | case domain.AuthTypeToken: | |
| 231 | + | // For token auth, indicate the token server endpoint | |
| 232 | + | realm := "https://" + host + "/v2/token" | |
| 233 | + | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |
| 234 | + | default: | |
| 235 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 239 | + | ||
| 240 | + | log.Warn(). | |
| 241 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 242 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 243 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 244 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 245 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 246 | + | Msg("unauthorized registry access attempt") | |
| 247 | + | } | |
| @@ -1,10 +1,15 @@ | |||||
| 1 | package middleware | 1 | package middleware | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "crypto/subtle" | 5 | "crypto/subtle" | ||
| 5 | "net/http" | 6 | "net/http" | ||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | + | ||||
| 11 | + | "gordon/internal/boundaries/in" | |||
| 12 | + | "gordon/internal/domain" | |||
| 8 | ) | 13 | ) | ||
| 9 | 14 | ||||
| 10 | // RegistryAuth middleware provides Docker Registry authentication. | 15 | // RegistryAuth middleware provides Docker Registry authentication. | ||
| @@ -73,3 +78,170 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||||
| 73 | 78 | ||||
| 74 | return authenticated | 79 | return authenticated | ||
| 75 | } | 80 | } | ||
| 81 | + | ||||
| 82 | + | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | |||
| 83 | + | // supporting both password and token-based authentication. | |||
| 84 | + | func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 85 | + | return func(next http.Handler) http.Handler { | |||
| 86 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 87 | + | // Warn if not using TLS | |||
| 88 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { | |||
| 89 | + | log.Warn(). | |||
| 90 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 91 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 92 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 93 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 94 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |||
| 95 | + | Msg("registry auth over insecure HTTP connection") | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Check if auth is enabled | |||
| 99 | + | if !authSvc.IsEnabled() { | |||
| 100 | + | next.ServeHTTP(w, r) | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | ctx := r.Context() | |||
| 105 | + | ||||
| 106 | + | // Authenticate based on auth type | |||
| 107 | + | var authenticated bool | |||
| 108 | + | switch authSvc.GetAuthType() { | |||
| 109 | + | case domain.AuthTypePassword: | |||
| 110 | + | authenticated = authenticatePassword(ctx, r, authSvc, log) | |||
| 111 | + | case domain.AuthTypeToken: | |||
| 112 | + | authenticated = authenticateToken(ctx, r, authSvc, log) | |||
| 113 | + | default: | |||
| 114 | + | log.Error(). | |||
| 115 | + | Str("auth_type", string(authSvc.GetAuthType())). | |||
| 116 | + | Msg("unknown auth type") | |||
| 117 | + | authenticated = false | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | if !authenticated { | |||
| 121 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | next.ServeHTTP(w, r) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // authenticatePassword handles password-based authentication. | |||
| 131 | + | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |||
| 132 | + | username, password, ok := r.BasicAuth() | |||
| 133 | + | if !ok { | |||
| 134 | + | log.Debug(). | |||
| 135 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 136 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 137 | + | Msg("no basic auth provided") | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | if authSvc.ValidatePassword(ctx, username, password) { | |||
| 142 | + | log.Debug(). | |||
| 143 | + | Str("username", username). | |||
| 144 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 145 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 146 | + | Msg("password authentication successful") | |||
| 147 | + | return true | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | log.Debug(). | |||
| 151 | + | Str("provided_username", username). | |||
| 152 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 153 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 154 | + | Msg("password authentication failed") | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // authenticateToken handles token-based authentication. | |||
| 159 | + | // It supports both Bearer token in Authorization header and token-as-password for CI. | |||
| 160 | + | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |||
| 161 | + | // First, check for Bearer token | |||
| 162 | + | authHeader := r.Header.Get("Authorization") | |||
| 163 | + | if strings.HasPrefix(authHeader, "Bearer ") { | |||
| 164 | + | token := strings.TrimPrefix(authHeader, "Bearer ") | |||
| 165 | + | claims, err := authSvc.ValidateToken(ctx, token) | |||
| 166 | + | if err != nil { | |||
| 167 | + | log.Debug(). | |||
| 168 | + | Err(err). | |||
| 169 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 170 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 171 | + | Msg("bearer token validation failed") | |||
| 172 | + | return false | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | log.Debug(). | |||
| 176 | + | Str("subject", claims.Subject). | |||
| 177 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 178 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 179 | + | Msg("bearer token authentication successful") | |||
| 180 | + | return true | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Fall back to token-as-password (for CI/automation) | |||
| 184 | + | // Username is the subject, password is the JWT token | |||
| 185 | + | username, password, ok := r.BasicAuth() | |||
| 186 | + | if !ok { | |||
| 187 | + | log.Debug(). | |||
| 188 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 189 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 190 | + | Msg("no auth credentials provided") | |||
| 191 | + | return false | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Try to validate the password as a JWT token | |||
| 195 | + | claims, err := authSvc.ValidateToken(ctx, password) | |||
| 196 | + | if err != nil { | |||
| 197 | + | log.Debug(). | |||
| 198 | + | Err(err). | |||
| 199 | + | Str("provided_username", username). | |||
| 200 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 201 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 202 | + | Msg("token-as-password validation failed") | |||
| 203 | + | return false | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // Verify the username matches the token subject | |||
| 207 | + | if claims.Subject != username { | |||
| 208 | + | log.Debug(). | |||
| 209 | + | Str("provided_username", username). | |||
| 210 | + | Str("token_subject", claims.Subject). | |||
| 211 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 212 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 213 | + | Msg("username does not match token subject") | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | log.Debug(). | |||
| 218 | + | Str("subject", claims.Subject). | |||
| 219 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 220 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 221 | + | Msg("token-as-password authentication successful") | |||
| 222 | + | return true | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |||
| 226 | + | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |||
| 227 | + | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |||
| 228 | + | ||||
| 229 | + | switch authType { | |||
| 230 | + | case domain.AuthTypeToken: | |||
| 231 | + | // For token auth, indicate the token server endpoint | |||
| 232 | + | realm := "https://" + host + "/v2/token" | |||
| 233 | + | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |||
| 234 | + | default: | |||
| 235 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |||
| 239 | + | ||||
| 240 | + | log.Warn(). | |||
| 241 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 242 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 243 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 244 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 245 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |||
| 246 | + | Msg("unauthorized registry access attempt") | |||
| 247 | + | } | |||
A internal/adapters/in/http/registry/token.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package registry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net/http" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ||
| 10 | + | "gordon/internal/boundaries/in" | |
| 11 | + | "gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TokenResponse represents the response from the token server. | |
| 15 | + | type TokenResponse struct { | |
| 16 | + | Token string `json:"token"` | |
| 17 | + | AccessToken string `json:"access_token,omitempty"` | |
| 18 | + | ExpiresIn int `json:"expires_in,omitempty"` | |
| 19 | + | IssuedAt string `json:"issued_at,omitempty"` | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // TokenHandler handles token server requests for Docker Registry authentication. | |
| 23 | + | type TokenHandler struct { | |
| 24 | + | authSvc in.AuthService | |
| 25 | + | log zerowrap.Logger | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewTokenHandler creates a new token handler. | |
| 29 | + | func NewTokenHandler(authSvc in.AuthService, log zerowrap.Logger) *TokenHandler { | |
| 30 | + | return &TokenHandler{ | |
| 31 | + | authSvc: authSvc, | |
| 32 | + | log: log, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ServeHTTP implements http.Handler for the token endpoint. | |
| 37 | + | func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 38 | + | h.handleToken(w, r) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // handleToken handles GET /v2/token requests. | |
| 42 | + | // This endpoint issues short-lived access tokens per the Docker Registry auth spec. | |
| 43 | + | func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |
| 44 | + | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 45 | + | zerowrap.FieldLayer: "adapter", | |
| 46 | + | zerowrap.FieldAdapter: "http", | |
| 47 | + | zerowrap.FieldHandler: "token", | |
| 48 | + | zerowrap.FieldMethod: r.Method, | |
| 49 | + | zerowrap.FieldPath: r.URL.Path, | |
| 50 | + | }) | |
| 51 | + | log := zerowrap.FromCtx(ctx) | |
| 52 | + | ||
| 53 | + | if r.Method != http.MethodGet { | |
| 54 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Check if auth is enabled | |
| 59 | + | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 60 | + | // If auth is disabled, return an anonymous token | |
| 61 | + | h.sendAnonymousToken(w, log) | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Get credentials from Basic Auth | |
| 66 | + | username, password, ok := r.BasicAuth() | |
| 67 | + | if !ok { | |
| 68 | + | // No credentials provided - return anonymous token with limited scope | |
| 69 | + | h.sendAnonymousToken(w, log) | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Validate credentials based on auth type | |
| 74 | + | var authenticated bool | |
| 75 | + | switch h.authSvc.GetAuthType() { | |
| 76 | + | case domain.AuthTypePassword: | |
| 77 | + | authenticated = h.authSvc.ValidatePassword(ctx, username, password) | |
| 78 | + | case domain.AuthTypeToken: | |
| 79 | + | // For token auth, the password might be an existing JWT | |
| 80 | + | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 81 | + | if err == nil && claims.Subject == username { | |
| 82 | + | authenticated = true | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if !authenticated { | |
| 87 | + | log.Debug(). | |
| 88 | + | Str("username", username). | |
| 89 | + | Msg("token request authentication failed") | |
| 90 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 91 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Generate a short-lived access token (5 minutes) | |
| 96 | + | accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute) | |
| 97 | + | if err != nil { | |
| 98 | + | log.Error().Err(err).Msg("failed to generate access token") | |
| 99 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | response := TokenResponse{ | |
| 104 | + | Token: accessToken, | |
| 105 | + | ExpiresIn: 300, // 5 minutes in seconds | |
| 106 | + | IssuedAt: time.Now().Format(time.RFC3339), | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | w.Header().Set("Content-Type", "application/json") | |
| 110 | + | w.WriteHeader(http.StatusOK) | |
| 111 | + | ||
| 112 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 113 | + | log.Error().Err(err).Msg("failed to encode token response") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | log.Debug(). | |
| 117 | + | Str("username", username). | |
| 118 | + | Int("expires_in", response.ExpiresIn). | |
| 119 | + | Msg("access token issued") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | |
| 123 | + | func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |
| 124 | + | // For anonymous access, we issue a very short-lived token with limited scope | |
| 125 | + | response := TokenResponse{ | |
| 126 | + | Token: "", // Empty token indicates limited access | |
| 127 | + | ExpiresIn: 60, // 1 minute | |
| 128 | + | IssuedAt: time.Now().Format(time.RFC3339), | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | w.Header().Set("Content-Type", "application/json") | |
| 132 | + | w.WriteHeader(http.StatusOK) | |
| 133 | + | ||
| 134 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 135 | + | log.Error().Err(err).Msg("failed to encode anonymous token response") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | log.Debug().Msg("anonymous token issued") | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package registry | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net/http" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ||||
| 10 | + | "gordon/internal/boundaries/in" | |||
| 11 | + | "gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // TokenResponse represents the response from the token server. | |||
| 15 | + | type TokenResponse struct { | |||
| 16 | + | Token string `json:"token"` | |||
| 17 | + | AccessToken string `json:"access_token,omitempty"` | |||
| 18 | + | ExpiresIn int `json:"expires_in,omitempty"` | |||
| 19 | + | IssuedAt string `json:"issued_at,omitempty"` | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // TokenHandler handles token server requests for Docker Registry authentication. | |||
| 23 | + | type TokenHandler struct { | |||
| 24 | + | authSvc in.AuthService | |||
| 25 | + | log zerowrap.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewTokenHandler creates a new token handler. | |||
| 29 | + | func NewTokenHandler(authSvc in.AuthService, log zerowrap.Logger) *TokenHandler { | |||
| 30 | + | return &TokenHandler{ | |||
| 31 | + | authSvc: authSvc, | |||
| 32 | + | log: log, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ServeHTTP implements http.Handler for the token endpoint. | |||
| 37 | + | func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 38 | + | h.handleToken(w, r) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // handleToken handles GET /v2/token requests. | |||
| 42 | + | // This endpoint issues short-lived access tokens per the Docker Registry auth spec. | |||
| 43 | + | func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 44 | + | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |||
| 45 | + | zerowrap.FieldLayer: "adapter", | |||
| 46 | + | zerowrap.FieldAdapter: "http", | |||
| 47 | + | zerowrap.FieldHandler: "token", | |||
| 48 | + | zerowrap.FieldMethod: r.Method, | |||
| 49 | + | zerowrap.FieldPath: r.URL.Path, | |||
| 50 | + | }) | |||
| 51 | + | log := zerowrap.FromCtx(ctx) | |||
| 52 | + | ||||
| 53 | + | if r.Method != http.MethodGet { | |||
| 54 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Check if auth is enabled | |||
| 59 | + | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |||
| 60 | + | // If auth is disabled, return an anonymous token | |||
| 61 | + | h.sendAnonymousToken(w, log) | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Get credentials from Basic Auth | |||
| 66 | + | username, password, ok := r.BasicAuth() | |||
| 67 | + | if !ok { | |||
| 68 | + | // No credentials provided - return anonymous token with limited scope | |||
| 69 | + | h.sendAnonymousToken(w, log) | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Validate credentials based on auth type | |||
| 74 | + | var authenticated bool | |||
| 75 | + | switch h.authSvc.GetAuthType() { | |||
| 76 | + | case domain.AuthTypePassword: | |||
| 77 | + | authenticated = h.authSvc.ValidatePassword(ctx, username, password) | |||
| 78 | + | case domain.AuthTypeToken: | |||
| 79 | + | // For token auth, the password might be an existing JWT | |||
| 80 | + | claims, err := h.authSvc.ValidateToken(ctx, password) | |||
| 81 | + | if err == nil && claims.Subject == username { | |||
| 82 | + | authenticated = true | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | if !authenticated { | |||
| 87 | + | log.Debug(). | |||
| 88 | + | Str("username", username). | |||
| 89 | + | Msg("token request authentication failed") | |||
| 90 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |||
| 91 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Generate a short-lived access token (5 minutes) | |||
| 96 | + | accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute) | |||
| 97 | + | if err != nil { | |||
| 98 | + | log.Error().Err(err).Msg("failed to generate access token") | |||
| 99 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | response := TokenResponse{ | |||
| 104 | + | Token: accessToken, | |||
| 105 | + | ExpiresIn: 300, // 5 minutes in seconds | |||
| 106 | + | IssuedAt: time.Now().Format(time.RFC3339), | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | w.Header().Set("Content-Type", "application/json") | |||
| 110 | + | w.WriteHeader(http.StatusOK) | |||
| 111 | + | ||||
| 112 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |||
| 113 | + | log.Error().Err(err).Msg("failed to encode token response") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | log.Debug(). | |||
| 117 | + | Str("username", username). | |||
| 118 | + | Int("expires_in", response.ExpiresIn). | |||
| 119 | + | Msg("access token issued") | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | |||
| 123 | + | func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |||
| 124 | + | // For anonymous access, we issue a very short-lived token with limited scope | |||
| 125 | + | response := TokenResponse{ | |||
| 126 | + | Token: "", // Empty token indicates limited access | |||
| 127 | + | ExpiresIn: 60, // 1 minute | |||
| 128 | + | IssuedAt: time.Now().Format(time.RFC3339), | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | w.Header().Set("Content-Type", "application/json") | |||
| 132 | + | w.WriteHeader(http.StatusOK) | |||
| 133 | + | ||||
| 134 | + | if err := json.NewEncoder(w).Encode(response); err != nil { | |||
| 135 | + | log.Error().Err(err).Msg("failed to encode anonymous token response") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | log.Debug().Msg("anonymous token issued") | |||
| 139 | + | } | |||