// Package basicauth gates a route group behind HTTP Basic authentication. // // The middleware delegates the credential check to a Verifier. // Production wires `*admin.Service` (Postgres-backed, bcrypt cost 12). // The bundled StaticVerifier is a test utility — it accepts any // non-empty username together with a fixed password so the contract // test can exercise the admin route group without booting a database. // Production wiring never references StaticVerifier. package basicauth import ( "context" "crypto/subtle" "net/http" "strings" "galaxy/backend/internal/server/httperr" "github.com/gin-gonic/gin" ) // DefaultRealm is the realm advertised on `WWW-Authenticate` for the admin // surface. const DefaultRealm = "galaxy-admin" // usernameContextKey is the unexported context key used to expose the // authenticated admin username to downstream handlers (e.g. for // soft-delete audit trails). The unexported value type prevents // accidental collisions with keys defined in unrelated packages. type usernameContextKey struct{} // Verifier validates a username/password pair. Implementations must run in // constant time relative to the credential bytes. type Verifier interface { // Verify reports whether the supplied credentials are accepted. A non-nil // error indicates an unexpected verifier failure, distinct from a clean // rejection (false, nil). Verify(ctx context.Context, username, password string) (bool, error) } // UsernameFromContext returns the authenticated admin username stored on // ctx by Middleware. The boolean reports whether a value was found. func UsernameFromContext(ctx context.Context) (string, bool) { if ctx == nil { return "", false } value, ok := ctx.Value(usernameContextKey{}).(string) if !ok { return "", false } return value, true } // WithUsername stores username on ctx under the package-private context // key. Exposed for tests that need to build a context outside the // middleware. func WithUsername(ctx context.Context, username string) context.Context { return context.WithValue(ctx, usernameContextKey{}, username) } // Middleware returns a gin middleware that enforces Basic authentication via // verifier. realm is advertised on `WWW-Authenticate`. A nil verifier behaves // as a deny-all verifier, suitable for the operating mode where the admin // surface must remain mounted but inaccessible. func Middleware(verifier Verifier, realm string) gin.HandlerFunc { if realm == "" { realm = DefaultRealm } challenge := `Basic realm="` + realm + `"` return func(c *gin.Context) { username, password, ok := c.Request.BasicAuth() if !ok { c.Header("WWW-Authenticate", challenge) httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "basic authentication is required") return } if verifier == nil { c.Header("WWW-Authenticate", challenge) httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected") return } accepted, err := verifier.Verify(c.Request.Context(), username, password) if err != nil { httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "credential verification failed") return } if !accepted { c.Header("WWW-Authenticate", challenge) httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected") return } c.Request = c.Request.WithContext(WithUsername(c.Request.Context(), username)) c.Next() } } // StaticVerifier accepts any non-empty username together with a // fixed shared password. It is a test-only utility: the OpenAPI // contract test wires it to exercise the admin route group without // booting a database. Production wiring uses the Postgres-backed // `*backend/internal/admin.Service`. type StaticVerifier struct { // Password is the shared secret. An empty value disables the verifier // (every request is rejected). Password string } // NewStaticVerifier returns a StaticVerifier with the supplied password. func NewStaticVerifier(password string) StaticVerifier { return StaticVerifier{Password: password} } // Verify accepts any non-empty username together with the configured password. // The password comparison runs in constant time. An empty configured password // rejects every request. func (v StaticVerifier) Verify(_ context.Context, username, password string) (bool, error) { if strings.TrimSpace(username) == "" { return false, nil } if v.Password == "" { return false, nil } if subtle.ConstantTimeCompare([]byte(password), []byte(v.Password)) != 1 { return false, nil } return true, nil }