132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
// 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
|
|
}
|