Backend: - Add file_handlers.go: presigned upload/download for item attachments - Add item_files.go: item file and thumbnail DB operations - Add migration 011: item_files table and thumbnail_key column - Update items/projects/relationships DB with extended field support - Update routes: React SPA serving from web/dist, file upload endpoints - Update auth handlers and middleware for cookie + bearer token auth - Remove Go HTML templates (replaced by React SPA) - Update storage client for presigned URL generation Frontend: - Add TagInput component for tag/keyword entry - Add SVG assets for Silo branding and UI icons - Update API client and types for file uploads, auth, extended fields - Update AuthContext for session-based auth flow - Update LoginPage, ProjectsPage, SchemasPage, SettingsPage - Fix tsconfig.node.json Deployment: - Update config.prod.yaml: single-binary SPA layout at /opt/silo - Update silod.service: ReadOnlyPaths for /opt/silo - Add scripts/deploy.sh: build, package, ship, migrate, start - Update docker-compose.yaml and Dockerfile - Add frontend-spec.md design document
275 lines
8.2 KiB
Go
275 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
)
|
|
|
|
// HandleAuthConfig returns public auth configuration for the login page.
|
|
func (s *Server) HandleAuthConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"oidc_enabled": s.oidc != nil,
|
|
"local_enabled": s.authConfig != nil && s.authConfig.Local.Enabled,
|
|
})
|
|
}
|
|
|
|
// HandleLoginPage redirects to the SPA (React handles the login UI).
|
|
func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// 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 == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "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")
|
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "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")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "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)
|
|
}
|
|
|
|
func generateRandomState() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|