Files
synapbus/internal/auth/middleware.go
T
Algis DumbrisandClaude Opus 4.6 8a1c096355 feat: implement human auth with OAuth 2.1 (fosite)
Add complete auth subsystem with OAuth 2.1 authorization server using
ory/fosite, local user accounts with bcrypt password hashing, session
management, and HTTP handlers for the Web UI.

Components:
- User store with bcrypt hashing (configurable cost, default 12), CRUD,
  validation (username 3-64 chars alphanumeric+underscore, password 8-72 bytes)
- Session store with secure random IDs, configurable lifetime (default 24h),
  expiration cleanup, and per-user invalidation
- OAuth client store with client_id/secret generation and bcrypt verification
- Fosite storage adapter implementing CoreStorage, TokenRevocationStorage,
  and PKCERequestStorage backed by SQLite
- OAuth provider configured with authorization code (PKCE S256 mandatory),
  client credentials, refresh token rotation, and token introspection
- HTTP handlers: POST /auth/register, POST /auth/login, POST /auth/logout,
  GET /auth/me, PUT /auth/password, GET /oauth/authorize, POST /oauth/token,
  POST /oauth/introspect
- Middleware: RequireSession (cookie), RequireBearer (access token),
  RequireAuth (either), RequireAdmin (role check)
- Structured auth event logging (login, token issuance, session lifecycle)
- Schema migration 002_auth.sql extending users, oauth_clients, oauth_tokens
  tables and adding sessions, oauth_authorization_codes tables
- Initial admin user auto-created on first run with random password printed
  to stdout
- All tests pass with CGO_ENABLED=0, zero external runtime dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:59:37 +02:00

169 lines
5.7 KiB
Go

package auth
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/ory/fosite"
)
type contextKey string
const (
userContextKey contextKey = "auth_user"
clientContextKey contextKey = "auth_client"
sessionContextKey contextKey = "auth_session_id"
)
// UserFromContext extracts the authenticated user from the context.
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userContextKey).(*User)
return user, ok
}
// ContextWithUser returns a new context with the user set.
func ContextWithUser(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, userContextKey, user)
}
// SessionIDFromContext extracts the session ID from the context.
func SessionIDFromContext(ctx context.Context) (string, bool) {
sid, ok := ctx.Value(sessionContextKey).(string)
return sid, ok
}
// ContextWithSessionID returns a new context with the session ID set.
func ContextWithSessionID(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionContextKey, sessionID)
}
// ClientFromContext extracts the authenticated client identity from the context.
func ClientFromContext(ctx context.Context) (string, bool) {
cid, ok := ctx.Value(clientContextKey).(string)
return cid, ok
}
// ContextWithClient returns a new context with the client ID set.
func ContextWithClient(ctx context.Context, clientID string) context.Context {
return context.WithValue(ctx, clientContextKey, clientID)
}
// SessionCookieName is the name of the session cookie.
const SessionCookieName = "synapbus_session"
// RequireSession creates middleware that checks for a valid session cookie.
// If valid, it injects the user into the context. If not, returns 401.
func RequireSession(userStore UserStore, sessionStore SessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
http.Error(w, `{"error":"unauthorized","message":"No session cookie"}`, http.StatusUnauthorized)
return
}
session, err := sessionStore.GetSession(r.Context(), cookie.Value)
if err != nil {
slog.Debug("session lookup failed", "error", err)
http.Error(w, `{"error":"unauthorized","message":"Invalid or expired session"}`, http.StatusUnauthorized)
return
}
user, err := userStore.GetUserByID(r.Context(), session.UserID)
if err != nil {
slog.Error("user lookup failed for session", "user_id", session.UserID, "error", err)
http.Error(w, `{"error":"unauthorized","message":"User not found"}`, http.StatusUnauthorized)
return
}
ctx := ContextWithUser(r.Context(), user)
ctx = ContextWithSessionID(ctx, session.SessionID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireBearer creates middleware that validates an OAuth access token.
// If valid, it injects the user/client identity into the context.
func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"unauthorized","message":"Missing Authorization header"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
http.Error(w, `{"error":"unauthorized","message":"Invalid Authorization header format"}`, http.StatusUnauthorized)
return
}
token := parts[1]
_ = token
// Use fosite introspection
_, ar, err := provider.IntrospectToken(r.Context(), parts[1], fosite.AccessToken, new(fositeSession))
if err != nil {
slog.Debug("bearer token validation failed", "error", err)
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, `{"error":"unauthorized","message":"Invalid or expired token"}`, http.StatusUnauthorized)
return
}
ctx := r.Context()
ctx = ContextWithClient(ctx, ar.GetClient().GetID())
// If the token has a user session, load the user
if sess, ok := ar.GetSession().(*fositeSession); ok && sess.UserID > 0 {
user, err := userStore.GetUserByID(ctx, sess.UserID)
if err == nil {
ctx = ContextWithUser(ctx, user)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAuth creates middleware that accepts either a session cookie or bearer token.
func RequireAuth(userStore UserStore, sessionStore SessionStore, provider fosite.OAuth2Provider) func(http.Handler) http.Handler {
sessionMW := RequireSession(userStore, sessionStore)
bearerMW := RequireBearer(provider, userStore)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check for bearer token first
if r.Header.Get("Authorization") != "" {
bearerMW(next).ServeHTTP(w, r)
return
}
// Fall back to session cookie
sessionMW(next).ServeHTTP(w, r)
})
}
}
// RequireAdmin creates middleware that requires the user to have admin role.
// Must be used after RequireSession or RequireAuth.
func RequireAdmin() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized","message":"Authentication required"}`, http.StatusUnauthorized)
return
}
if user.Role != RoleAdmin {
http.Error(w, `{"error":"forbidden","message":"Admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}