refactor(auth): remove password authentication from domain and service layer
Remove AuthTypePassword, ValidatePassword, GeneratePasswordHash, and all password-related code paths from the domain, service interface, service implementation, HTTP handler, middleware, CLI, and app wiring. The /auth/password endpoint now returns 410 Gone. Regenerated mocks and updated all tests.
- commit
- d22746b20ddc9c3471b2cfb4ae294785448c604f
- author
- brice · · 2026-03-22 06:20 UTC
- committer
- brice · · 2026-03-22 17:35 UTC
- parent
- 28746f8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +48 -727
M go.mod +0 -1
| @@ -22,7 +22,6 @@ require ( | |||
| 22 | 22 | go.opentelemetry.io/otel/sdk v1.42.0 | |
| 23 | 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | |
| 24 | 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | |
| 25 | - | golang.org/x/crypto v0.49.0 | |
| 26 | 25 | golang.org/x/mod v0.34.0 | |
| 27 | 26 | golang.org/x/term v0.41.0 | |
| 28 | 27 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| @@ -22,7 +22,6 @@ require ( | |||||
| 22 | go.opentelemetry.io/otel/sdk v1.42.0 | 22 | go.opentelemetry.io/otel/sdk v1.42.0 | ||
| 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | ||
| 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | ||
| 25 | - | golang.org/x/crypto v0.49.0 | |||
| 26 | golang.org/x/mod v0.34.0 | 25 | golang.org/x/mod v0.34.0 | ||
| 27 | golang.org/x/term v0.41.0 | 26 | golang.org/x/term v0.41.0 | ||
| 28 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 27 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
M go.sum +0 -2 generated file: collapsed
M internal/adapters/in/cli/auth.go +0 -76
| @@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command { | |||
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | cmd.AddCommand(newAuthTokenCmd()) | |
| 47 | - | cmd.AddCommand(newAuthPasswordCmd()) | |
| 48 | 47 | cmd.AddCommand(newAuthInternalCmd()) | |
| 49 | 48 | cmd.AddCommand(newAuthLoginCmd()) | |
| 50 | 49 | cmd.AddCommand(newAuthStatusCmd()) | |
| @@ -191,19 +190,6 @@ Examples: | |||
| 191 | 190 | return cmd | |
| 192 | 191 | } | |
| 193 | 192 | ||
| 194 | - | // newAuthPasswordCmd creates the password subcommand group. | |
| 195 | - | func newAuthPasswordCmd() *cobra.Command { | |
| 196 | - | cmd := &cobra.Command{ | |
| 197 | - | Use: "password", | |
| 198 | - | Short: "Password utilities", | |
| 199 | - | Long: `Utilities for managing password authentication.`, | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | cmd.AddCommand(newPasswordHashCmd()) | |
| 203 | - | ||
| 204 | - | return cmd | |
| 205 | - | } | |
| 206 | - | ||
| 207 | 193 | // newAuthInternalCmd creates the internal credentials command. | |
| 208 | 194 | func newAuthInternalCmd() *cobra.Command { | |
| 209 | 195 | return &cobra.Command{ | |
| @@ -420,24 +406,6 @@ func runShowInternalAuth() error { | |||
| 420 | 406 | return nil | |
| 421 | 407 | } | |
| 422 | 408 | ||
| 423 | - | // newPasswordHashCmd creates the password hash command. | |
| 424 | - | func newPasswordHashCmd() *cobra.Command { | |
| 425 | - | return &cobra.Command{ | |
| 426 | - | Use: "hash", | |
| 427 | - | Short: "Generate a bcrypt hash for a password", | |
| 428 | - | Long: `Interactively prompt for a password and output its bcrypt hash. | |
| 429 | - | ||
| 430 | - | The hash can be stored in your secrets backend and referenced in the config: | |
| 431 | - | ||
| 432 | - | [auth] | |
| 433 | - | type = "password" | |
| 434 | - | password_hash = "github.com/bnema/gordon/auth/password_hash"`, | |
| 435 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 436 | - | return runPasswordHash() | |
| 437 | - | }, | |
| 438 | - | } | |
| 439 | - | } | |
| 440 | - | ||
| 441 | 409 | // runTokenGenerate generates a new authentication token. | |
| 442 | 410 | // parseAndConvertScopes parses a comma-separated scope string and converts | |
| 443 | 411 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | |
| @@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error { | |||
| 646 | 614 | return nil | |
| 647 | 615 | } | |
| 648 | 616 | ||
| 649 | - | // runPasswordHash generates a bcrypt hash for a password. | |
| 650 | - | func runPasswordHash() error { | |
| 651 | - | fmt.Print("Enter password: ") | |
| 652 | - | ||
| 653 | - | // Read password without echo (use os.Stdin.Fd() for better compatibility) | |
| 654 | - | fd, err := stdinFD() | |
| 655 | - | if err != nil { | |
| 656 | - | return fmt.Errorf("failed to get stdin fd: %w", err) | |
| 657 | - | } | |
| 658 | - | passwordBytes, err := term.ReadPassword(fd) | |
| 659 | - | if err != nil { | |
| 660 | - | // Fallback for non-terminal input | |
| 661 | - | reader := bufio.NewReader(os.Stdin) | |
| 662 | - | password, err := reader.ReadString('\n') | |
| 663 | - | if err != nil { | |
| 664 | - | return fmt.Errorf("failed to read password: %w", err) | |
| 665 | - | } | |
| 666 | - | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 667 | - | } | |
| 668 | - | fmt.Println() | |
| 669 | - | ||
| 670 | - | if len(passwordBytes) == 0 { | |
| 671 | - | return fmt.Errorf("password cannot be empty") | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | // Create a temporary auth service to generate the hash | |
| 675 | - | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |
| 676 | - | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |
| 677 | - | if err != nil { | |
| 678 | - | return fmt.Errorf("failed to generate hash: %w", err) | |
| 679 | - | } | |
| 680 | - | ||
| 681 | - | fmt.Println() | |
| 682 | - | fmt.Println("Bcrypt hash (store in your secrets backend):") | |
| 683 | - | fmt.Println(hash) | |
| 684 | - | fmt.Println() | |
| 685 | - | fmt.Println("Then reference the path in your config:") | |
| 686 | - | fmt.Println(" [auth]") | |
| 687 | - | fmt.Println(" type = \"password\"") | |
| 688 | - | fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"") | |
| 689 | - | ||
| 690 | - | return nil | |
| 691 | - | } | |
| 692 | - | ||
| 693 | 617 | // cliConfig holds the configuration needed for CLI commands. | |
| 694 | 618 | type cliConfig struct { | |
| 695 | 619 | Backend domain.SecretsBackend | |
| @@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command { | |||||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | cmd.AddCommand(newAuthTokenCmd()) | 46 | cmd.AddCommand(newAuthTokenCmd()) | ||
| 47 | - | cmd.AddCommand(newAuthPasswordCmd()) | |||
| 48 | cmd.AddCommand(newAuthInternalCmd()) | 47 | cmd.AddCommand(newAuthInternalCmd()) | ||
| 49 | cmd.AddCommand(newAuthLoginCmd()) | 48 | cmd.AddCommand(newAuthLoginCmd()) | ||
| 50 | cmd.AddCommand(newAuthStatusCmd()) | 49 | cmd.AddCommand(newAuthStatusCmd()) | ||
| @@ -191,19 +190,6 @@ Examples: | |||||
| 191 | return cmd | 190 | return cmd | ||
| 192 | } | 191 | } | ||
| 193 | 192 | ||||
| 194 | - | // newAuthPasswordCmd creates the password subcommand group. | |||
| 195 | - | func newAuthPasswordCmd() *cobra.Command { | |||
| 196 | - | cmd := &cobra.Command{ | |||
| 197 | - | Use: "password", | |||
| 198 | - | Short: "Password utilities", | |||
| 199 | - | Long: `Utilities for managing password authentication.`, | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | cmd.AddCommand(newPasswordHashCmd()) | |||
| 203 | - | ||||
| 204 | - | return cmd | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | // newAuthInternalCmd creates the internal credentials command. | 193 | // newAuthInternalCmd creates the internal credentials command. | ||
| 208 | func newAuthInternalCmd() *cobra.Command { | 194 | func newAuthInternalCmd() *cobra.Command { | ||
| 209 | return &cobra.Command{ | 195 | return &cobra.Command{ | ||
| @@ -420,24 +406,6 @@ func runShowInternalAuth() error { | |||||
| 420 | return nil | 406 | return nil | ||
| 421 | } | 407 | } | ||
| 422 | 408 | ||||
| 423 | - | // newPasswordHashCmd creates the password hash command. | |||
| 424 | - | func newPasswordHashCmd() *cobra.Command { | |||
| 425 | - | return &cobra.Command{ | |||
| 426 | - | Use: "hash", | |||
| 427 | - | Short: "Generate a bcrypt hash for a password", | |||
| 428 | - | Long: `Interactively prompt for a password and output its bcrypt hash. | |||
| 429 | - | ||||
| 430 | - | The hash can be stored in your secrets backend and referenced in the config: | |||
| 431 | - | ||||
| 432 | - | [auth] | |||
| 433 | - | type = "password" | |||
| 434 | - | password_hash = "github.com/bnema/gordon/auth/password_hash"`, | |||
| 435 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 436 | - | return runPasswordHash() | |||
| 437 | - | }, | |||
| 438 | - | } | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | // runTokenGenerate generates a new authentication token. | 409 | // runTokenGenerate generates a new authentication token. | ||
| 442 | // parseAndConvertScopes parses a comma-separated scope string and converts | 410 | // parseAndConvertScopes parses a comma-separated scope string and converts | ||
| 443 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | 411 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | ||
| @@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error { | |||||
| 646 | return nil | 614 | return nil | ||
| 647 | } | 615 | } | ||
| 648 | 616 | ||||
| 649 | - | // runPasswordHash generates a bcrypt hash for a password. | |||
| 650 | - | func runPasswordHash() error { | |||
| 651 | - | fmt.Print("Enter password: ") | |||
| 652 | - | ||||
| 653 | - | // Read password without echo (use os.Stdin.Fd() for better compatibility) | |||
| 654 | - | fd, err := stdinFD() | |||
| 655 | - | if err != nil { | |||
| 656 | - | return fmt.Errorf("failed to get stdin fd: %w", err) | |||
| 657 | - | } | |||
| 658 | - | passwordBytes, err := term.ReadPassword(fd) | |||
| 659 | - | if err != nil { | |||
| 660 | - | // Fallback for non-terminal input | |||
| 661 | - | reader := bufio.NewReader(os.Stdin) | |||
| 662 | - | password, err := reader.ReadString('\n') | |||
| 663 | - | if err != nil { | |||
| 664 | - | return fmt.Errorf("failed to read password: %w", err) | |||
| 665 | - | } | |||
| 666 | - | passwordBytes = []byte(strings.TrimSpace(password)) | |||
| 667 | - | } | |||
| 668 | - | fmt.Println() | |||
| 669 | - | ||||
| 670 | - | if len(passwordBytes) == 0 { | |||
| 671 | - | return fmt.Errorf("password cannot be empty") | |||
| 672 | - | } | |||
| 673 | - | ||||
| 674 | - | // Create a temporary auth service to generate the hash | |||
| 675 | - | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |||
| 676 | - | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |||
| 677 | - | if err != nil { | |||
| 678 | - | return fmt.Errorf("failed to generate hash: %w", err) | |||
| 679 | - | } | |||
| 680 | - | ||||
| 681 | - | fmt.Println() | |||
| 682 | - | fmt.Println("Bcrypt hash (store in your secrets backend):") | |||
| 683 | - | fmt.Println(hash) | |||
| 684 | - | fmt.Println() | |||
| 685 | - | fmt.Println("Then reference the path in your config:") | |||
| 686 | - | fmt.Println(" [auth]") | |||
| 687 | - | fmt.Println(" type = \"password\"") | |||
| 688 | - | fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"") | |||
| 689 | - | ||||
| 690 | - | return nil | |||
| 691 | - | } | |||
| 692 | - | ||||
| 693 | // cliConfig holds the configuration needed for CLI commands. | 617 | // cliConfig holds the configuration needed for CLI commands. | ||
| 694 | type cliConfig struct { | 618 | type cliConfig struct { | ||
| 695 | Backend domain.SecretsBackend | 619 | Backend domain.SecretsBackend | ||
M internal/adapters/in/http/auth/handler.go +6 -123
| @@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap. | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | // PasswordRequest represents the request body for POST /auth/password. | |
| 43 | - | type PasswordRequest struct { | |
| 44 | - | Username string `json:"username"` | |
| 45 | - | Password string `json:"password"` | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // PasswordResponse represents the response from POST /auth/password. | |
| 49 | - | type PasswordResponse struct { | |
| 50 | - | Token string `json:"token"` | |
| 51 | - | ExpiresIn int `json:"expires_in"` | |
| 52 | - | IssuedAt string `json:"issued_at"` | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | const passwordSessionTTL = 24 * time.Hour | |
| 56 | - | ||
| 57 | 42 | func setNoStoreHeaders(w http.ResponseWriter) { | |
| 58 | 43 | w.Header().Set("Cache-Control", "no-store") | |
| 59 | 44 | w.Header().Set("Pragma", "no-cache") | |
| @@ -76,107 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 76 | 61 | } | |
| 77 | 62 | ||
| 78 | 63 | // handlePassword handles POST /auth/password requests. | |
| 79 | - | // Validates username/password and returns a daily session JWT. | |
| 80 | - | // Only works when auth type is "password" - returns error for "token" auth type. | |
| 64 | + | // Password authentication has been removed; this endpoint always returns Gone. | |
| 81 | 65 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |
| 82 | 66 | setNoStoreHeaders(w) | |
| 83 | - | ||
| 84 | - | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 85 | - | zerowrap.FieldLayer: "adapter", | |
| 86 | - | zerowrap.FieldAdapter: "http", | |
| 87 | - | zerowrap.FieldHandler: "auth", | |
| 88 | - | zerowrap.FieldMethod: r.Method, | |
| 89 | - | zerowrap.FieldPath: r.URL.Path, | |
| 90 | - | }) | |
| 91 | - | log := zerowrap.FromCtx(ctx) | |
| 92 | - | ||
| 93 | - | if r.Method != http.MethodPost { | |
| 94 | - | w.Header().Set("Content-Type", "application/json") | |
| 95 | - | w.WriteHeader(http.StatusMethodNotAllowed) | |
| 96 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |
| 97 | - | return | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // Check if auth is enabled | |
| 101 | - | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 102 | - | w.Header().Set("Content-Type", "application/json") | |
| 103 | - | w.WriteHeader(http.StatusServiceUnavailable) | |
| 104 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Password endpoint only works with password auth type | |
| 109 | - | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |
| 110 | - | w.Header().Set("Content-Type", "application/json") | |
| 111 | - | w.WriteHeader(http.StatusBadRequest) | |
| 112 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 113 | - | Error: "password authentication not configured, use token-based auth", | |
| 114 | - | }) | |
| 115 | - | return | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Limit request body size | |
| 119 | - | r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB | |
| 120 | - | ||
| 121 | - | // Parse request body | |
| 122 | - | var req PasswordRequest | |
| 123 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 124 | - | w.Header().Set("Content-Type", "application/json") | |
| 125 | - | w.WriteHeader(http.StatusBadRequest) | |
| 126 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |
| 127 | - | return | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | if req.Username == "" || req.Password == "" { | |
| 131 | - | w.Header().Set("Content-Type", "application/json") | |
| 132 | - | w.WriteHeader(http.StatusBadRequest) | |
| 133 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |
| 134 | - | return | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // Validate password | |
| 138 | - | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |
| 139 | - | log.Debug(). | |
| 140 | - | Str("username", req.Username). | |
| 141 | - | Msg("password authentication failed") | |
| 142 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |
| 143 | - | w.Header().Set("Content-Type", "application/json") | |
| 144 | - | w.WriteHeader(http.StatusUnauthorized) | |
| 145 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |
| 146 | - | return | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Generate daily session token with full access (push, pull, admin). | |
| 150 | - | // This enforces regular re-authentication similar to short-session UX. | |
| 151 | - | expiry := passwordSessionTTL | |
| 152 | - | scopes := []string{"push", "pull", "admin:*:*"} | |
| 153 | - | ||
| 154 | - | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |
| 155 | - | if err != nil { | |
| 156 | - | log.Error().Err(err).Msg("failed to generate token") | |
| 157 | - | w.Header().Set("Content-Type", "application/json") | |
| 158 | - | w.WriteHeader(http.StatusInternalServerError) | |
| 159 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |
| 160 | - | return | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | response := PasswordResponse{ | |
| 164 | - | Token: token, | |
| 165 | - | ExpiresIn: int(expiry.Seconds()), | |
| 166 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 67 | w.Header().Set("Content-Type", "application/json") | |
| 170 | - | w.WriteHeader(http.StatusOK) | |
| 171 | - | ||
| 172 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 173 | - | log.Error().Err(err).Msg("failed to encode password response") | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | log.Debug(). | |
| 177 | - | Str("username", req.Username). | |
| 178 | - | Int("expires_in", response.ExpiresIn). | |
| 179 | - | Msg("long-lived token issued via password auth") | |
| 68 | + | w.WriteHeader(http.StatusGone) | |
| 69 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 70 | + | Error: "password authentication has been removed, use token-based auth", | |
| 71 | + | }) | |
| 180 | 72 | } | |
| 181 | 73 | ||
| 182 | 74 | // handleToken handles GET /auth/token requests. | |
| @@ -278,21 +170,12 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ | |||
| 278 | 170 | return true, nil | |
| 279 | 171 | } | |
| 280 | 172 | ||
| 281 | - | // Always try JWT token validation first — even in password-auth mode the | |
| 282 | - | // server issues JWTs on login, and the CLI sends them back via Basic Auth | |
| 283 | - | // for registry token exchange. | |
| 173 | + | // Validate JWT token sent via Basic Auth password field. | |
| 284 | 174 | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 285 | 175 | if err == nil && claims.Subject == username { | |
| 286 | 176 | return true, claims | |
| 287 | 177 | } | |
| 288 | 178 | ||
| 289 | - | // Fall back to password validation for direct username/password credentials. | |
| 290 | - | if h.authSvc.GetAuthType() == domain.AuthTypePassword { | |
| 291 | - | if h.authSvc.ValidatePassword(ctx, username, password) { | |
| 292 | - | return true, nil | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | 179 | return false, nil | |
| 297 | 180 | } | |
| 298 | 181 | ||
| @@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap. | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | - | // PasswordRequest represents the request body for POST /auth/password. | |||
| 43 | - | type PasswordRequest struct { | |||
| 44 | - | Username string `json:"username"` | |||
| 45 | - | Password string `json:"password"` | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // PasswordResponse represents the response from POST /auth/password. | |||
| 49 | - | type PasswordResponse struct { | |||
| 50 | - | Token string `json:"token"` | |||
| 51 | - | ExpiresIn int `json:"expires_in"` | |||
| 52 | - | IssuedAt string `json:"issued_at"` | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | const passwordSessionTTL = 24 * time.Hour | |||
| 56 | - | ||||
| 57 | func setNoStoreHeaders(w http.ResponseWriter) { | 42 | func setNoStoreHeaders(w http.ResponseWriter) { | ||
| 58 | w.Header().Set("Cache-Control", "no-store") | 43 | w.Header().Set("Cache-Control", "no-store") | ||
| 59 | w.Header().Set("Pragma", "no-cache") | 44 | w.Header().Set("Pragma", "no-cache") | ||
| @@ -76,107 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 76 | } | 61 | } | ||
| 77 | 62 | ||||
| 78 | // handlePassword handles POST /auth/password requests. | 63 | // handlePassword handles POST /auth/password requests. | ||
| 79 | - | // Validates username/password and returns a daily session JWT. | 64 | + | // Password authentication has been removed; this endpoint always returns Gone. |
| 80 | - | // Only works when auth type is "password" - returns error for "token" auth type. | |||
| 81 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | 65 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | ||
| 82 | setNoStoreHeaders(w) | 66 | setNoStoreHeaders(w) | ||
| 83 | - | ||||
| 84 | - | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |||
| 85 | - | zerowrap.FieldLayer: "adapter", | |||
| 86 | - | zerowrap.FieldAdapter: "http", | |||
| 87 | - | zerowrap.FieldHandler: "auth", | |||
| 88 | - | zerowrap.FieldMethod: r.Method, | |||
| 89 | - | zerowrap.FieldPath: r.URL.Path, | |||
| 90 | - | }) | |||
| 91 | - | log := zerowrap.FromCtx(ctx) | |||
| 92 | - | ||||
| 93 | - | if r.Method != http.MethodPost { | |||
| 94 | - | w.Header().Set("Content-Type", "application/json") | |||
| 95 | - | w.WriteHeader(http.StatusMethodNotAllowed) | |||
| 96 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |||
| 97 | - | return | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // Check if auth is enabled | |||
| 101 | - | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |||
| 102 | - | w.Header().Set("Content-Type", "application/json") | |||
| 103 | - | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 104 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |||
| 105 | - | return | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // Password endpoint only works with password auth type | |||
| 109 | - | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |||
| 110 | - | w.Header().Set("Content-Type", "application/json") | |||
| 111 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 112 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |||
| 113 | - | Error: "password authentication not configured, use token-based auth", | |||
| 114 | - | }) | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // Limit request body size | |||
| 119 | - | r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB | |||
| 120 | - | ||||
| 121 | - | // Parse request body | |||
| 122 | - | var req PasswordRequest | |||
| 123 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 124 | - | w.Header().Set("Content-Type", "application/json") | |||
| 125 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 126 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |||
| 127 | - | return | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | if req.Username == "" || req.Password == "" { | |||
| 131 | - | w.Header().Set("Content-Type", "application/json") | |||
| 132 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 133 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |||
| 134 | - | return | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // Validate password | |||
| 138 | - | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |||
| 139 | - | log.Debug(). | |||
| 140 | - | Str("username", req.Username). | |||
| 141 | - | Msg("password authentication failed") | |||
| 142 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |||
| 143 | - | w.Header().Set("Content-Type", "application/json") | |||
| 144 | - | w.WriteHeader(http.StatusUnauthorized) | |||
| 145 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |||
| 146 | - | return | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // Generate daily session token with full access (push, pull, admin). | |||
| 150 | - | // This enforces regular re-authentication similar to short-session UX. | |||
| 151 | - | expiry := passwordSessionTTL | |||
| 152 | - | scopes := []string{"push", "pull", "admin:*:*"} | |||
| 153 | - | ||||
| 154 | - | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |||
| 155 | - | if err != nil { | |||
| 156 | - | log.Error().Err(err).Msg("failed to generate token") | |||
| 157 | - | w.Header().Set("Content-Type", "application/json") | |||
| 158 | - | w.WriteHeader(http.StatusInternalServerError) | |||
| 159 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |||
| 160 | - | return | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | response := PasswordResponse{ | |||
| 164 | - | Token: token, | |||
| 165 | - | ExpiresIn: int(expiry.Seconds()), | |||
| 166 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | w.Header().Set("Content-Type", "application/json") | 67 | w.Header().Set("Content-Type", "application/json") | ||
| 170 | - | w.WriteHeader(http.StatusOK) | 68 | + | w.WriteHeader(http.StatusGone) |
| 171 | - | 69 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 172 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | 70 | + | Error: "password authentication has been removed, use token-based auth", |
| 173 | - | log.Error().Err(err).Msg("failed to encode password response") | 71 | + | }) |
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | log.Debug(). | |||
| 177 | - | Str("username", req.Username). | |||
| 178 | - | Int("expires_in", response.ExpiresIn). | |||
| 179 | - | Msg("long-lived token issued via password auth") | |||
| 180 | } | 72 | } | ||
| 181 | 73 | ||||
| 182 | // handleToken handles GET /auth/token requests. | 74 | // handleToken handles GET /auth/token requests. | ||
| @@ -278,21 +170,12 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ | |||||
| 278 | return true, nil | 170 | return true, nil | ||
| 279 | } | 171 | } | ||
| 280 | 172 | ||||
| 281 | - | // Always try JWT token validation first — even in password-auth mode the | 173 | + | // Validate JWT token sent via Basic Auth password field. |
| 282 | - | // server issues JWTs on login, and the CLI sends them back via Basic Auth | |||
| 283 | - | // for registry token exchange. | |||
| 284 | claims, err := h.authSvc.ValidateToken(ctx, password) | 174 | claims, err := h.authSvc.ValidateToken(ctx, password) | ||
| 285 | if err == nil && claims.Subject == username { | 175 | if err == nil && claims.Subject == username { | ||
| 286 | return true, claims | 176 | return true, claims | ||
| 287 | } | 177 | } | ||
| 288 | 178 | ||||
| 289 | - | // Fall back to password validation for direct username/password credentials. | |||
| 290 | - | if h.authSvc.GetAuthType() == domain.AuthTypePassword { | |||
| 291 | - | if h.authSvc.ValidatePassword(ctx, username, password) { | |||
| 292 | - | return true, nil | |||
| 293 | - | } | |||
| 294 | - | } | |||
| 295 | - | ||||
| 296 | return false, nil | 179 | return false, nil | ||
| 297 | } | 180 | } | ||
| 298 | 181 | ||||
M internal/adapters/in/http/auth/handler_test.go +11 -132
| @@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger { | |||
| 22 | 22 | return zerowrap.Default() | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | func TestHandler_Password_Success(t *testing.T) { | |
| 26 | - | authSvc := mocks.NewMockAuthService(t) | |
| 27 | - | ||
| 28 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 29 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 30 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 31 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |
| 32 | - | Return("jwt-token-here", nil) | |
| 33 | - | ||
| 34 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 35 | - | ||
| 36 | - | body := `{"username":"admin","password":"secret"}` | |
| 37 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 38 | - | req.Header.Set("Content-Type", "application/json") | |
| 39 | - | rec := httptest.NewRecorder() | |
| 40 | - | ||
| 41 | - | handler.ServeHTTP(rec, req) | |
| 42 | - | ||
| 43 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 44 | - | ||
| 45 | - | var resp PasswordResponse | |
| 46 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 47 | - | assert.NoError(t, err) | |
| 48 | - | assert.Equal(t, "jwt-token-here", resp.Token) | |
| 49 | - | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |
| 50 | - | assert.NotEmpty(t, resp.IssuedAt) | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |
| 54 | - | authSvc := mocks.NewMockAuthService(t) | |
| 55 | - | ||
| 56 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 57 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 58 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 59 | - | ||
| 60 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 61 | - | ||
| 62 | - | body := `{"username":"admin","password":"wrongpassword"}` | |
| 63 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 64 | - | req.Header.Set("Content-Type", "application/json") | |
| 65 | - | rec := httptest.NewRecorder() | |
| 66 | - | ||
| 67 | - | handler.ServeHTTP(rec, req) | |
| 68 | - | ||
| 69 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 70 | - | assert.Contains(t, rec.Body.String(), "invalid credentials") | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |
| 74 | - | authSvc := mocks.NewMockAuthService(t) | |
| 75 | - | ||
| 76 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 77 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 78 | - | ||
| 79 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 80 | - | ||
| 81 | - | body := `{"username":"admin","password":"secret"}` | |
| 82 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 83 | - | req.Header.Set("Content-Type", "application/json") | |
| 84 | - | rec := httptest.NewRecorder() | |
| 85 | - | ||
| 86 | - | handler.ServeHTTP(rec, req) | |
| 87 | - | ||
| 88 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 89 | - | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestHandler_Password_AuthDisabled(t *testing.T) { | |
| 93 | - | authSvc := mocks.NewMockAuthService(t) | |
| 94 | - | ||
| 95 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 96 | - | ||
| 97 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 25 | + | func TestHandler_Password_AlwaysGone(t *testing.T) { | |
| 26 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 98 | 27 | ||
| 99 | 28 | body := `{"username":"admin","password":"secret"}` | |
| 100 | 29 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| @@ -103,73 +32,25 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 103 | 32 | ||
| 104 | 33 | handler.ServeHTTP(rec, req) | |
| 105 | 34 | ||
| 106 | - | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 107 | - | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | func TestHandler_Password_MissingFields(t *testing.T) { | |
| 111 | - | authSvc := mocks.NewMockAuthService(t) | |
| 112 | - | ||
| 113 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 114 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 115 | - | ||
| 116 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 117 | - | ||
| 118 | - | tests := []struct { | |
| 119 | - | name string | |
| 120 | - | body string | |
| 121 | - | }{ | |
| 122 | - | {"missing username", `{"password":"secret"}`}, | |
| 123 | - | {"missing password", `{"username":"admin"}`}, | |
| 124 | - | {"empty username", `{"username":"","password":"secret"}`}, | |
| 125 | - | {"empty password", `{"username":"admin","password":""}`}, | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | for _, tt := range tests { | |
| 129 | - | t.Run(tt.name, func(t *testing.T) { | |
| 130 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |
| 131 | - | req.Header.Set("Content-Type", "application/json") | |
| 132 | - | rec := httptest.NewRecorder() | |
| 133 | - | ||
| 134 | - | handler.ServeHTTP(rec, req) | |
| 135 | - | ||
| 136 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 137 | - | }) | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |
| 142 | - | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 143 | - | ||
| 144 | - | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |
| 145 | - | ||
| 146 | - | for _, method := range methods { | |
| 147 | - | t.Run(method, func(t *testing.T) { | |
| 148 | - | req := httptest.NewRequest(method, "/auth/password", nil) | |
| 149 | - | rec := httptest.NewRecorder() | |
| 150 | - | ||
| 151 | - | handler.ServeHTTP(rec, req) | |
| 152 | - | ||
| 153 | - | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 154 | - | }) | |
| 155 | - | } | |
| 35 | + | assert.Equal(t, http.StatusGone, rec.Code) | |
| 36 | + | assert.Contains(t, rec.Body.String(), "password authentication has been removed") | |
| 156 | 37 | } | |
| 157 | 38 | ||
| 158 | 39 | func TestHandler_Token_Success(t *testing.T) { | |
| 159 | 40 | authSvc := mocks.NewMockAuthService(t) | |
| 160 | 41 | ||
| 161 | 42 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 162 | - | // ValidateToken is tried first; plain password fails JWT parsing | |
| 163 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token")) | |
| 164 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 165 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 43 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 44 | + | Subject: "admin", | |
| 45 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 46 | + | }, nil) | |
| 166 | 47 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 167 | 48 | Return("access-token", nil) | |
| 168 | 49 | ||
| 169 | 50 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 170 | 51 | ||
| 171 | 52 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |
| 172 | - | req.SetBasicAuth("admin", "secret") | |
| 53 | + | req.SetBasicAuth("admin", "existing-jwt-token") | |
| 173 | 54 | rec := httptest.NewRecorder() | |
| 174 | 55 | ||
| 175 | 56 | handler.ServeHTTP(rec, req) | |
| @@ -218,14 +99,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) { | |||
| 218 | 99 | authSvc := mocks.NewMockAuthService(t) | |
| 219 | 100 | ||
| 220 | 101 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 221 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token")) | |
| 222 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 223 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 102 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token")) | |
| 224 | 103 | ||
| 225 | 104 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 226 | 105 | ||
| 227 | 106 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 228 | - | req.SetBasicAuth("admin", "wrongpassword") | |
| 107 | + | req.SetBasicAuth("admin", "invalid-token") | |
| 229 | 108 | rec := httptest.NewRecorder() | |
| 230 | 109 | ||
| 231 | 110 | handler.ServeHTTP(rec, req) | |
| @@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger { | |||||
| 22 | return zerowrap.Default() | 22 | return zerowrap.Default() | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | func TestHandler_Password_Success(t *testing.T) { | 25 | + | func TestHandler_Password_AlwaysGone(t *testing.T) { |
| 26 | - | authSvc := mocks.NewMockAuthService(t) | 26 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) |
| 27 | - | ||||
| 28 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 29 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 30 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |||
| 31 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |||
| 32 | - | Return("jwt-token-here", nil) | |||
| 33 | - | ||||
| 34 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 35 | - | ||||
| 36 | - | body := `{"username":"admin","password":"secret"}` | |||
| 37 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 38 | - | req.Header.Set("Content-Type", "application/json") | |||
| 39 | - | rec := httptest.NewRecorder() | |||
| 40 | - | ||||
| 41 | - | handler.ServeHTTP(rec, req) | |||
| 42 | - | ||||
| 43 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 44 | - | ||||
| 45 | - | var resp PasswordResponse | |||
| 46 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 47 | - | assert.NoError(t, err) | |||
| 48 | - | assert.Equal(t, "jwt-token-here", resp.Token) | |||
| 49 | - | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |||
| 50 | - | assert.NotEmpty(t, resp.IssuedAt) | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |||
| 54 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 55 | - | ||||
| 56 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 57 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 58 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 59 | - | ||||
| 60 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 61 | - | ||||
| 62 | - | body := `{"username":"admin","password":"wrongpassword"}` | |||
| 63 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 64 | - | req.Header.Set("Content-Type", "application/json") | |||
| 65 | - | rec := httptest.NewRecorder() | |||
| 66 | - | ||||
| 67 | - | handler.ServeHTTP(rec, req) | |||
| 68 | - | ||||
| 69 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 70 | - | assert.Contains(t, rec.Body.String(), "invalid credentials") | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |||
| 74 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 75 | - | ||||
| 76 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 77 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 78 | - | ||||
| 79 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 80 | - | ||||
| 81 | - | body := `{"username":"admin","password":"secret"}` | |||
| 82 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 83 | - | req.Header.Set("Content-Type", "application/json") | |||
| 84 | - | rec := httptest.NewRecorder() | |||
| 85 | - | ||||
| 86 | - | handler.ServeHTTP(rec, req) | |||
| 87 | - | ||||
| 88 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 89 | - | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 93 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 94 | - | ||||
| 95 | - | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 96 | - | ||||
| 97 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 98 | 27 | ||||
| 99 | body := `{"username":"admin","password":"secret"}` | 28 | body := `{"username":"admin","password":"secret"}` | ||
| 100 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | 29 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | ||
| @@ -103,73 +32,25 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||||
| 103 | 32 | ||||
| 104 | handler.ServeHTTP(rec, req) | 33 | handler.ServeHTTP(rec, req) | ||
| 105 | 34 | ||||
| 106 | - | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | 35 | + | assert.Equal(t, http.StatusGone, rec.Code) |
| 107 | - | assert.Contains(t, rec.Body.String(), "authentication is required") | 36 | + | assert.Contains(t, rec.Body.String(), "password authentication has been removed") |
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func TestHandler_Password_MissingFields(t *testing.T) { | |||
| 111 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 112 | - | ||||
| 113 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 114 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 115 | - | ||||
| 116 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 117 | - | ||||
| 118 | - | tests := []struct { | |||
| 119 | - | name string | |||
| 120 | - | body string | |||
| 121 | - | }{ | |||
| 122 | - | {"missing username", `{"password":"secret"}`}, | |||
| 123 | - | {"missing password", `{"username":"admin"}`}, | |||
| 124 | - | {"empty username", `{"username":"","password":"secret"}`}, | |||
| 125 | - | {"empty password", `{"username":"admin","password":""}`}, | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | for _, tt := range tests { | |||
| 129 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |||
| 131 | - | req.Header.Set("Content-Type", "application/json") | |||
| 132 | - | rec := httptest.NewRecorder() | |||
| 133 | - | ||||
| 134 | - | handler.ServeHTTP(rec, req) | |||
| 135 | - | ||||
| 136 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 137 | - | }) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |||
| 142 | - | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |||
| 143 | - | ||||
| 144 | - | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |||
| 145 | - | ||||
| 146 | - | for _, method := range methods { | |||
| 147 | - | t.Run(method, func(t *testing.T) { | |||
| 148 | - | req := httptest.NewRequest(method, "/auth/password", nil) | |||
| 149 | - | rec := httptest.NewRecorder() | |||
| 150 | - | ||||
| 151 | - | handler.ServeHTTP(rec, req) | |||
| 152 | - | ||||
| 153 | - | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 154 | - | }) | |||
| 155 | - | } | |||
| 156 | } | 37 | } | ||
| 157 | 38 | ||||
| 158 | func TestHandler_Token_Success(t *testing.T) { | 39 | func TestHandler_Token_Success(t *testing.T) { | ||
| 159 | authSvc := mocks.NewMockAuthService(t) | 40 | authSvc := mocks.NewMockAuthService(t) | ||
| 160 | 41 | ||||
| 161 | authSvc.EXPECT().IsEnabled().Return(true) | 42 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 162 | - | // ValidateToken is tried first; plain password fails JWT parsing | 43 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ |
| 163 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token")) | 44 | + | Subject: "admin", |
| 164 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | 45 | + | Scopes: []string{"repository:myrepo:pull"}, |
| 165 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | 46 | + | }, nil) |
| 166 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | 47 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | ||
| 167 | Return("access-token", nil) | 48 | Return("access-token", nil) | ||
| 168 | 49 | ||||
| 169 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 50 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| 170 | 51 | ||||
| 171 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | 52 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | ||
| 172 | - | req.SetBasicAuth("admin", "secret") | 53 | + | req.SetBasicAuth("admin", "existing-jwt-token") |
| 173 | rec := httptest.NewRecorder() | 54 | rec := httptest.NewRecorder() | ||
| 174 | 55 | ||||
| 175 | handler.ServeHTTP(rec, req) | 56 | handler.ServeHTTP(rec, req) | ||
| @@ -218,14 +99,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) { | |||||
| 218 | authSvc := mocks.NewMockAuthService(t) | 99 | authSvc := mocks.NewMockAuthService(t) | ||
| 219 | 100 | ||||
| 220 | authSvc.EXPECT().IsEnabled().Return(true) | 101 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 221 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token")) | 102 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token")) |
| 222 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 223 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 224 | 103 | ||||
| 225 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 104 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| 226 | 105 | ||||
| 227 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | 106 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | ||
| 228 | - | req.SetBasicAuth("admin", "wrongpassword") | 107 | + | req.SetBasicAuth("admin", "invalid-token") |
| 229 | rec := httptest.NewRecorder() | 108 | rec := httptest.NewRecorder() | ||
| 230 | 109 | ||||
| 231 | handler.ServeHTTP(rec, req) | 110 | handler.ServeHTTP(rec, req) | ||
M internal/adapters/in/http/middleware/auth.go +1 -44
| @@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 93 | 93 | // (allows CI/CD tokens while still supporting interactive password login) | |
| 94 | 94 | var authenticated bool | |
| 95 | 95 | var tokenClaims *domain.TokenClaims | |
| 96 | - | switch authSvc.GetAuthType() { | |
| 97 | - | case domain.AuthTypePassword: | |
| 98 | - | // Try password auth first | |
| 99 | - | authenticated = authenticatePassword(ctx, r, authSvc, log) | |
| 100 | - | if !authenticated { | |
| 101 | - | // Fall back to token auth (for CI/CD tokens) | |
| 102 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 103 | - | } | |
| 104 | - | case domain.AuthTypeToken: | |
| 105 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 106 | - | default: | |
| 107 | - | log.Error(). | |
| 108 | - | Str("auth_type", string(authSvc.GetAuthType())). | |
| 109 | - | Msg("unknown auth type") | |
| 110 | - | authenticated = false | |
| 111 | - | } | |
| 96 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 112 | 97 | ||
| 113 | 98 | if !authenticated { | |
| 114 | 99 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| @@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 132 | 117 | } | |
| 133 | 118 | } | |
| 134 | 119 | ||
| 135 | - | // authenticatePassword handles password-based authentication. | |
| 136 | - | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |
| 137 | - | username, password, ok := r.BasicAuth() | |
| 138 | - | if !ok { | |
| 139 | - | log.Debug(). | |
| 140 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 141 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 142 | - | Msg("no basic auth provided") | |
| 143 | - | return false | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | if authSvc.ValidatePassword(ctx, username, password) { | |
| 147 | - | log.Debug(). | |
| 148 | - | Str("username", username). | |
| 149 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 150 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 151 | - | Msg("password authentication successful") | |
| 152 | - | return true | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | log.Debug(). | |
| 156 | - | Str("provided_username", redactUsername(username)). | |
| 157 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 158 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 159 | - | Msg("password authentication failed") | |
| 160 | - | return false | |
| 161 | - | } | |
| 162 | - | ||
| 163 | 120 | // authenticateToken handles token-based authentication. | |
| 164 | 121 | // It supports both Bearer token in Authorization header and token-as-password for CI. | |
| 165 | 122 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | |
| @@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 93 | // (allows CI/CD tokens while still supporting interactive password login) | 93 | // (allows CI/CD tokens while still supporting interactive password login) | ||
| 94 | var authenticated bool | 94 | var authenticated bool | ||
| 95 | var tokenClaims *domain.TokenClaims | 95 | var tokenClaims *domain.TokenClaims | ||
| 96 | - | switch authSvc.GetAuthType() { | 96 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) |
| 97 | - | case domain.AuthTypePassword: | |||
| 98 | - | // Try password auth first | |||
| 99 | - | authenticated = authenticatePassword(ctx, r, authSvc, log) | |||
| 100 | - | if !authenticated { | |||
| 101 | - | // Fall back to token auth (for CI/CD tokens) | |||
| 102 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 103 | - | } | |||
| 104 | - | case domain.AuthTypeToken: | |||
| 105 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 106 | - | default: | |||
| 107 | - | log.Error(). | |||
| 108 | - | Str("auth_type", string(authSvc.GetAuthType())). | |||
| 109 | - | Msg("unknown auth type") | |||
| 110 | - | authenticated = false | |||
| 111 | - | } | |||
| 112 | 97 | ||||
| 113 | if !authenticated { | 98 | if !authenticated { | ||
| 114 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | 99 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | ||
| @@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 132 | } | 117 | } | ||
| 133 | } | 118 | } | ||
| 134 | 119 | ||||
| 135 | - | // authenticatePassword handles password-based authentication. | |||
| 136 | - | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |||
| 137 | - | username, password, ok := r.BasicAuth() | |||
| 138 | - | if !ok { | |||
| 139 | - | log.Debug(). | |||
| 140 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 141 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 142 | - | Msg("no basic auth provided") | |||
| 143 | - | return false | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | if authSvc.ValidatePassword(ctx, username, password) { | |||
| 147 | - | log.Debug(). | |||
| 148 | - | Str("username", username). | |||
| 149 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 150 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 151 | - | Msg("password authentication successful") | |||
| 152 | - | return true | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | log.Debug(). | |||
| 156 | - | Str("provided_username", redactUsername(username)). | |||
| 157 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 158 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 159 | - | Msg("password authentication failed") | |||
| 160 | - | return false | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | // authenticateToken handles token-based authentication. | 120 | // authenticateToken handles token-based authentication. | ||
| 164 | // It supports both Bearer token in Authorization header and token-as-password for CI. | 121 | // It supports both Bearer token in Authorization header and token-as-password for CI. | ||
| 165 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | 122 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | ||
M internal/adapters/in/http/middleware/auth_test.go +23 -35
| @@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger { | |||
| 20 | 20 | ||
| 21 | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |
| 22 | 22 | log := testLogger() | |
| 23 | - | called := false | |
| 23 | + | tokenCalled := false | |
| 24 | 24 | authSvc := stubAuthService{ | |
| 25 | 25 | enabled: true, | |
| 26 | - | authType: domain.AuthTypePassword, | |
| 27 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 28 | - | called = true | |
| 29 | - | return false | |
| 26 | + | authType: domain.AuthTypeToken, | |
| 27 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 28 | + | tokenCalled = true | |
| 29 | + | return nil, errors.New("not a token") | |
| 30 | 30 | }, | |
| 31 | 31 | } | |
| 32 | 32 | internalAuth := InternalRegistryAuth{ | |
| @@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||
| 46 | 46 | handler.ServeHTTP(rec, req) | |
| 47 | 47 | ||
| 48 | 48 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 49 | - | assert.False(t, called, "auth service should not be used for internal loopback auth") | |
| 49 | + | assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth") | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |
| 53 | 53 | log := testLogger() | |
| 54 | - | called := false | |
| 54 | + | tokenCalled := false | |
| 55 | 55 | authSvc := stubAuthService{ | |
| 56 | 56 | enabled: true, | |
| 57 | - | authType: domain.AuthTypePassword, | |
| 58 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 59 | - | called = true | |
| 60 | - | return false | |
| 57 | + | authType: domain.AuthTypeToken, | |
| 58 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 59 | + | tokenCalled = true | |
| 60 | + | return nil, errors.New("invalid token") | |
| 61 | 61 | }, | |
| 62 | 62 | } | |
| 63 | 63 | internalAuth := InternalRegistryAuth{ | |
| @@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||
| 77 | 77 | handler.ServeHTTP(rec, req) | |
| 78 | 78 | ||
| 79 | 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 80 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 81 | - | assert.True(t, called, "auth service should be used when internal auth fails") | |
| 80 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") | |
| 81 | + | assert.True(t, tokenCalled, "auth service should be used when internal auth fails") | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |
| 85 | 85 | log := testLogger() | |
| 86 | - | called := false | |
| 86 | + | tokenCalled := false | |
| 87 | 87 | authSvc := stubAuthService{ | |
| 88 | 88 | enabled: true, | |
| 89 | - | authType: domain.AuthTypePassword, | |
| 90 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 91 | - | called = true | |
| 92 | - | return false | |
| 89 | + | authType: domain.AuthTypeToken, | |
| 90 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 91 | + | tokenCalled = true | |
| 92 | + | return nil, errors.New("invalid token") | |
| 93 | 93 | }, | |
| 94 | 94 | } | |
| 95 | 95 | internalAuth := InternalRegistryAuth{ | |
| @@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||
| 109 | 109 | handler.ServeHTTP(rec, req) | |
| 110 | 110 | ||
| 111 | 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 112 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 113 | - | assert.True(t, called, "auth service should be used for non-localhost requests") | |
| 112 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") | |
| 113 | + | assert.True(t, tokenCalled, "auth service should be used for non-localhost requests") | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | type stubAuthService struct { | |
| 117 | - | enabled bool | |
| 118 | - | authType domain.AuthType | |
| 119 | - | validatePassword func(ctx context.Context, username, password string) bool | |
| 120 | - | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 117 | + | enabled bool | |
| 118 | + | authType domain.AuthType | |
| 119 | + | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 121 | 120 | } | |
| 122 | 121 | ||
| 123 | 122 | func (s stubAuthService) GetAuthType() domain.AuthType { | |
| @@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool { | |||
| 128 | 127 | return s.enabled | |
| 129 | 128 | } | |
| 130 | 129 | ||
| 131 | - | func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool { | |
| 132 | - | if s.validatePassword != nil { | |
| 133 | - | return s.validatePassword(ctx, username, password) | |
| 134 | - | } | |
| 135 | - | return false | |
| 136 | - | } | |
| 137 | - | ||
| 138 | 130 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 139 | 131 | if s.validateToken != nil { | |
| 140 | 132 | return s.validateToken(ctx, tokenString) | |
| @@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | |||
| 162 | 154 | return nil, errors.New("not implemented") | |
| 163 | 155 | } | |
| 164 | 156 | ||
| 165 | - | func (s stubAuthService) GeneratePasswordHash(string) (string, error) { | |
| 166 | - | return "", errors.New("not implemented") | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 157 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | |
| 170 | 158 | return nil, errors.New("not implemented") | |
| 171 | 159 | } | |
| @@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger { | |||||
| 20 | 20 | ||||
| 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | ||
| 22 | log := testLogger() | 22 | log := testLogger() | ||
| 23 | - | called := false | 23 | + | tokenCalled := false |
| 24 | authSvc := stubAuthService{ | 24 | authSvc := stubAuthService{ | ||
| 25 | enabled: true, | 25 | enabled: true, | ||
| 26 | - | authType: domain.AuthTypePassword, | 26 | + | authType: domain.AuthTypeToken, |
| 27 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 27 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 28 | - | called = true | 28 | + | tokenCalled = true |
| 29 | - | return false | 29 | + | return nil, errors.New("not a token") |
| 30 | }, | 30 | }, | ||
| 31 | } | 31 | } | ||
| 32 | internalAuth := InternalRegistryAuth{ | 32 | internalAuth := InternalRegistryAuth{ | ||
| @@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||||
| 46 | handler.ServeHTTP(rec, req) | 46 | handler.ServeHTTP(rec, req) | ||
| 47 | 47 | ||||
| 48 | assert.Equal(t, http.StatusOK, rec.Code) | 48 | assert.Equal(t, http.StatusOK, rec.Code) | ||
| 49 | - | assert.False(t, called, "auth service should not be used for internal loopback auth") | 49 | + | assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth") |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | ||
| 53 | log := testLogger() | 53 | log := testLogger() | ||
| 54 | - | called := false | 54 | + | tokenCalled := false |
| 55 | authSvc := stubAuthService{ | 55 | authSvc := stubAuthService{ | ||
| 56 | enabled: true, | 56 | enabled: true, | ||
| 57 | - | authType: domain.AuthTypePassword, | 57 | + | authType: domain.AuthTypeToken, |
| 58 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 58 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 59 | - | called = true | 59 | + | tokenCalled = true |
| 60 | - | return false | 60 | + | return nil, errors.New("invalid token") |
| 61 | }, | 61 | }, | ||
| 62 | } | 62 | } | ||
| 63 | internalAuth := InternalRegistryAuth{ | 63 | internalAuth := InternalRegistryAuth{ | ||
| @@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||||
| 77 | handler.ServeHTTP(rec, req) | 77 | handler.ServeHTTP(rec, req) | ||
| 78 | 78 | ||||
| 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | ||
| 80 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | 80 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") |
| 81 | - | assert.True(t, called, "auth service should be used when internal auth fails") | 81 | + | assert.True(t, tokenCalled, "auth service should be used when internal auth fails") |
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | ||
| 85 | log := testLogger() | 85 | log := testLogger() | ||
| 86 | - | called := false | 86 | + | tokenCalled := false |
| 87 | authSvc := stubAuthService{ | 87 | authSvc := stubAuthService{ | ||
| 88 | enabled: true, | 88 | enabled: true, | ||
| 89 | - | authType: domain.AuthTypePassword, | 89 | + | authType: domain.AuthTypeToken, |
| 90 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 90 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 91 | - | called = true | 91 | + | tokenCalled = true |
| 92 | - | return false | 92 | + | return nil, errors.New("invalid token") |
| 93 | }, | 93 | }, | ||
| 94 | } | 94 | } | ||
| 95 | internalAuth := InternalRegistryAuth{ | 95 | internalAuth := InternalRegistryAuth{ | ||
| @@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||||
| 109 | handler.ServeHTTP(rec, req) | 109 | handler.ServeHTTP(rec, req) | ||
| 110 | 110 | ||||
| 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | ||
| 112 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | 112 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") |
| 113 | - | assert.True(t, called, "auth service should be used for non-localhost requests") | 113 | + | assert.True(t, tokenCalled, "auth service should be used for non-localhost requests") |
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | type stubAuthService struct { | 116 | type stubAuthService struct { | ||
| 117 | - | enabled bool | 117 | + | enabled bool |
| 118 | - | authType domain.AuthType | 118 | + | authType domain.AuthType |
| 119 | - | validatePassword func(ctx context.Context, username, password string) bool | 119 | + | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) |
| 120 | - | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |||
| 121 | } | 120 | } | ||
| 122 | 121 | ||||
| 123 | func (s stubAuthService) GetAuthType() domain.AuthType { | 122 | func (s stubAuthService) GetAuthType() domain.AuthType { | ||
| @@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool { | |||||
| 128 | return s.enabled | 127 | return s.enabled | ||
| 129 | } | 128 | } | ||
| 130 | 129 | ||||
| 131 | - | func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool { | |||
| 132 | - | if s.validatePassword != nil { | |||
| 133 | - | return s.validatePassword(ctx, username, password) | |||
| 134 | - | } | |||
| 135 | - | return false | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | 130 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | ||
| 139 | if s.validateToken != nil { | 131 | if s.validateToken != nil { | ||
| 140 | return s.validateToken(ctx, tokenString) | 132 | return s.validateToken(ctx, tokenString) | ||
| @@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | |||||
| 162 | return nil, errors.New("not implemented") | 154 | return nil, errors.New("not implemented") | ||
| 163 | } | 155 | } | ||
| 164 | 156 | ||||
| 165 | - | func (s stubAuthService) GeneratePasswordHash(string) (string, error) { | |||
| 166 | - | return "", errors.New("not implemented") | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | 157 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | ||
| 170 | return nil, errors.New("not implemented") | 158 | return nil, errors.New("not implemented") | ||
| 171 | } | 159 | } | ||
M internal/app/run.go +2 -51
| @@ -1024,24 +1024,8 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 1024 | 1024 | } | |
| 1025 | 1025 | ||
| 1026 | 1026 | // resolveAuthType determines the auth type from config. | |
| 1027 | - | // If password_hash is configured, password auth is available (plus tokens). | |
| 1028 | - | // If only token_secret is configured, token-only mode. | |
| 1029 | - | // The explicit "type" field is deprecated but still respected for backwards compat. | |
| 1030 | - | func resolveAuthType(cfg Config) domain.AuthType { | |
| 1031 | - | // Explicit type takes precedence (backwards compatibility) | |
| 1032 | - | if cfg.Auth.Type == "token" { | |
| 1033 | - | return domain.AuthTypeToken | |
| 1034 | - | } | |
| 1035 | - | if cfg.Auth.Type == "password" { | |
| 1036 | - | return domain.AuthTypePassword | |
| 1037 | - | } | |
| 1038 | - | ||
| 1039 | - | // Infer from config: password auth if password_hash is configured | |
| 1040 | - | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |
| 1041 | - | return domain.AuthTypePassword | |
| 1042 | - | } | |
| 1043 | - | ||
| 1044 | - | // Default to token-only | |
| 1027 | + | // Token-only authentication is the only supported mode. | |
| 1028 | + | func resolveAuthType(_ Config) domain.AuthType { | |
| 1045 | 1029 | return domain.AuthTypeToken | |
| 1046 | 1030 | } | |
| 1047 | 1031 | ||
| @@ -1104,42 +1088,9 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 1104 | 1088 | authConfig.TokenSecret = secret | |
| 1105 | 1089 | authConfig.TokenExpiry = expiry | |
| 1106 | 1090 | ||
| 1107 | - | // Password config only needed for password auth mode | |
| 1108 | - | if authType == domain.AuthTypePassword { | |
| 1109 | - | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | |
| 1110 | - | if err != nil { | |
| 1111 | - | return auth.Config{}, err | |
| 1112 | - | } | |
| 1113 | - | if hash == "" { | |
| 1114 | - | return auth.Config{}, errAuthNotConfigured() | |
| 1115 | - | } | |
| 1116 | - | authConfig.PasswordHash = hash | |
| 1117 | - | } | |
| 1118 | - | ||
| 1119 | 1091 | return authConfig, nil | |
| 1120 | 1092 | } | |
| 1121 | 1093 | ||
| 1122 | - | // errAuthNotConfigured returns an error when auth is enabled but credentials are not configured. | |
| 1123 | - | func errAuthNotConfigured() error { | |
| 1124 | - | return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth") | |
| 1125 | - | } | |
| 1126 | - | ||
| 1127 | - | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |
| 1128 | - | if cfg.Auth.PasswordHash != "" { | |
| 1129 | - | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) | |
| 1130 | - | if err != nil { | |
| 1131 | - | return "", log.WrapErr(err, "failed to load password hash") | |
| 1132 | - | } | |
| 1133 | - | return hash, nil | |
| 1134 | - | } | |
| 1135 | - | ||
| 1136 | - | if cfg.Auth.Password != "" { | |
| 1137 | - | return "", fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead") | |
| 1138 | - | } | |
| 1139 | - | ||
| 1140 | - | return "", nil | |
| 1141 | - | } | |
| 1142 | - | ||
| 1143 | 1094 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | |
| 1144 | 1095 | secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) | |
| 1145 | 1096 | if err != nil { | |
| @@ -1024,24 +1024,8 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 1024 | } | 1024 | } | ||
| 1025 | 1025 | ||||
| 1026 | // resolveAuthType determines the auth type from config. | 1026 | // resolveAuthType determines the auth type from config. | ||
| 1027 | - | // If password_hash is configured, password auth is available (plus tokens). | 1027 | + | // Token-only authentication is the only supported mode. |
| 1028 | - | // If only token_secret is configured, token-only mode. | 1028 | + | func resolveAuthType(_ Config) domain.AuthType { |
| 1029 | - | // The explicit "type" field is deprecated but still respected for backwards compat. | |||
| 1030 | - | func resolveAuthType(cfg Config) domain.AuthType { | |||
| 1031 | - | // Explicit type takes precedence (backwards compatibility) | |||
| 1032 | - | if cfg.Auth.Type == "token" { | |||
| 1033 | - | return domain.AuthTypeToken | |||
| 1034 | - | } | |||
| 1035 | - | if cfg.Auth.Type == "password" { | |||
| 1036 | - | return domain.AuthTypePassword | |||
| 1037 | - | } | |||
| 1038 | - | ||||
| 1039 | - | // Infer from config: password auth if password_hash is configured | |||
| 1040 | - | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |||
| 1041 | - | return domain.AuthTypePassword | |||
| 1042 | - | } | |||
| 1043 | - | ||||
| 1044 | - | // Default to token-only | |||
| 1045 | return domain.AuthTypeToken | 1029 | return domain.AuthTypeToken | ||
| 1046 | } | 1030 | } | ||
| 1047 | 1031 | ||||
| @@ -1104,42 +1088,9 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 1104 | authConfig.TokenSecret = secret | 1088 | authConfig.TokenSecret = secret | ||
| 1105 | authConfig.TokenExpiry = expiry | 1089 | authConfig.TokenExpiry = expiry | ||
| 1106 | 1090 | ||||
| 1107 | - | // Password config only needed for password auth mode | |||
| 1108 | - | if authType == domain.AuthTypePassword { | |||
| 1109 | - | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | |||
| 1110 | - | if err != nil { | |||
| 1111 | - | return auth.Config{}, err | |||
| 1112 | - | } | |||
| 1113 | - | if hash == "" { | |||
| 1114 | - | return auth.Config{}, errAuthNotConfigured() | |||
| 1115 | - | } | |||
| 1116 | - | authConfig.PasswordHash = hash | |||
| 1117 | - | } | |||
| 1118 | - | ||||
| 1119 | return authConfig, nil | 1091 | return authConfig, nil | ||
| 1120 | } | 1092 | } | ||
| 1121 | 1093 | ||||
| 1122 | - | // errAuthNotConfigured returns an error when auth is enabled but credentials are not configured. | |||
| 1123 | - | func errAuthNotConfigured() error { | |||
| 1124 | - | return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth") | |||
| 1125 | - | } | |||
| 1126 | - | ||||
| 1127 | - | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |||
| 1128 | - | if cfg.Auth.PasswordHash != "" { | |||
| 1129 | - | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) | |||
| 1130 | - | if err != nil { | |||
| 1131 | - | return "", log.WrapErr(err, "failed to load password hash") | |||
| 1132 | - | } | |||
| 1133 | - | return hash, nil | |||
| 1134 | - | } | |||
| 1135 | - | ||||
| 1136 | - | if cfg.Auth.Password != "" { | |||
| 1137 | - | return "", fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead") | |||
| 1138 | - | } | |||
| 1139 | - | ||||
| 1140 | - | return "", nil | |||
| 1141 | - | } | |||
| 1142 | - | ||||
| 1143 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | 1094 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | ||
| 1144 | secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) | 1095 | secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) | ||
| 1145 | if err != nil { | 1096 | if err != nil { | ||
M internal/boundaries/in/auth.go +0 -8
| @@ -15,11 +15,6 @@ type AuthService interface { | |||
| 15 | 15 | // IsEnabled returns whether authentication is enabled. | |
| 16 | 16 | IsEnabled() bool | |
| 17 | 17 | ||
| 18 | - | // Password authentication | |
| 19 | - | // ValidatePassword checks if the username and password are valid. | |
| 20 | - | ValidatePassword(ctx context.Context, username, password string) bool | |
| 21 | - | ||
| 22 | - | // Token authentication | |
| 23 | 18 | // ValidateToken validates a JWT token and returns its claims. | |
| 24 | 19 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 25 | 20 | ||
| @@ -40,9 +35,6 @@ type AuthService interface { | |||
| 40 | 35 | // ListTokens returns all stored tokens. | |
| 41 | 36 | ListTokens(ctx context.Context) ([]domain.Token, error) | |
| 42 | 37 | ||
| 43 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 44 | - | GeneratePasswordHash(password string) (string, error) | |
| 45 | - | ||
| 46 | 38 | // GetAuthStatus returns authentication status from context. | |
| 47 | 39 | // Extracts and validates token claims that were set by auth middleware. | |
| 48 | 40 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | |
| @@ -15,11 +15,6 @@ type AuthService interface { | |||||
| 15 | // IsEnabled returns whether authentication is enabled. | 15 | // IsEnabled returns whether authentication is enabled. | ||
| 16 | IsEnabled() bool | 16 | IsEnabled() bool | ||
| 17 | 17 | ||||
| 18 | - | // Password authentication | |||
| 19 | - | // ValidatePassword checks if the username and password are valid. | |||
| 20 | - | ValidatePassword(ctx context.Context, username, password string) bool | |||
| 21 | - | ||||
| 22 | - | // Token authentication | |||
| 23 | // ValidateToken validates a JWT token and returns its claims. | 18 | // ValidateToken validates a JWT token and returns its claims. | ||
| 24 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | 19 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | ||
| 25 | 20 | ||||
| @@ -40,9 +35,6 @@ type AuthService interface { | |||||
| 40 | // ListTokens returns all stored tokens. | 35 | // ListTokens returns all stored tokens. | ||
| 41 | ListTokens(ctx context.Context) ([]domain.Token, error) | 36 | ListTokens(ctx context.Context) ([]domain.Token, error) | ||
| 42 | 37 | ||||
| 43 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |||
| 44 | - | GeneratePasswordHash(password string) (string, error) | |||
| 45 | - | ||||
| 46 | // GetAuthStatus returns authentication status from context. | 38 | // GetAuthStatus returns authentication status from context. | ||
| 47 | // Extracts and validates token claims that were set by auth middleware. | 39 | // Extracts and validates token claims that were set by auth middleware. | ||
| 48 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | 40 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | ||
M internal/boundaries/in/mocks/mock_auth_service.go +0 -123
| @@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co | |||
| 183 | 183 | return _c | |
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | - | // GeneratePasswordHash provides a mock function for the type MockAuthService | |
| 187 | - | func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) { | |
| 188 | - | ret := _mock.Called(password) | |
| 189 | - | ||
| 190 | - | if len(ret) == 0 { | |
| 191 | - | panic("no return value specified for GeneratePasswordHash") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | var r0 string | |
| 195 | - | var r1 error | |
| 196 | - | if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { | |
| 197 | - | return returnFunc(password) | |
| 198 | - | } | |
| 199 | - | if returnFunc, ok := ret.Get(0).(func(string) string); ok { | |
| 200 | - | r0 = returnFunc(password) | |
| 201 | - | } else { | |
| 202 | - | r0 = ret.Get(0).(string) | |
| 203 | - | } | |
| 204 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 205 | - | r1 = returnFunc(password) | |
| 206 | - | } else { | |
| 207 | - | r1 = ret.Error(1) | |
| 208 | - | } | |
| 209 | - | return r0, r1 | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash' | |
| 213 | - | type MockAuthService_GeneratePasswordHash_Call struct { | |
| 214 | - | *mock.Call | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // GeneratePasswordHash is a helper method to define mock.On call | |
| 218 | - | // - password string | |
| 219 | - | func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call { | |
| 220 | - | return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)} | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call { | |
| 224 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 225 | - | var arg0 string | |
| 226 | - | if args[0] != nil { | |
| 227 | - | arg0 = args[0].(string) | |
| 228 | - | } | |
| 229 | - | run( | |
| 230 | - | arg0, | |
| 231 | - | ) | |
| 232 | - | }) | |
| 233 | - | return _c | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call { | |
| 237 | - | _c.Call.Return(s, err) | |
| 238 | - | return _c | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call { | |
| 242 | - | _c.Call.Return(run) | |
| 243 | - | return _c | |
| 244 | - | } | |
| 245 | - | ||
| 246 | 186 | // GenerateToken provides a mock function for the type MockAuthService | |
| 247 | 187 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |
| 248 | 188 | ret := _mock.Called(ctx, subject, scopes, expiry) | |
| @@ -650,69 +590,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co | |||
| 650 | 590 | return _c | |
| 651 | 591 | } | |
| 652 | 592 | ||
| 653 | - | // ValidatePassword provides a mock function for the type MockAuthService | |
| 654 | - | func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool { | |
| 655 | - | ret := _mock.Called(ctx, username, password) | |
| 656 | - | ||
| 657 | - | if len(ret) == 0 { | |
| 658 | - | panic("no return value specified for ValidatePassword") | |
| 659 | - | } | |
| 660 | - | ||
| 661 | - | var r0 bool | |
| 662 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { | |
| 663 | - | r0 = returnFunc(ctx, username, password) | |
| 664 | - | } else { | |
| 665 | - | r0 = ret.Get(0).(bool) | |
| 666 | - | } | |
| 667 | - | return r0 | |
| 668 | - | } | |
| 669 | - | ||
| 670 | - | // MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword' | |
| 671 | - | type MockAuthService_ValidatePassword_Call struct { | |
| 672 | - | *mock.Call | |
| 673 | - | } | |
| 674 | - | ||
| 675 | - | // ValidatePassword is a helper method to define mock.On call | |
| 676 | - | // - ctx context.Context | |
| 677 | - | // - username string | |
| 678 | - | // - password string | |
| 679 | - | func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call { | |
| 680 | - | return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)} | |
| 681 | - | } | |
| 682 | - | ||
| 683 | - | func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call { | |
| 684 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 685 | - | var arg0 context.Context | |
| 686 | - | if args[0] != nil { | |
| 687 | - | arg0 = args[0].(context.Context) | |
| 688 | - | } | |
| 689 | - | var arg1 string | |
| 690 | - | if args[1] != nil { | |
| 691 | - | arg1 = args[1].(string) | |
| 692 | - | } | |
| 693 | - | var arg2 string | |
| 694 | - | if args[2] != nil { | |
| 695 | - | arg2 = args[2].(string) | |
| 696 | - | } | |
| 697 | - | run( | |
| 698 | - | arg0, | |
| 699 | - | arg1, | |
| 700 | - | arg2, | |
| 701 | - | ) | |
| 702 | - | }) | |
| 703 | - | return _c | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call { | |
| 707 | - | _c.Call.Return(b) | |
| 708 | - | return _c | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call { | |
| 712 | - | _c.Call.Return(run) | |
| 713 | - | return _c | |
| 714 | - | } | |
| 715 | - | ||
| 716 | 593 | // ValidateToken provides a mock function for the type MockAuthService | |
| 717 | 594 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 718 | 595 | ret := _mock.Called(ctx, tokenString) | |
| @@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co | |||||
| 183 | return _c | 183 | return _c | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | - | // GeneratePasswordHash provides a mock function for the type MockAuthService | |||
| 187 | - | func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) { | |||
| 188 | - | ret := _mock.Called(password) | |||
| 189 | - | ||||
| 190 | - | if len(ret) == 0 { | |||
| 191 | - | panic("no return value specified for GeneratePasswordHash") | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | var r0 string | |||
| 195 | - | var r1 error | |||
| 196 | - | if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { | |||
| 197 | - | return returnFunc(password) | |||
| 198 | - | } | |||
| 199 | - | if returnFunc, ok := ret.Get(0).(func(string) string); ok { | |||
| 200 | - | r0 = returnFunc(password) | |||
| 201 | - | } else { | |||
| 202 | - | r0 = ret.Get(0).(string) | |||
| 203 | - | } | |||
| 204 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 205 | - | r1 = returnFunc(password) | |||
| 206 | - | } else { | |||
| 207 | - | r1 = ret.Error(1) | |||
| 208 | - | } | |||
| 209 | - | return r0, r1 | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | // MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash' | |||
| 213 | - | type MockAuthService_GeneratePasswordHash_Call struct { | |||
| 214 | - | *mock.Call | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // GeneratePasswordHash is a helper method to define mock.On call | |||
| 218 | - | // - password string | |||
| 219 | - | func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call { | |||
| 220 | - | return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)} | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call { | |||
| 224 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 225 | - | var arg0 string | |||
| 226 | - | if args[0] != nil { | |||
| 227 | - | arg0 = args[0].(string) | |||
| 228 | - | } | |||
| 229 | - | run( | |||
| 230 | - | arg0, | |||
| 231 | - | ) | |||
| 232 | - | }) | |||
| 233 | - | return _c | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call { | |||
| 237 | - | _c.Call.Return(s, err) | |||
| 238 | - | return _c | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call { | |||
| 242 | - | _c.Call.Return(run) | |||
| 243 | - | return _c | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | // GenerateToken provides a mock function for the type MockAuthService | 186 | // GenerateToken provides a mock function for the type MockAuthService | ||
| 247 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | 187 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | ||
| 248 | ret := _mock.Called(ctx, subject, scopes, expiry) | 188 | ret := _mock.Called(ctx, subject, scopes, expiry) | ||
| @@ -650,69 +590,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co | |||||
| 650 | return _c | 590 | return _c | ||
| 651 | } | 591 | } | ||
| 652 | 592 | ||||
| 653 | - | // ValidatePassword provides a mock function for the type MockAuthService | |||
| 654 | - | func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool { | |||
| 655 | - | ret := _mock.Called(ctx, username, password) | |||
| 656 | - | ||||
| 657 | - | if len(ret) == 0 { | |||
| 658 | - | panic("no return value specified for ValidatePassword") | |||
| 659 | - | } | |||
| 660 | - | ||||
| 661 | - | var r0 bool | |||
| 662 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { | |||
| 663 | - | r0 = returnFunc(ctx, username, password) | |||
| 664 | - | } else { | |||
| 665 | - | r0 = ret.Get(0).(bool) | |||
| 666 | - | } | |||
| 667 | - | return r0 | |||
| 668 | - | } | |||
| 669 | - | ||||
| 670 | - | // MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword' | |||
| 671 | - | type MockAuthService_ValidatePassword_Call struct { | |||
| 672 | - | *mock.Call | |||
| 673 | - | } | |||
| 674 | - | ||||
| 675 | - | // ValidatePassword is a helper method to define mock.On call | |||
| 676 | - | // - ctx context.Context | |||
| 677 | - | // - username string | |||
| 678 | - | // - password string | |||
| 679 | - | func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call { | |||
| 680 | - | return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)} | |||
| 681 | - | } | |||
| 682 | - | ||||
| 683 | - | func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call { | |||
| 684 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 685 | - | var arg0 context.Context | |||
| 686 | - | if args[0] != nil { | |||
| 687 | - | arg0 = args[0].(context.Context) | |||
| 688 | - | } | |||
| 689 | - | var arg1 string | |||
| 690 | - | if args[1] != nil { | |||
| 691 | - | arg1 = args[1].(string) | |||
| 692 | - | } | |||
| 693 | - | var arg2 string | |||
| 694 | - | if args[2] != nil { | |||
| 695 | - | arg2 = args[2].(string) | |||
| 696 | - | } | |||
| 697 | - | run( | |||
| 698 | - | arg0, | |||
| 699 | - | arg1, | |||
| 700 | - | arg2, | |||
| 701 | - | ) | |||
| 702 | - | }) | |||
| 703 | - | return _c | |||
| 704 | - | } | |||
| 705 | - | ||||
| 706 | - | func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call { | |||
| 707 | - | _c.Call.Return(b) | |||
| 708 | - | return _c | |||
| 709 | - | } | |||
| 710 | - | ||||
| 711 | - | func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call { | |||
| 712 | - | _c.Call.Return(run) | |||
| 713 | - | return _c | |||
| 714 | - | } | |||
| 715 | - | ||||
| 716 | // ValidateToken provides a mock function for the type MockAuthService | 593 | // ValidateToken provides a mock function for the type MockAuthService | ||
| 717 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | 594 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | ||
| 718 | ret := _mock.Called(ctx, tokenString) | 595 | ret := _mock.Called(ctx, tokenString) | ||
M internal/domain/auth.go +0 -2
| @@ -12,8 +12,6 @@ import ( | |||
| 12 | 12 | type AuthType string | |
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | - | // AuthTypePassword uses bcrypt-hashed password authentication. | |
| 16 | - | AuthTypePassword AuthType = "password" | |
| 17 | 15 | // AuthTypeToken uses JWT token-based authentication. | |
| 18 | 16 | AuthTypeToken AuthType = "token" | |
| 19 | 17 | ) | |
| @@ -12,8 +12,6 @@ import ( | |||||
| 12 | type AuthType string | 12 | type AuthType string | ||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | - | // AuthTypePassword uses bcrypt-hashed password authentication. | |||
| 16 | - | AuthTypePassword AuthType = "password" | |||
| 17 | // AuthTypeToken uses JWT token-based authentication. | 15 | // AuthTypeToken uses JWT token-based authentication. | ||
| 18 | AuthTypeToken AuthType = "token" | 16 | AuthTypeToken AuthType = "token" | ||
| 19 | ) | 17 | ) | ||
M internal/usecase/auth/service.go +5 -44
| @@ -3,7 +3,6 @@ package auth | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "crypto/subtle" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "fmt" | |
| 9 | 8 | "time" | |
| @@ -11,7 +10,6 @@ import ( | |||
| 11 | 10 | "github.com/bnema/zerowrap" | |
| 12 | 11 | "github.com/golang-jwt/jwt/v5" | |
| 13 | 12 | "github.com/google/uuid" | |
| 14 | - | "golang.org/x/crypto/bcrypt" | |
| 15 | 13 | ||
| 16 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | 15 | "github.com/bnema/gordon/internal/domain" | |
| @@ -20,8 +18,6 @@ import ( | |||
| 20 | 18 | const ( | |
| 21 | 19 | // TokenIssuer is the issuer claim for generated tokens. | |
| 22 | 20 | TokenIssuer = "gordon-registry" | |
| 23 | - | // DefaultBcryptCost is the default cost for bcrypt hashing. | |
| 24 | - | DefaultBcryptCost = 12 | |
| 25 | 21 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | |
| 26 | 22 | // Tokens with this lifetime or less skip store validation. | |
| 27 | 23 | MaxAccessTokenLifetime = 5 * time.Minute | |
| @@ -38,12 +34,11 @@ const ( | |||
| 38 | 34 | ||
| 39 | 35 | // Config holds the authentication configuration. | |
| 40 | 36 | type Config struct { | |
| 41 | - | Enabled bool | |
| 42 | - | AuthType domain.AuthType | |
| 43 | - | Username string | |
| 44 | - | PasswordHash string // bcrypt hash for password auth | |
| 45 | - | TokenSecret []byte // signing secret for token auth | |
| 46 | - | TokenExpiry time.Duration // default token expiry (0 = never) | |
| 37 | + | Enabled bool | |
| 38 | + | AuthType domain.AuthType | |
| 39 | + | Username string | |
| 40 | + | TokenSecret []byte // signing secret for token auth | |
| 41 | + | TokenExpiry time.Duration // default token expiry (0 = never) | |
| 47 | 42 | } | |
| 48 | 43 | ||
| 49 | 44 | // Service implements the AuthService interface. | |
| @@ -72,31 +67,6 @@ func (s *Service) IsEnabled() bool { | |||
| 72 | 67 | return s.config.Enabled | |
| 73 | 68 | } | |
| 74 | 69 | ||
| 75 | - | // ValidatePassword checks if the username and password are valid. | |
| 76 | - | func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool { | |
| 77 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 78 | - | zerowrap.FieldLayer: "usecase", | |
| 79 | - | zerowrap.FieldUseCase: "ValidatePassword", | |
| 80 | - | "username": username, | |
| 81 | - | }) | |
| 82 | - | log := zerowrap.FromCtx(ctx) | |
| 83 | - | ||
| 84 | - | // Constant-time username comparison | |
| 85 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1 | |
| 86 | - | ||
| 87 | - | // Bcrypt comparison (already constant-time) | |
| 88 | - | err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password)) | |
| 89 | - | passwordMatch := err == nil | |
| 90 | - | ||
| 91 | - | if !usernameMatch || !passwordMatch { | |
| 92 | - | log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed") | |
| 93 | - | return false | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | log.Debug().Msg("password validation successful") | |
| 97 | - | return true | |
| 98 | - | } | |
| 99 | - | ||
| 100 | 70 | // ValidateToken validates a JWT token and returns its claims. | |
| 101 | 71 | func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 102 | 72 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -560,15 +530,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 560 | 530 | return newTokenString, nil | |
| 561 | 531 | } | |
| 562 | 532 | ||
| 563 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 564 | - | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |
| 565 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |
| 566 | - | if err != nil { | |
| 567 | - | return "", err | |
| 568 | - | } | |
| 569 | - | return string(hash), nil | |
| 570 | - | } | |
| 571 | - | ||
| 572 | 533 | // getStringClaim safely extracts a string claim from JWT claims. | |
| 573 | 534 | func getStringClaim(claims jwt.MapClaims, key string) string { | |
| 574 | 535 | if val, ok := claims[key].(string); ok { | |
| @@ -3,7 +3,6 @@ package auth | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "crypto/subtle" | |||
| 7 | "errors" | 6 | "errors" | ||
| 8 | "fmt" | 7 | "fmt" | ||
| 9 | "time" | 8 | "time" | ||
| @@ -11,7 +10,6 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/golang-jwt/jwt/v5" | 11 | "github.com/golang-jwt/jwt/v5" | ||
| 13 | "github.com/google/uuid" | 12 | "github.com/google/uuid" | ||
| 14 | - | "golang.org/x/crypto/bcrypt" | |||
| 15 | 13 | ||||
| 16 | "github.com/bnema/gordon/internal/boundaries/out" | 14 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -20,8 +18,6 @@ import ( | |||||
| 20 | const ( | 18 | const ( | ||
| 21 | // TokenIssuer is the issuer claim for generated tokens. | 19 | // TokenIssuer is the issuer claim for generated tokens. | ||
| 22 | TokenIssuer = "gordon-registry" | 20 | TokenIssuer = "gordon-registry" | ||
| 23 | - | // DefaultBcryptCost is the default cost for bcrypt hashing. | |||
| 24 | - | DefaultBcryptCost = 12 | |||
| 25 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | 21 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | ||
| 26 | // Tokens with this lifetime or less skip store validation. | 22 | // Tokens with this lifetime or less skip store validation. | ||
| 27 | MaxAccessTokenLifetime = 5 * time.Minute | 23 | MaxAccessTokenLifetime = 5 * time.Minute | ||
| @@ -38,12 +34,11 @@ const ( | |||||
| 38 | 34 | ||||
| 39 | // Config holds the authentication configuration. | 35 | // Config holds the authentication configuration. | ||
| 40 | type Config struct { | 36 | type Config struct { | ||
| 41 | - | Enabled bool | 37 | + | Enabled bool |
| 42 | - | AuthType domain.AuthType | 38 | + | AuthType domain.AuthType |
| 43 | - | Username string | 39 | + | Username string |
| 44 | - | PasswordHash string // bcrypt hash for password auth | 40 | + | TokenSecret []byte // signing secret for token auth |
| 45 | - | TokenSecret []byte // signing secret for token auth | 41 | + | TokenExpiry time.Duration // default token expiry (0 = never) |
| 46 | - | TokenExpiry time.Duration // default token expiry (0 = never) | |||
| 47 | } | 42 | } | ||
| 48 | 43 | ||||
| 49 | // Service implements the AuthService interface. | 44 | // Service implements the AuthService interface. | ||
| @@ -72,31 +67,6 @@ func (s *Service) IsEnabled() bool { | |||||
| 72 | return s.config.Enabled | 67 | return s.config.Enabled | ||
| 73 | } | 68 | } | ||
| 74 | 69 | ||||
| 75 | - | // ValidatePassword checks if the username and password are valid. | |||
| 76 | - | func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool { | |||
| 77 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 78 | - | zerowrap.FieldLayer: "usecase", | |||
| 79 | - | zerowrap.FieldUseCase: "ValidatePassword", | |||
| 80 | - | "username": username, | |||
| 81 | - | }) | |||
| 82 | - | log := zerowrap.FromCtx(ctx) | |||
| 83 | - | ||||
| 84 | - | // Constant-time username comparison | |||
| 85 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1 | |||
| 86 | - | ||||
| 87 | - | // Bcrypt comparison (already constant-time) | |||
| 88 | - | err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password)) | |||
| 89 | - | passwordMatch := err == nil | |||
| 90 | - | ||||
| 91 | - | if !usernameMatch || !passwordMatch { | |||
| 92 | - | log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed") | |||
| 93 | - | return false | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | log.Debug().Msg("password validation successful") | |||
| 97 | - | return true | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | // ValidateToken validates a JWT token and returns its claims. | 70 | // ValidateToken validates a JWT token and returns its claims. | ||
| 101 | func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | 71 | func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | ||
| 102 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 72 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -560,15 +530,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||||
| 560 | return newTokenString, nil | 530 | return newTokenString, nil | ||
| 561 | } | 531 | } | ||
| 562 | 532 | ||||
| 563 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |||
| 564 | - | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |||
| 565 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |||
| 566 | - | if err != nil { | |||
| 567 | - | return "", err | |||
| 568 | - | } | |||
| 569 | - | return string(hash), nil | |||
| 570 | - | } | |||
| 571 | - | ||||
| 572 | // getStringClaim safely extracts a string claim from JWT claims. | 533 | // getStringClaim safely extracts a string claim from JWT claims. | ||
| 573 | func getStringClaim(claims jwt.MapClaims, key string) string { | 534 | func getStringClaim(claims jwt.MapClaims, key string) string { | ||
| 574 | if val, ok := claims[key].(string); ok { | 535 | if val, ok := claims[key].(string); ok { | ||
M internal/usecase/auth/service_test.go +0 -86
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | - | "golang.org/x/crypto/bcrypt" | |
| 15 | 14 | ||
| 16 | 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 17 | 16 | "github.com/bnema/gordon/internal/domain" | |
| @@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) { | |||
| 26 | 25 | name string | |
| 27 | 26 | authType domain.AuthType | |
| 28 | 27 | }{ | |
| 29 | - | {"password type", domain.AuthTypePassword}, | |
| 30 | 28 | {"token type", domain.AuthTypeToken}, | |
| 31 | 29 | } | |
| 32 | 30 | ||
| @@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) { | |||
| 55 | 53 | } | |
| 56 | 54 | } | |
| 57 | 55 | ||
| 58 | - | func TestService_ValidatePassword_Success(t *testing.T) { | |
| 59 | - | password := "testpassword123" | |
| 60 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 61 | - | require.NoError(t, err) | |
| 62 | - | ||
| 63 | - | svc := NewService(Config{ | |
| 64 | - | Enabled: true, | |
| 65 | - | AuthType: domain.AuthTypePassword, | |
| 66 | - | Username: "testuser", | |
| 67 | - | PasswordHash: string(hash), | |
| 68 | - | }, nil, zerowrap.Default()) | |
| 69 | - | ||
| 70 | - | ctx := testContext() | |
| 71 | - | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |
| 75 | - | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | ||
| 78 | - | svc := NewService(Config{ | |
| 79 | - | Enabled: true, | |
| 80 | - | AuthType: domain.AuthTypePassword, | |
| 81 | - | Username: "testuser", | |
| 82 | - | PasswordHash: string(hash), | |
| 83 | - | }, nil, zerowrap.Default()) | |
| 84 | - | ||
| 85 | - | ctx := testContext() | |
| 86 | - | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |
| 90 | - | password := "testpassword123" | |
| 91 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 92 | - | require.NoError(t, err) | |
| 93 | - | ||
| 94 | - | svc := NewService(Config{ | |
| 95 | - | Enabled: true, | |
| 96 | - | AuthType: domain.AuthTypePassword, | |
| 97 | - | Username: "testuser", | |
| 98 | - | PasswordHash: string(hash), | |
| 99 | - | }, nil, zerowrap.Default()) | |
| 100 | - | ||
| 101 | - | ctx := testContext() | |
| 102 | - | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | 56 | func TestService_GenerateToken_Success(t *testing.T) { | |
| 106 | 57 | tokenStore := mocks.NewMockTokenStore(t) | |
| 107 | 58 | ||
| @@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) { | |||
| 720 | 671 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | |
| 721 | 672 | } | |
| 722 | 673 | ||
| 723 | - | func TestService_GeneratePasswordHash(t *testing.T) { | |
| 724 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 725 | - | ||
| 726 | - | password := "securePassword123!" | |
| 727 | - | hash, err := svc.GeneratePasswordHash(password) | |
| 728 | - | ||
| 729 | - | assert.NoError(t, err) | |
| 730 | - | assert.NotEmpty(t, hash) | |
| 731 | - | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |
| 732 | - | ||
| 733 | - | // Verify the hash works with bcrypt | |
| 734 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |
| 735 | - | assert.NoError(t, err) | |
| 736 | - | ||
| 737 | - | // Verify wrong password fails | |
| 738 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |
| 739 | - | assert.Error(t, err) | |
| 740 | - | } | |
| 741 | - | ||
| 742 | - | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |
| 743 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 744 | - | ||
| 745 | - | password := "testpassword" | |
| 746 | - | hash1, err := svc.GeneratePasswordHash(password) | |
| 747 | - | require.NoError(t, err) | |
| 748 | - | ||
| 749 | - | hash2, err := svc.GeneratePasswordHash(password) | |
| 750 | - | require.NoError(t, err) | |
| 751 | - | ||
| 752 | - | // Bcrypt should generate different hashes each time due to salt | |
| 753 | - | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |
| 754 | - | ||
| 755 | - | // But both should validate correctly | |
| 756 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |
| 757 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |
| 758 | - | } | |
| 759 | - | ||
| 760 | 674 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | |
| 761 | 675 | // Test that generated tokens include the nbf (not-before) claim | |
| 762 | 676 | // This is a security requirement per SEC-MED-006 | |
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | - | "golang.org/x/crypto/bcrypt" | |||
| 15 | 14 | ||||
| 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) { | |||||
| 26 | name string | 25 | name string | ||
| 27 | authType domain.AuthType | 26 | authType domain.AuthType | ||
| 28 | }{ | 27 | }{ | ||
| 29 | - | {"password type", domain.AuthTypePassword}, | |||
| 30 | {"token type", domain.AuthTypeToken}, | 28 | {"token type", domain.AuthTypeToken}, | ||
| 31 | } | 29 | } | ||
| 32 | 30 | ||||
| @@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) { | |||||
| 55 | } | 53 | } | ||
| 56 | } | 54 | } | ||
| 57 | 55 | ||||
| 58 | - | func TestService_ValidatePassword_Success(t *testing.T) { | |||
| 59 | - | password := "testpassword123" | |||
| 60 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 61 | - | require.NoError(t, err) | |||
| 62 | - | ||||
| 63 | - | svc := NewService(Config{ | |||
| 64 | - | Enabled: true, | |||
| 65 | - | AuthType: domain.AuthTypePassword, | |||
| 66 | - | Username: "testuser", | |||
| 67 | - | PasswordHash: string(hash), | |||
| 68 | - | }, nil, zerowrap.Default()) | |||
| 69 | - | ||||
| 70 | - | ctx := testContext() | |||
| 71 | - | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |||
| 75 | - | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |||
| 76 | - | require.NoError(t, err) | |||
| 77 | - | ||||
| 78 | - | svc := NewService(Config{ | |||
| 79 | - | Enabled: true, | |||
| 80 | - | AuthType: domain.AuthTypePassword, | |||
| 81 | - | Username: "testuser", | |||
| 82 | - | PasswordHash: string(hash), | |||
| 83 | - | }, nil, zerowrap.Default()) | |||
| 84 | - | ||||
| 85 | - | ctx := testContext() | |||
| 86 | - | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |||
| 90 | - | password := "testpassword123" | |||
| 91 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 92 | - | require.NoError(t, err) | |||
| 93 | - | ||||
| 94 | - | svc := NewService(Config{ | |||
| 95 | - | Enabled: true, | |||
| 96 | - | AuthType: domain.AuthTypePassword, | |||
| 97 | - | Username: "testuser", | |||
| 98 | - | PasswordHash: string(hash), | |||
| 99 | - | }, nil, zerowrap.Default()) | |||
| 100 | - | ||||
| 101 | - | ctx := testContext() | |||
| 102 | - | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | func TestService_GenerateToken_Success(t *testing.T) { | 56 | func TestService_GenerateToken_Success(t *testing.T) { | ||
| 106 | tokenStore := mocks.NewMockTokenStore(t) | 57 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 107 | 58 | ||||
| @@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) { | |||||
| 720 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | 671 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | ||
| 721 | } | 672 | } | ||
| 722 | 673 | ||||
| 723 | - | func TestService_GeneratePasswordHash(t *testing.T) { | |||
| 724 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 725 | - | ||||
| 726 | - | password := "securePassword123!" | |||
| 727 | - | hash, err := svc.GeneratePasswordHash(password) | |||
| 728 | - | ||||
| 729 | - | assert.NoError(t, err) | |||
| 730 | - | assert.NotEmpty(t, hash) | |||
| 731 | - | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |||
| 732 | - | ||||
| 733 | - | // Verify the hash works with bcrypt | |||
| 734 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |||
| 735 | - | assert.NoError(t, err) | |||
| 736 | - | ||||
| 737 | - | // Verify wrong password fails | |||
| 738 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |||
| 739 | - | assert.Error(t, err) | |||
| 740 | - | } | |||
| 741 | - | ||||
| 742 | - | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |||
| 743 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 744 | - | ||||
| 745 | - | password := "testpassword" | |||
| 746 | - | hash1, err := svc.GeneratePasswordHash(password) | |||
| 747 | - | require.NoError(t, err) | |||
| 748 | - | ||||
| 749 | - | hash2, err := svc.GeneratePasswordHash(password) | |||
| 750 | - | require.NoError(t, err) | |||
| 751 | - | ||||
| 752 | - | // Bcrypt should generate different hashes each time due to salt | |||
| 753 | - | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |||
| 754 | - | ||||
| 755 | - | // But both should validate correctly | |||
| 756 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |||
| 757 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |||
| 758 | - | } | |||
| 759 | - | ||||
| 760 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | 674 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | ||
| 761 | // Test that generated tokens include the nbf (not-before) claim | 675 | // Test that generated tokens include the nbf (not-before) claim | ||
| 762 | // This is a security requirement per SEC-MED-006 | 676 | // This is a security requirement per SEC-MED-006 | ||