Add [sso].public_url so the browser SSO authorize redirect uses the public MCIAS hostname while the code exchange stays on the internal address (mcdsl v1.9.0). Document the SSO URL split and the rootless-podman / unikernel-eligibility rules in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
7.2 KiB
Go
252 lines
7.2 KiB
Go
package webserver
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.wntrmute.dev/mc/mcdsl/auth"
|
|
"git.wntrmute.dev/mc/mcdsl/csrf"
|
|
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
|
"git.wntrmute.dev/mc/mcdsl/web"
|
|
|
|
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
|
|
|
"git.wntrmute.dev/mc/mcq/internal/db"
|
|
"git.wntrmute.dev/mc/mcq/internal/render"
|
|
)
|
|
|
|
const cookieName = "mcq_session"
|
|
|
|
// Config holds webserver-specific configuration.
|
|
type Config struct {
|
|
ServiceName string
|
|
Tags []string
|
|
// SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead
|
|
// of the direct username/password login form.
|
|
MciasURL string // internal MCIAS URL for the server-to-server code exchange
|
|
PublicURL string // browser-facing MCIAS URL for the authorize redirect (optional)
|
|
CACert string
|
|
RedirectURI string
|
|
}
|
|
|
|
// Server is the MCQ web UI server.
|
|
type Server struct {
|
|
db *db.DB
|
|
auth *auth.Authenticator
|
|
csrf *csrf.Protect
|
|
ssoClient *mcdsso.Client
|
|
render *render.Renderer
|
|
logger *slog.Logger
|
|
config Config
|
|
}
|
|
|
|
// New creates a web UI server.
|
|
func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) {
|
|
csrfSecret := make([]byte, 32)
|
|
if _, err := rand.Read(csrfSecret); err != nil {
|
|
return nil, fmt.Errorf("generate CSRF secret: %w", err)
|
|
}
|
|
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
|
|
|
|
s := &Server{
|
|
db: database,
|
|
auth: authenticator,
|
|
csrf: csrfProtect,
|
|
render: render.New(),
|
|
logger: logger,
|
|
config: cfg,
|
|
}
|
|
|
|
// Create SSO client if the service has an SSO redirect_uri configured.
|
|
if cfg.RedirectURI != "" {
|
|
ssoClient, err := mcdsso.New(mcdsso.Config{
|
|
MciasURL: cfg.MciasURL,
|
|
PublicURL: cfg.PublicURL,
|
|
ClientID: "mcq",
|
|
RedirectURI: cfg.RedirectURI,
|
|
CACert: cfg.CACert,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create SSO client: %w", err)
|
|
}
|
|
s.ssoClient = ssoClient
|
|
authorizeURL := cfg.PublicURL
|
|
if authorizeURL == "" {
|
|
authorizeURL = cfg.MciasURL
|
|
}
|
|
logger.Info("SSO enabled: redirecting to MCIAS for login",
|
|
"authorize_url", authorizeURL, "exchange_url", cfg.MciasURL)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// RegisterRoutes adds web UI routes to the given router.
|
|
func (s *Server) RegisterRoutes(r chi.Router) {
|
|
if s.ssoClient != nil {
|
|
r.Get("/login", s.handleSSOLogin)
|
|
r.Get("/sso/redirect", s.handleSSORedirect)
|
|
r.Get("/sso/callback", s.handleSSOCallback)
|
|
} else {
|
|
r.Get("/login", s.handleLoginPage)
|
|
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
|
|
}
|
|
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
|
|
|
|
// Authenticated routes.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(web.RequireAuth(s.auth, cookieName, "/login"))
|
|
r.Use(s.csrf.Middleware)
|
|
|
|
r.Get("/", s.handleList)
|
|
r.Get("/d/{slug}", s.handleRead)
|
|
r.Post("/d/{slug}/read", s.handleMarkRead)
|
|
r.Post("/d/{slug}/unread", s.handleMarkUnread)
|
|
r.Post("/d/{slug}/delete", s.handleDelete)
|
|
r.Post("/logout", s.handleLogout)
|
|
})
|
|
}
|
|
|
|
type pageData struct {
|
|
Username string
|
|
Error string
|
|
Title string
|
|
Content any
|
|
SSO bool
|
|
}
|
|
|
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
|
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{}, s.csrf.TemplateFunc(w))
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
totpCode := r.FormValue("totp_code")
|
|
|
|
token, _, err := s.auth.Login(username, password, totpCode)
|
|
if err != nil {
|
|
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{Error: "Invalid credentials"}, s.csrf.TemplateFunc(w))
|
|
return
|
|
}
|
|
|
|
web.SetSessionCookie(w, cookieName, token)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
|
|
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
|
|
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w))
|
|
}
|
|
|
|
// handleSSORedirect initiates the SSO redirect to MCIAS.
|
|
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
|
|
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil {
|
|
s.logger.Error("sso: redirect to login", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
|
|
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
|
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq")
|
|
if err != nil {
|
|
s.logger.Error("sso: callback", "error", err)
|
|
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
web.SetSessionCookie(w, cookieName, token)
|
|
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
token := web.GetSessionToken(r, cookieName)
|
|
if token != "" {
|
|
_ = s.auth.Logout(token)
|
|
}
|
|
web.ClearSessionCookie(w, cookieName)
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
type listData struct {
|
|
Username string
|
|
Documents []db.Document
|
|
}
|
|
|
|
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
|
info := auth.TokenInfoFromContext(r.Context())
|
|
docs, err := s.db.ListDocuments()
|
|
if err != nil {
|
|
s.logger.Error("failed to list documents", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if docs == nil {
|
|
docs = []db.Document{}
|
|
}
|
|
web.RenderTemplate(w, mcqweb.FS, "list.html", listData{
|
|
Username: info.Username,
|
|
Documents: docs,
|
|
}, s.csrf.TemplateFunc(w))
|
|
}
|
|
|
|
type readData struct {
|
|
Username string
|
|
Doc db.Document
|
|
HTML template.HTML
|
|
}
|
|
|
|
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
|
|
info := auth.TokenInfoFromContext(r.Context())
|
|
slug := chi.URLParam(r, "slug")
|
|
|
|
doc, err := s.db.GetDocument(slug)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
html, err := s.render.Render([]byte(doc.Body))
|
|
if err != nil {
|
|
s.logger.Error("failed to render markdown", "slug", slug, "error", err)
|
|
http.Error(w, "render error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
web.RenderTemplate(w, mcqweb.FS, "read.html", readData{
|
|
Username: info.Username,
|
|
Doc: *doc,
|
|
HTML: template.HTML(html), //nolint:gosec // markdown rendered by goldmark, not user-controlled injection
|
|
}, s.csrf.TemplateFunc(w))
|
|
}
|
|
|
|
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
if _, err := s.db.MarkRead(slug); err != nil {
|
|
s.logger.Error("failed to mark read", "slug", slug, "error", err)
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
if _, err := s.db.MarkUnread(slug); err != nil {
|
|
s.logger.Error("failed to mark unread", "slug", slug, "error", err)
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
if err := s.db.DeleteDocument(slug); err != nil {
|
|
s.logger.Error("failed to delete document", "slug", slug, "error", err)
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|