Skip to content

Commit 7597b89

Browse files
committed
feat: proper session-based dashboard auth (dashauth package)
New internal/dashauth package: - Session-based auth with HttpOnly cookies (SameSite=Strict) - Login endpoint: POST /auth/login validates API key, creates session - Logout endpoint: POST /auth/logout clears session - Status endpoint: GET /auth/status returns {authenticated, dev_mode} - RequireAPIAuth middleware: accepts Bearer token OR session cookie - Dev mode: all auth bypassed, dashboard shows yellow DEV banner - Login rate limiting: 5 attempts/IP/minute - Session cleanup goroutine every 15 minutes - 8 hour session expiry, cleared on server restart - Constant-time API key comparison Dashboard updated: - Login screen with password input - Cookie-based auth — no API key in URL or browser history - Dev mode banner when no auth configured - Auto-redirect to login on 401 (session expired) Future hardening documented (not hacked): - OIDC/SSO, multi-user RBAC, session persistence, refresh tokens Replaces old auth() middleware and ?key= URL parameter approach.
1 parent 2664ced commit 7597b89

4 files changed

Lines changed: 457 additions & 61 deletions

File tree

cmd/open-kmip/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func main() {
8686

8787
// Start REST API
8888
if *apiPort > 0 {
89-
a := api.NewAPI(store, *apiKey, *corsOrigin, *certFile, *keyFile, auditLog, tracker)
89+
a := api.NewAPI(store, *apiKey, *corsOrigin, *certFile, *keyFile, auditLog, tracker, *devMode)
9090
apiAddr := fmt.Sprintf("%s:%d", *host, *apiPort)
9191
go func() {
9292
if err := a.Serve(apiAddr); err != nil {

internal/api/api.go

Lines changed: 44 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"crypto/rand"
1111
"crypto/rsa"
1212
"crypto/sha256"
13-
"crypto/subtle"
1413
"crypto/x509"
1514
"encoding/base64"
1615
"encoding/hex"
@@ -28,6 +27,7 @@ import (
2827
kmiplib "github.com/cyphera-labs/kmip-go"
2928
"github.com/cyphera-labs/open-kmip-server/internal/audit"
3029
"github.com/cyphera-labs/open-kmip-server/internal/dashboard"
30+
"github.com/cyphera-labs/open-kmip-server/internal/dashauth"
3131
"github.com/cyphera-labs/open-kmip-server/internal/kmip"
3232
"github.com/cyphera-labs/open-kmip-server/internal/storage"
3333
"golang.org/x/crypto/chacha20poly1305"
@@ -78,19 +78,27 @@ func (rl *ipRateLimiter) allow(ip string) bool {
7878

7979
// API is the REST API server.
8080
type API struct {
81-
store storage.Storage
82-
audit *audit.Logger
83-
apiKey string
84-
corsOrigin string
85-
certFile string
86-
keyFile string
87-
start time.Time
88-
tracker *kmip.ConnectionTracker
81+
store storage.Storage
82+
audit *audit.Logger
83+
apiKey string
84+
corsOrigin string
85+
certFile string
86+
keyFile string
87+
start time.Time
88+
tracker *kmip.ConnectionTracker
8989
rateLimiter *ipRateLimiter
90+
dashAuth *dashauth.DashAuth
9091
}
9192

9293
// NewAPI creates a REST API server.
93-
func NewAPI(store storage.Storage, apiKey, corsOrigin, certFile, keyFile string, auditLog *audit.Logger, tracker *kmip.ConnectionTracker) *API {
94+
// devMode=true skips dashboard authentication (--dev flag).
95+
func NewAPI(store storage.Storage, apiKey, corsOrigin, certFile, keyFile string, auditLog *audit.Logger, tracker *kmip.ConnectionTracker, devMode bool) *API {
96+
var da *dashauth.DashAuth
97+
if devMode {
98+
da = dashauth.NewDevMode()
99+
} else {
100+
da = dashauth.New(apiKey, true) // KMIP REST always TLS
101+
}
94102
return &API{
95103
store: store,
96104
audit: auditLog,
@@ -101,6 +109,7 @@ func NewAPI(store storage.Storage, apiKey, corsOrigin, certFile, keyFile string,
101109
start: time.Now(),
102110
tracker: tracker,
103111
rateLimiter: newIPRateLimiter(),
112+
dashAuth: da,
104113
}
105114
}
106115

@@ -132,28 +141,32 @@ func (a *API) logAudit(r *http.Request, operation, objectUID, objectName, status
132141
func (a *API) Serve(addr string) error {
133142
mux := http.NewServeMux()
134143

135-
mux.HandleFunc("GET /v1/keys", a.auth(a.handleListKeys))
136-
mux.HandleFunc("POST /v1/keys", a.auth(a.handleCreateKey))
137-
mux.HandleFunc("GET /v1/keys/{uid}", a.auth(a.handleGetKey))
144+
mux.HandleFunc("GET /v1/keys", a.dashAuth.RequireAPIAuth(a.handleListKeys))
145+
mux.HandleFunc("POST /v1/keys", a.dashAuth.RequireAPIAuth(a.handleCreateKey))
146+
mux.HandleFunc("GET /v1/keys/{uid}", a.dashAuth.RequireAPIAuth(a.handleGetKey))
138147
// C4 fix: material export endpoint removed
139-
mux.HandleFunc("POST /v1/keys/{uid}/activate", a.auth(a.handleActivateKey))
140-
mux.HandleFunc("POST /v1/keys/{uid}/revoke", a.auth(a.handleRevokeKey))
141-
mux.HandleFunc("DELETE /v1/keys/{uid}", a.auth(a.handleDestroyKey))
142-
mux.HandleFunc("POST /v1/keys/{uid}/encrypt", a.auth(a.handleEncryptKey))
143-
mux.HandleFunc("POST /v1/keys/{uid}/decrypt", a.auth(a.handleDecryptKey))
144-
mux.HandleFunc("POST /v1/keys/{uid}/sign", a.auth(a.handleSignKey))
145-
mux.HandleFunc("POST /v1/keys/{uid}/verify", a.auth(a.handleVerifyKey))
146-
mux.HandleFunc("POST /v1/keys/{uid}/mac", a.auth(a.handleMACKey))
147-
mux.HandleFunc("POST /v1/keys/{uid}/rekey", a.auth(a.handleRekeyKey))
148-
mux.HandleFunc("POST /v1/keys/{uid}/wrap", a.auth(a.handleWrapKey))
149-
mux.HandleFunc("POST /v1/keys/{uid}/unwrap", a.auth(a.handleUnwrapKey))
150-
mux.HandleFunc("POST /v1/certificates", a.auth(a.handleUploadCertificate))
151-
mux.HandleFunc("GET /v1/connections", a.auth(a.handleConnections))
152-
mux.HandleFunc("GET /v1/status", a.auth(a.handleStatus))
153-
mux.HandleFunc("GET /v1/audit", a.auth(a.handleAuditLog))
154-
mux.HandleFunc("GET /v1/inventory", a.auth(a.handleInventory))
148+
mux.HandleFunc("POST /v1/keys/{uid}/activate", a.dashAuth.RequireAPIAuth(a.handleActivateKey))
149+
mux.HandleFunc("POST /v1/keys/{uid}/revoke", a.dashAuth.RequireAPIAuth(a.handleRevokeKey))
150+
mux.HandleFunc("DELETE /v1/keys/{uid}", a.dashAuth.RequireAPIAuth(a.handleDestroyKey))
151+
mux.HandleFunc("POST /v1/keys/{uid}/encrypt", a.dashAuth.RequireAPIAuth(a.handleEncryptKey))
152+
mux.HandleFunc("POST /v1/keys/{uid}/decrypt", a.dashAuth.RequireAPIAuth(a.handleDecryptKey))
153+
mux.HandleFunc("POST /v1/keys/{uid}/sign", a.dashAuth.RequireAPIAuth(a.handleSignKey))
154+
mux.HandleFunc("POST /v1/keys/{uid}/verify", a.dashAuth.RequireAPIAuth(a.handleVerifyKey))
155+
mux.HandleFunc("POST /v1/keys/{uid}/mac", a.dashAuth.RequireAPIAuth(a.handleMACKey))
156+
mux.HandleFunc("POST /v1/keys/{uid}/rekey", a.dashAuth.RequireAPIAuth(a.handleRekeyKey))
157+
mux.HandleFunc("POST /v1/keys/{uid}/wrap", a.dashAuth.RequireAPIAuth(a.handleWrapKey))
158+
mux.HandleFunc("POST /v1/keys/{uid}/unwrap", a.dashAuth.RequireAPIAuth(a.handleUnwrapKey))
159+
mux.HandleFunc("POST /v1/certificates", a.dashAuth.RequireAPIAuth(a.handleUploadCertificate))
160+
mux.HandleFunc("GET /v1/connections", a.dashAuth.RequireAPIAuth(a.handleConnections))
161+
mux.HandleFunc("GET /v1/status", a.dashAuth.RequireAPIAuth(a.handleStatus))
162+
mux.HandleFunc("GET /v1/audit", a.dashAuth.RequireAPIAuth(a.handleAuditLog))
163+
mux.HandleFunc("GET /v1/inventory", a.dashAuth.RequireAPIAuth(a.handleInventory))
155164
mux.HandleFunc("GET /metrics", a.handleMetrics) // public for Prometheus scraping
156-
// Dashboard static files are public — API calls require auth via ?key= param
165+
166+
// Auth endpoints (public — handles login/logout/status)
167+
a.dashAuth.RegisterRoutes(mux)
168+
169+
// Dashboard static files (public — JS handles showing login screen)
157170
mux.Handle("/ui/", http.StripPrefix("/ui", dashboard.Handler()))
158171

159172
handler := a.rateLimitMiddleware(a.limitBodyMiddleware(a.corsMiddleware(mux)))
@@ -166,31 +179,7 @@ func (a *API) Serve(addr string) error {
166179
return http.ListenAndServeTLS(addr, a.certFile, a.keyFile, handler)
167180
}
168181

169-
// C3 fix: auth requires API key when configured — no bypass
170-
func (a *API) auth(next http.HandlerFunc) http.HandlerFunc {
171-
return func(w http.ResponseWriter, r *http.Request) {
172-
if a.apiKey == "" {
173-
// H3 note: in production, apiKey is required (enforced in main)
174-
next(w, r)
175-
return
176-
}
177-
authHeader := r.Header.Get("Authorization")
178-
if authHeader == "" {
179-
// H3 fix: log failed auth
180-
a.logAudit(r, "AUTHN_FAILURE", "", "", "failure", "missing Authorization header")
181-
a.writeError(w, http.StatusUnauthorized, "missing Authorization header")
182-
return
183-
}
184-
token := strings.TrimPrefix(authHeader, "Bearer ")
185-
if token == authHeader || subtle.ConstantTimeCompare([]byte(token), []byte(a.apiKey)) != 1 {
186-
// H3 fix: log failed auth
187-
a.logAudit(r, "AUTHN_FAILURE", "", "", "failure", "invalid API key")
188-
a.writeError(w, http.StatusUnauthorized, "invalid API key")
189-
return
190-
}
191-
next(w, r)
192-
}
193-
}
182+
// auth() replaced by dashauth.RequireAPIAuth — session cookie + Bearer token
194183

195184
func (a *API) rateLimitMiddleware(next http.Handler) http.Handler {
196185
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)