feat: backend service
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user