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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
)
|
||||
|
||||
@@ -240,6 +241,9 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ChildRevision: req.ChildRevision,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
rel.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||
if strings.Contains(err.Error(), "cycle") {
|
||||
@@ -333,7 +337,11 @@ func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata); err != nil {
|
||||
var bomUpdatedBy *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
bomUpdatedBy = &user.Username
|
||||
}
|
||||
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata, bomUpdatedBy); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update relationship")
|
||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||
return
|
||||
@@ -776,9 +784,14 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
var importUsername *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
importUsername = &user.Username
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// Update existing
|
||||
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata); err != nil {
|
||||
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata, importUsername); err != nil {
|
||||
result.ErrorCount++
|
||||
result.Errors = append(result.Errors, CSVImportErr{
|
||||
Row: rowNum,
|
||||
@@ -794,6 +807,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
RelType: "component",
|
||||
Quantity: quantity,
|
||||
Metadata: metadata,
|
||||
CreatedBy: importUsername,
|
||||
}
|
||||
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||
result.ErrorCount++
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/partnum"
|
||||
)
|
||||
@@ -355,6 +356,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
ItemType: itemType,
|
||||
Description: description,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
item.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
||||
result.Errors = append(result.Errors, CSVImportErr{
|
||||
|
||||
@@ -10,7 +10,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/partnum"
|
||||
"github.com/kindredsystems/silo/internal/schema"
|
||||
@@ -30,6 +33,11 @@ type Server struct {
|
||||
schemasDir string
|
||||
partgen *partnum.Generator
|
||||
storage *storage.Storage
|
||||
auth *auth.Service
|
||||
sessions *scs.SessionManager
|
||||
oidc *auth.OIDCBackend
|
||||
authConfig *config.AuthConfig
|
||||
webHandler *WebHandler
|
||||
}
|
||||
|
||||
// NewServer creates a new API server.
|
||||
@@ -39,6 +47,10 @@ func NewServer(
|
||||
schemas map[string]*schema.Schema,
|
||||
schemasDir string,
|
||||
store *storage.Storage,
|
||||
authService *auth.Service,
|
||||
sessionManager *scs.SessionManager,
|
||||
oidcBackend *auth.OIDCBackend,
|
||||
authCfg *config.AuthConfig,
|
||||
) *Server {
|
||||
items := db.NewItemRepository(database)
|
||||
projects := db.NewProjectRepository(database)
|
||||
@@ -56,6 +68,10 @@ func NewServer(
|
||||
schemasDir: schemasDir,
|
||||
partgen: partgen,
|
||||
storage: store,
|
||||
auth: authService,
|
||||
sessions: sessionManager,
|
||||
oidc: oidcBackend,
|
||||
authConfig: authCfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +374,9 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
ItemType: itemType,
|
||||
Description: req.Description,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
item.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
properties := req.Properties
|
||||
if properties == nil {
|
||||
@@ -465,7 +484,11 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Update the item record (UUID stays the same)
|
||||
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription); err != nil {
|
||||
var updatedBy *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
updatedBy = &user.Username
|
||||
}
|
||||
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription, updatedBy); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update item")
|
||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||
return
|
||||
@@ -478,6 +501,9 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
Properties: req.Properties,
|
||||
Comment: &req.Comment,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
rev.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
||||
@@ -782,7 +808,11 @@ func (s *Server) HandleRollbackRevision(w http.ResponseWriter, r *http.Request)
|
||||
comment = fmt.Sprintf("Rollback to revision %d", revNum)
|
||||
}
|
||||
|
||||
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, nil)
|
||||
var createdBy *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
createdBy = &user.Username
|
||||
}
|
||||
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, createdBy)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create rollback revision")
|
||||
writeError(w, http.StatusBadRequest, "rollback_failed", err.Error())
|
||||
@@ -1102,6 +1132,9 @@ func (s *Server) HandleCreateRevision(w http.ResponseWriter, r *http.Request) {
|
||||
Properties: req.Properties,
|
||||
Comment: &req.Comment,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
rev.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
||||
@@ -1192,6 +1225,9 @@ func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
FileSize: &result.Size,
|
||||
Comment: &comment,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
rev.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
||||
@@ -1387,6 +1423,9 @@ func (s *Server) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
project.CreatedBy = &user.Username
|
||||
}
|
||||
|
||||
if err := s.projects.Create(ctx, project); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create project")
|
||||
|
||||
@@ -3,9 +3,12 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -54,3 +57,113 @@ func Recoverer(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth extracts the user from a session cookie or API token and injects
|
||||
// it into the request context. If auth is disabled, injects a synthetic dev user.
|
||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Dev mode: inject synthetic admin user
|
||||
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||
devUser := &auth.User{
|
||||
ID: "00000000-0000-0000-0000-000000000000",
|
||||
Username: "dev",
|
||||
DisplayName: "Developer",
|
||||
Email: "dev@localhost",
|
||||
Role: auth.RoleAdmin,
|
||||
AuthSource: "local",
|
||||
}
|
||||
ctx := auth.ContextWithUser(r.Context(), devUser)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Check for API token (Authorization: Bearer silo_...)
|
||||
if token := extractBearerToken(r); token != "" {
|
||||
user, err := s.auth.ValidateToken(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired API token")
|
||||
return
|
||||
}
|
||||
ctx := auth.ContextWithUser(r.Context(), user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check session
|
||||
if s.sessions != nil {
|
||||
userID := s.sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
user, err := s.auth.GetUserByID(r.Context(), userID)
|
||||
if err == nil && user != nil {
|
||||
ctx := auth.ContextWithUser(r.Context(), user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
// Invalid session — destroy it
|
||||
_ = s.sessions.Destroy(r.Context())
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Not authenticated
|
||||
if isAPIRequest(r) {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
} else {
|
||||
next := r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
next += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// RequireRole returns middleware that rejects users below the given role.
|
||||
// Role hierarchy: admin > editor > viewer.
|
||||
func (s *Server) RequireRole(minimum string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if !auth.RoleSatisfies(user.Role, minimum) {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"Insufficient permissions: requires "+minimum+" role")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFProtect wraps nosurf for browser-based form submissions.
|
||||
// API routes (using Bearer token auth) are exempt.
|
||||
func (s *Server) CSRFProtect(next http.Handler) http.Handler {
|
||||
csrfHandler := nosurf.New(next)
|
||||
csrfHandler.SetBaseCookie(http.Cookie{
|
||||
HttpOnly: true,
|
||||
Secure: s.authConfig != nil && s.authConfig.Enabled,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
csrfHandler.ExemptGlob("/api/*")
|
||||
csrfHandler.ExemptPath("/health")
|
||||
csrfHandler.ExemptPath("/ready")
|
||||
csrfHandler.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusForbidden, "csrf_failed", "CSRF token validation failed")
|
||||
}))
|
||||
return csrfHandler
|
||||
}
|
||||
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.URL.Path, "/api/")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -13,119 +14,170 @@ import (
|
||||
func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware stack
|
||||
// Base middleware stack
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(RequestLogger(logger))
|
||||
r.Use(Recoverer(logger))
|
||||
|
||||
// CORS: configurable origins, locked down when auth is enabled
|
||||
corsOrigins := []string{"*"}
|
||||
corsCredentials := false
|
||||
if server.authConfig != nil && server.authConfig.Enabled {
|
||||
if len(server.authConfig.CORS.AllowedOrigins) > 0 {
|
||||
corsOrigins = server.authConfig.CORS.AllowedOrigins
|
||||
}
|
||||
corsCredentials = true
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||
AllowedOrigins: corsOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"Link", "X-Request-ID"},
|
||||
AllowCredentials: false,
|
||||
AllowCredentials: corsCredentials,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Session middleware (must come before auth middleware)
|
||||
if server.sessions != nil {
|
||||
r.Use(server.sessions.LoadAndSave)
|
||||
}
|
||||
|
||||
// Web handler for HTML pages
|
||||
webHandler, err := NewWebHandler(logger)
|
||||
webHandler, err := NewWebHandler(logger, server)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to create web handler")
|
||||
}
|
||||
|
||||
// Health endpoints
|
||||
// Health endpoints (no auth)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
r.Get("/ready", server.HandleReady)
|
||||
|
||||
// Web UI routes
|
||||
r.Get("/", webHandler.HandleIndex)
|
||||
r.Get("/projects", webHandler.HandleProjectsPage)
|
||||
r.Get("/schemas", webHandler.HandleSchemasPage)
|
||||
// Auth endpoints (no auth required)
|
||||
r.Get("/login", server.HandleLoginPage)
|
||||
r.Post("/login", server.HandleLogin)
|
||||
r.Post("/logout", server.HandleLogout)
|
||||
r.Get("/auth/oidc", server.HandleOIDCLogin)
|
||||
r.Get("/auth/callback", server.HandleOIDCCallback)
|
||||
|
||||
// API routes
|
||||
// Web UI routes (require auth + CSRF)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireAuth)
|
||||
r.Use(server.CSRFProtect)
|
||||
|
||||
r.Get("/", webHandler.HandleIndex)
|
||||
r.Get("/projects", webHandler.HandleProjectsPage)
|
||||
r.Get("/schemas", webHandler.HandleSchemasPage)
|
||||
r.Get("/settings", server.HandleSettingsPage)
|
||||
r.Post("/settings/tokens", server.HandleCreateTokenWeb)
|
||||
r.Post("/settings/tokens/{id}/revoke", server.HandleRevokeTokenWeb)
|
||||
})
|
||||
|
||||
// API routes (require auth, no CSRF — token auth instead)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Schemas
|
||||
r.Use(server.RequireAuth)
|
||||
|
||||
// Auth endpoints
|
||||
r.Get("/auth/me", server.HandleGetCurrentUser)
|
||||
r.Route("/auth/tokens", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListTokens)
|
||||
r.Post("/", server.HandleCreateToken)
|
||||
r.Delete("/{id}", server.HandleRevokeToken)
|
||||
})
|
||||
|
||||
// Schemas (read: viewer, write: editor)
|
||||
r.Route("/schemas", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListSchemas)
|
||||
r.Get("/{name}", server.HandleGetSchema)
|
||||
r.Get("/{name}/properties", server.HandleGetPropertySchema)
|
||||
|
||||
// Schema segment value management
|
||||
r.Route("/{name}/segments/{segment}/values", func(r chi.Router) {
|
||||
r.Post("/", server.HandleAddSchemaValue)
|
||||
r.Put("/{code}", server.HandleUpdateSchemaValue)
|
||||
r.Delete("/{code}", server.HandleDeleteSchemaValue)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Route("/{name}/segments/{segment}/values", func(r chi.Router) {
|
||||
r.Post("/", server.HandleAddSchemaValue)
|
||||
r.Put("/{code}", server.HandleUpdateSchemaValue)
|
||||
r.Delete("/{code}", server.HandleDeleteSchemaValue)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Projects
|
||||
// Projects (read: viewer, write: editor)
|
||||
r.Route("/projects", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListProjects)
|
||||
r.Post("/", server.HandleCreateProject)
|
||||
r.Get("/{code}", server.HandleGetProject)
|
||||
r.Put("/{code}", server.HandleUpdateProject)
|
||||
r.Delete("/{code}", server.HandleDeleteProject)
|
||||
r.Get("/{code}/items", server.HandleGetProjectItems)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Post("/", server.HandleCreateProject)
|
||||
r.Put("/{code}", server.HandleUpdateProject)
|
||||
r.Delete("/{code}", server.HandleDeleteProject)
|
||||
})
|
||||
})
|
||||
|
||||
// Items
|
||||
// Items (read: viewer, write: editor)
|
||||
r.Route("/items", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListItems)
|
||||
r.Get("/search", server.HandleFuzzySearch)
|
||||
r.Post("/", server.HandleCreateItem)
|
||||
|
||||
// CSV Import/Export
|
||||
r.Get("/export.csv", server.HandleExportCSV)
|
||||
r.Post("/import", server.HandleImportCSV)
|
||||
r.Get("/template.csv", server.HandleCSVTemplate)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Post("/", server.HandleCreateItem)
|
||||
r.Post("/import", server.HandleImportCSV)
|
||||
})
|
||||
|
||||
r.Route("/{partNumber}", func(r chi.Router) {
|
||||
r.Get("/", server.HandleGetItem)
|
||||
r.Put("/", server.HandleUpdateItem)
|
||||
r.Delete("/", server.HandleDeleteItem)
|
||||
|
||||
// Item project tags
|
||||
r.Get("/projects", server.HandleGetItemProjects)
|
||||
r.Post("/projects", server.HandleAddItemProjects)
|
||||
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
|
||||
|
||||
// Revisions
|
||||
r.Get("/revisions", server.HandleListRevisions)
|
||||
r.Post("/revisions", server.HandleCreateRevision)
|
||||
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
||||
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
||||
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
|
||||
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
|
||||
|
||||
// File upload/download
|
||||
r.Post("/file", server.HandleUploadFile)
|
||||
r.Get("/file", server.HandleDownloadLatestFile)
|
||||
r.Get("/file/{revision}", server.HandleDownloadFile)
|
||||
|
||||
// BOM / Relationships
|
||||
r.Get("/bom", server.HandleGetBOM)
|
||||
r.Post("/bom", server.HandleAddBOMEntry)
|
||||
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
||||
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Put("/", server.HandleUpdateItem)
|
||||
r.Delete("/", server.HandleDeleteItem)
|
||||
r.Post("/projects", server.HandleAddItemProjects)
|
||||
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
|
||||
r.Post("/revisions", server.HandleCreateRevision)
|
||||
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
|
||||
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
|
||||
r.Post("/file", server.HandleUploadFile)
|
||||
r.Post("/bom", server.HandleAddBOMEntry)
|
||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Integrations
|
||||
// Integrations (read: viewer, write: editor)
|
||||
r.Route("/integrations/odoo", func(r chi.Router) {
|
||||
r.Get("/config", server.HandleGetOdooConfig)
|
||||
r.Put("/config", server.HandleUpdateOdooConfig)
|
||||
r.Get("/sync-log", server.HandleGetOdooSyncLog)
|
||||
r.Post("/test-connection", server.HandleTestOdooConnection)
|
||||
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
|
||||
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Put("/config", server.HandleUpdateOdooConfig)
|
||||
r.Post("/test-connection", server.HandleTestOdooConnection)
|
||||
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
|
||||
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
|
||||
})
|
||||
})
|
||||
|
||||
// Part number generation
|
||||
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
||||
// Part number generation (editor)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
@@ -482,7 +482,20 @@
|
||||
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
||||
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
|
||||
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
||||
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
|
||||
</nav>
|
||||
{{if .User}}
|
||||
<div class="header-user" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<span style="color:var(--ctp-subtext1);font-size:0.9rem;">{{.User.DisplayName}}</span>
|
||||
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:1rem;font-size:0.75rem;font-weight:600;
|
||||
{{if eq .User.Role "admin"}}background:rgba(203,166,247,0.2);color:var(--ctp-mauve);
|
||||
{{else if eq .User.Role "editor"}}background:rgba(137,180,250,0.2);color:var(--ctp-blue);
|
||||
{{else}}background:rgba(148,226,213,0.2);color:var(--ctp-teal);{{end}}">{{.User.Role}}</span>
|
||||
<form method="POST" action="/logout" style="display:inline;margin:0;">
|
||||
<button type="submit" class="btn-secondary" style="padding:0.35rem 0.75rem;font-size:0.8rem;border-radius:0.4rem;cursor:pointer;border:none;background:var(--ctp-surface1);color:var(--ctp-subtext1);">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
@@ -492,6 +505,8 @@
|
||||
{{template "projects_content" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_content" .}}
|
||||
{{else if eq .Page "settings"}}
|
||||
{{template "settings_content" .}}
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
@@ -501,6 +516,8 @@
|
||||
{{template "projects_scripts" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_scripts" .}}
|
||||
{{else if eq .Page "settings"}}
|
||||
{{template "settings_scripts" .}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
222
internal/api/templates/login.html
Normal file
222
internal/api/templates/login.html
Normal file
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login - Silo</title>
|
||||
<style>
|
||||
:root {
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background-color: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--ctp-surface0);
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: var(--ctp-mauve);
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--ctp-subtext0);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--ctp-red);
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
border: 1px solid rgba(243, 139, 168, 0.2);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-mauve);
|
||||
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--ctp-mauve);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.btn-oidc {
|
||||
background-color: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.btn-oidc:hover {
|
||||
background-color: var(--ctp-sapphire);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">Silo</h1>
|
||||
<p class="login-subtitle">Product Lifecycle Management</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-msg">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login{{if .Next}}?next={{.Next}}{{end}}"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
class="form-input"
|
||||
placeholder="Username or LDAP uid"
|
||||
autofocus
|
||||
required
|
||||
value="{{.Username}}"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="form-input"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
</form>
|
||||
|
||||
{{if .OIDCEnabled}}
|
||||
<div class="divider"><span>or</span></div>
|
||||
<a href="/auth/oidc" class="btn btn-oidc">Sign in with Keycloak</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
291
internal/api/templates/settings.html
Normal file
291
internal/api/templates/settings.html
Normal file
@@ -0,0 +1,291 @@
|
||||
{{define "settings_content"}}
|
||||
<style>
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.settings-info {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.settings-info dt {
|
||||
color: var(--ctp-subtext0);
|
||||
font-weight: 500;
|
||||
}
|
||||
.settings-info dd {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.role-admin {
|
||||
background: rgba(203, 166, 247, 0.2);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
.role-editor {
|
||||
background: rgba(137, 180, 250, 0.2);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
.role-viewer {
|
||||
background: rgba(148, 226, 213, 0.2);
|
||||
color: var(--ctp-teal);
|
||||
}
|
||||
.token-display {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-peach);
|
||||
word-break: break-all;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.new-token-banner {
|
||||
background: rgba(166, 227, 161, 0.1);
|
||||
border: 1px solid rgba(166, 227, 161, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.new-token-banner p {
|
||||
color: var(--ctp-green);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.new-token-banner .hint {
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
.copy-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--ctp-surface1);
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: var(--ctp-surface2);
|
||||
}
|
||||
.token-prefix {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(243, 139, 168, 0.15);
|
||||
color: var(--ctp-red);
|
||||
border: none;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgba(243, 139, 168, 0.25);
|
||||
}
|
||||
.create-token-form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.create-token-form .form-group {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.no-tokens {
|
||||
color: var(--ctp-subtext0);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Account</h2>
|
||||
</div>
|
||||
{{if .User}}
|
||||
<dl class="settings-info">
|
||||
<dt>Username</dt>
|
||||
<dd>{{.User.Username}}</dd>
|
||||
<dt>Display Name</dt>
|
||||
<dd>{{.User.DisplayName}}</dd>
|
||||
<dt>Email</dt>
|
||||
<dd>
|
||||
{{if .User.Email}}{{.User.Email}}{{else}}<span
|
||||
style="color: var(--ctp-overlay0)"
|
||||
>Not set</span
|
||||
>{{end}}
|
||||
</dd>
|
||||
<dt>Auth Source</dt>
|
||||
<dd>{{.User.AuthSource}}</dd>
|
||||
<dt>Role</dt>
|
||||
<dd>
|
||||
<span class="role-badge role-{{.User.Role}}"
|
||||
>{{.User.Role}}</span
|
||||
>
|
||||
</dd>
|
||||
</dl>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">API Tokens</h2>
|
||||
</div>
|
||||
<p
|
||||
style="
|
||||
color: var(--ctp-subtext0);
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
"
|
||||
>
|
||||
API tokens allow the FreeCAD plugin and scripts to authenticate with
|
||||
Silo. Tokens inherit your role permissions.
|
||||
</p>
|
||||
|
||||
{{if and .Data (index .Data "new_token")}} {{if ne (index .Data
|
||||
"new_token") ""}}
|
||||
<div class="new-token-banner">
|
||||
<p>Your new API token (copy it now — it won't be shown again):</p>
|
||||
<code class="token-display" id="new-token-value"
|
||||
>{{index .Data "new_token"}}</code
|
||||
>
|
||||
<button
|
||||
class="copy-btn"
|
||||
onclick="
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
document.getElementById('new-token-value')
|
||||
.textContent,
|
||||
)
|
||||
.then(() => {
|
||||
this.textContent = 'Copied!';
|
||||
})
|
||||
"
|
||||
>
|
||||
Copy to clipboard
|
||||
</button>
|
||||
<p class="hint">
|
||||
Store this token securely. You will not be able to see it again.
|
||||
</p>
|
||||
</div>
|
||||
{{end}} {{end}}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/settings/tokens"
|
||||
class="create-token-form"
|
||||
style="margin-bottom: 1.5rem"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">Token Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-input"
|
||||
placeholder="e.g., FreeCAD workstation"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
style="padding: 0.75rem 1.25rem; white-space: nowrap"
|
||||
>
|
||||
Create Token
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokens-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}} {{define "settings_scripts"}}
|
||||
<script>
|
||||
(function () {
|
||||
async function loadTokens() {
|
||||
try {
|
||||
const resp = await fetch("/api/auth/tokens");
|
||||
if (!resp.ok) return;
|
||||
const tokens = await resp.json();
|
||||
const tbody = document.getElementById("tokens-table");
|
||||
if (!tokens || tokens.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="6" class="no-tokens">No API tokens yet. Create one to get started.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = tokens
|
||||
.map(
|
||||
(t) => `
|
||||
<tr>
|
||||
<td>${escHtml(t.name)}</td>
|
||||
<td><span class="token-prefix">${escHtml(t.token_prefix)}...</span></td>
|
||||
<td>${formatDate(t.created_at)}</td>
|
||||
<td>${t.last_used_at ? formatDate(t.last_used_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
|
||||
<td>${t.expires_at ? formatDate(t.expires_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
|
||||
<td>
|
||||
<form method="POST" action="/settings/tokens/${t.id}/revoke" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-danger" onclick="return confirm('Revoke this token?')">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
} catch (e) {
|
||||
console.error("Failed to load tokens:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(s) {
|
||||
if (!s) return "";
|
||||
const d = new Date(s);
|
||||
return (
|
||||
d.toLocaleDateString() +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Check for newly created token in URL or page state
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// The token is passed via a cookie/session flash, rendered by the server if present
|
||||
loadTokens();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,11 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -16,58 +17,49 @@ var templatesFS embed.FS
|
||||
type WebHandler struct {
|
||||
templates *template.Template
|
||||
logger zerolog.Logger
|
||||
server *Server
|
||||
}
|
||||
|
||||
// NewWebHandler creates a new web handler.
|
||||
func NewWebHandler(logger zerolog.Logger) (*WebHandler, error) {
|
||||
// Parse templates from embedded filesystem
|
||||
func NewWebHandler(logger zerolog.Logger, server *Server) (*WebHandler, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WebHandler{
|
||||
wh := &WebHandler{
|
||||
templates: tmpl,
|
||||
logger: logger,
|
||||
}, nil
|
||||
server: server,
|
||||
}
|
||||
|
||||
// Store reference on server for auth handlers to use templates
|
||||
server.webHandler = wh
|
||||
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
// PageData holds data for page rendering.
|
||||
type PageData struct {
|
||||
Title string
|
||||
Page string
|
||||
Data any
|
||||
}
|
||||
|
||||
// render executes a page template within the base layout.
|
||||
func (h *WebHandler) render(w http.ResponseWriter, page string, data PageData) {
|
||||
// First, render the page-specific content
|
||||
var contentBuf bytes.Buffer
|
||||
if err := h.templates.ExecuteTemplate(&contentBuf, page+".html", data); err != nil {
|
||||
h.logger.Error().Err(err).Str("page", page).Msg("failed to render page template")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Now render the base template with the content
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
h.logger.Error().Err(err).Msg("failed to render base template")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
Title string
|
||||
Page string
|
||||
Data any
|
||||
User *auth.User
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// HandleIndex serves the main items page.
|
||||
func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if this is the root path
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
Title: "Items",
|
||||
Page: "items",
|
||||
Title: "Items",
|
||||
Page: "items",
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -80,8 +72,10 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// HandleProjectsPage serves the projects page.
|
||||
func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{
|
||||
Title: "Projects",
|
||||
Page: "projects",
|
||||
Title: "Projects",
|
||||
Page: "projects",
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -94,8 +88,10 @@ func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request)
|
||||
// HandleSchemasPage serves the schemas page.
|
||||
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{
|
||||
Title: "Schemas",
|
||||
Page: "schemas",
|
||||
Title: "Schemas",
|
||||
Page: "schemas",
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
Reference in New Issue
Block a user