feat(auth): add authentication, RBAC, API tokens, and default admin
Add a complete authentication and authorization system to Silo with three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak), session management, API token support, and role-based access control. Authentication backends: - Local: bcrypt (cost 12) password verification against users table - LDAP: FreeIPA simple bind with group-to-role mapping - OIDC: Keycloak redirect flow with realm role mapping - Backends are tried in order; users upserted to DB on first login Session and token management: - PostgreSQL-backed sessions via alexedwards/scs + pgxstore - Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once) - 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies Role-based access control (admin > editor > viewer): - RequireAuth middleware: Bearer token -> session -> redirect/401 - RequireRole middleware: per-route-group minimum role enforcement - CSRF protection via justinas/nosurf on web forms, API exempt - CORS locked to configured origins when auth enabled Route restructuring: - Public: /health, /ready, /login, /auth/oidc, /auth/callback - Web (auth + CSRF): /, /projects, /schemas, /settings - API read (viewer): GET /api/** - API write (editor): POST/PUT/PATCH/DELETE /api/** User context wiring: - created_by/updated_by columns on items, projects, relationships - All create/update handlers populate tracking fields from context - CSV and BOM import handlers pass authenticated username - Revision creation tracks user across all code paths Default admin account: - Configurable via auth.local.default_admin_username/password - Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD - Idempotent: created on first startup, skipped if exists CLI and FreeCAD plugin: - silo token create/list/revoke subcommands (HTTP API client) - FreeCAD SiloClient sends Bearer token on all requests - Token read from ApiToken preference or SILO_API_TOKEN env var Web UI: - Login page (Catppuccin Mocha themed, OIDC button conditional) - Settings page with account info and API token management - User display name, role badge, and logout button in header - One-time token display banner with copy-to-clipboard Database (migration 009): - users table with role, auth_source, oidc_subject, password_hash - api_tokens table with SHA-256 hash, prefix, expiry, scopes - sessions table (scs pgxstore schema) - audit_log table (schema ready for future use) - created_by/updated_by ALTER on items, relationships, projects New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3, justinas/nosurf, golang.org/x/oauth2
This commit is contained in:
373
internal/api/auth_handlers.go
Normal file
373
internal/api/auth_handlers.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
)
|
||||
|
||||
// loginPageData holds template data for the login page.
|
||||
type loginPageData struct {
|
||||
Error string
|
||||
Username string
|
||||
Next string
|
||||
CSRFToken string
|
||||
OIDCEnabled bool
|
||||
}
|
||||
|
||||
// HandleLoginPage renders the login page.
|
||||
func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
// If auth is disabled, redirect to home
|
||||
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// If already logged in, redirect to home
|
||||
if s.sessions != nil {
|
||||
userID := s.sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.renderLogin(w, r, "")
|
||||
}
|
||||
|
||||
// HandleLogin processes the login form submission.
|
||||
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
s.renderLogin(w, r, "Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.auth.Authenticate(r.Context(), username, password)
|
||||
if err != nil {
|
||||
s.logger.Warn().Str("username", username).Err(err).Msg("login failed")
|
||||
s.renderLogin(w, r, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to renew session token")
|
||||
s.renderLogin(w, r, "Internal error, please try again")
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", user.ID)
|
||||
s.sessions.Put(r.Context(), "username", user.Username)
|
||||
|
||||
s.logger.Info().Str("username", username).Str("source", user.AuthSource).Msg("user logged in")
|
||||
|
||||
// Redirect to original destination or home
|
||||
next := r.URL.Query().Get("next")
|
||||
if next == "" || !strings.HasPrefix(next, "/") {
|
||||
next = "/"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleLogout destroys the session and redirects to login.
|
||||
func (s *Server) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sessions != nil {
|
||||
_ = s.sessions.Destroy(r.Context())
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleOIDCLogin initiates the OIDC redirect to Keycloak.
|
||||
func (s *Server) HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateRandomState()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to generate OIDC state")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.sessions.Put(r.Context(), "oidc_state", state)
|
||||
http.Redirect(w, r, s.oidc.AuthCodeURL(state), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleOIDCCallback processes the OIDC redirect from Keycloak.
|
||||
func (s *Server) HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
http.Error(w, "OIDC not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state
|
||||
expectedState := s.sessions.GetString(r.Context(), "oidc_state")
|
||||
actualState := r.URL.Query().Get("state")
|
||||
if expectedState == "" || actualState != expectedState {
|
||||
s.logger.Warn().Msg("OIDC state mismatch")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.sessions.Remove(r.Context(), "oidc_state")
|
||||
|
||||
// Check for error from IdP
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
desc := r.URL.Query().Get("error_description")
|
||||
s.logger.Warn().Str("error", errParam).Str("description", desc).Msg("OIDC error from IdP")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
code := r.URL.Query().Get("code")
|
||||
user, err := s.oidc.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("OIDC exchange failed")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert user into DB
|
||||
if err := s.auth.UpsertOIDCUser(r.Context(), user); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to upsert OIDC user")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to renew session token")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", user.ID)
|
||||
s.sessions.Put(r.Context(), "username", user.Username)
|
||||
|
||||
s.logger.Info().Str("username", user.Username).Msg("OIDC user logged in")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleGetCurrentUser returns the authenticated user as JSON.
|
||||
func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"auth_source": user.AuthSource,
|
||||
})
|
||||
}
|
||||
|
||||
// createTokenRequest is the request body for token creation.
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
|
||||
// HandleCreateToken creates a new API token (JSON API).
|
||||
func (s *Server) HandleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Token name is required")
|
||||
return
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
|
||||
t := time.Now().AddDate(0, 0, *req.ExpiresInDays)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
rawToken, info, err := s.auth.GenerateToken(r.Context(), user.ID, req.Name, nil, expiresAt)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to generate token")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"token": rawToken,
|
||||
"id": info.ID,
|
||||
"name": info.Name,
|
||||
"token_prefix": info.TokenPrefix,
|
||||
"expires_at": info.ExpiresAt,
|
||||
"created_at": info.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleListTokens lists all tokens for the current user.
|
||||
func (s *Server) HandleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := s.auth.ListTokens(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list tokens")
|
||||
return
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenPrefix string `json:"token_prefix"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
result := make([]tokenResponse, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
result = append(result, tokenResponse{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
TokenPrefix: t.TokenPrefix,
|
||||
LastUsedAt: t.LastUsedAt,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
CreatedAt: t.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// HandleRevokeToken deletes an API token (JSON API).
|
||||
func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
tokenID := chi.URLParam(r, "id")
|
||||
if err := s.auth.RevokeToken(r.Context(), user.ID, tokenID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Token not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleSettingsPage renders the settings page.
|
||||
func (s *Server) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
tokens, _ := s.auth.ListTokens(r.Context(), user.ID)
|
||||
|
||||
// Retrieve one-time new token from session (if just created)
|
||||
var newToken string
|
||||
if s.sessions != nil {
|
||||
newToken = s.sessions.PopString(r.Context(), "new_token")
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
Title: "Settings",
|
||||
Page: "settings",
|
||||
User: user,
|
||||
CSRFToken: nosurf.Token(r),
|
||||
Data: map[string]any{
|
||||
"tokens": tokens,
|
||||
"new_token": newToken,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.webHandler.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to render settings page")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateTokenWeb creates a token via the web form.
|
||||
func (s *Server) HandleCreateTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
name = "Unnamed token"
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if daysStr := r.FormValue("expires_in_days"); daysStr != "" {
|
||||
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
||||
t := time.Now().AddDate(0, 0, d)
|
||||
expiresAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
rawToken, _, err := s.auth.GenerateToken(r.Context(), user.ID, name, nil, expiresAt)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to generate token")
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the raw token in session for one-time display
|
||||
s.sessions.Put(r.Context(), "new_token", rawToken)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleRevokeTokenWeb revokes a token via the web form.
|
||||
func (s *Server) HandleRevokeTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
tokenID := chi.URLParam(r, "id")
|
||||
_ = s.auth.RevokeToken(r.Context(), user.ID, tokenID)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
data := loginPageData{
|
||||
Error: errMsg,
|
||||
Username: r.FormValue("username"),
|
||||
Next: r.URL.Query().Get("next"),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
OIDCEnabled: s.oidc != nil,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.webHandler.templates.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to render login page")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user