Migrate to mcdsl: auth, config, csrf, web
- Replace internal/auth with mcdsl/auth - Replace internal/config with mcdsl/config (embed config.Base) - Replace internal/webserver/csrf.go with mcdsl/csrf - Use mcdsl/web for session cookies and template rendering - Use mcdsl/httpserver for server setup and StatusWriter - Remove direct mcias client library dependency - Update .golangci.yaml to v2 format (formatters section) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
srv/
|
||||
mcat
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.idea/
|
||||
.vscode/
|
||||
80
.golangci.yaml
Normal file
80
.golangci.yaml
Normal file
@@ -0,0 +1,80 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- unused
|
||||
- errorlint
|
||||
- gosec
|
||||
- staticcheck
|
||||
- revive
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-blank: false
|
||||
check-type-assertions: true
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment
|
||||
|
||||
gosec:
|
||||
severity: medium
|
||||
confidence: medium
|
||||
excludes:
|
||||
- G104
|
||||
|
||||
errorlint:
|
||||
errorf: true
|
||||
asserts: true
|
||||
comparison: true
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: error-return
|
||||
severity: error
|
||||
- name: unexported-return
|
||||
severity: error
|
||||
- name: error-strings
|
||||
severity: warning
|
||||
- name: if-return
|
||||
severity: warning
|
||||
- name: increment-decrement
|
||||
severity: warning
|
||||
- name: var-naming
|
||||
severity: warning
|
||||
- name: range
|
||||
severity: warning
|
||||
- name: time-naming
|
||||
severity: warning
|
||||
- name: indent-error-flow
|
||||
severity: warning
|
||||
- name: early-return
|
||||
severity: warning
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor
|
||||
rules:
|
||||
- path: "_test\\.go"
|
||||
linters:
|
||||
- gosec
|
||||
text: "G101"
|
||||
96
ARCHITECTURE.md
Normal file
96
ARCHITECTURE.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
mcat is a lightweight web application for testing MCIAS login policies. It
|
||||
presents a login form, forwards credentials (along with a configurable
|
||||
`service_name` and `tags`) to an MCIAS instance, and displays whether the
|
||||
login was accepted or denied. This lets operators verify that login policy
|
||||
rules behave as expected for a given service context.
|
||||
|
||||
## System Diagram
|
||||
|
||||
```
|
||||
┌──────────────┐ HTTPS ┌──────────────┐
|
||||
│ Browser │◄──────────────────►│ mcat │
|
||||
│ (htmx UI) │ │ web server │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
│ HTTPS (Login, ValidateToken)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ MCIAS │
|
||||
│ (auth) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Web Server (`internal/webserver/`)
|
||||
|
||||
Single chi-based HTTP server serving the web UI over TLS. Handles:
|
||||
|
||||
- **Login flow**: Renders login form, submits credentials to MCIAS via the
|
||||
auth package, sets session cookie on success.
|
||||
- **Session management**: HttpOnly/Secure/SameSite=Strict cookies containing
|
||||
the MCIAS bearer token.
|
||||
- **CSRF protection**: HMAC-SHA256 double-submit cookies on all mutating
|
||||
requests.
|
||||
- **Template rendering**: Go `html/template` with a layout + page block
|
||||
pattern. Templates and static files are embedded via `//go:embed`.
|
||||
|
||||
### Auth Package (`internal/auth/`)
|
||||
|
||||
Wraps the MCIAS Go client library. Provides:
|
||||
|
||||
- `Login(username, password, totpCode)` — Forwards to MCIAS with the
|
||||
configured `service_name` and `tags`. Returns the bearer token.
|
||||
- `ValidateToken(token)` — Validates against MCIAS with 30-second cache
|
||||
(keyed by SHA-256 of token).
|
||||
- `Logout()` — Revokes the token on MCIAS.
|
||||
|
||||
### Configuration (`internal/config/`)
|
||||
|
||||
TOML configuration loaded at startup. Required fields are validated; the
|
||||
service refuses to start if any are missing.
|
||||
|
||||
### CLI (`cmd/mcat/`)
|
||||
|
||||
Cobra-based. Subcommands: `server` (start), `version`.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen_addr = ":8443" # HTTPS listen address
|
||||
tls_cert = "cert.pem" # TLS certificate path
|
||||
tls_key = "key.pem" # TLS key path
|
||||
|
||||
[mcias]
|
||||
server_url = "https://..." # MCIAS server URL
|
||||
ca_cert = "" # Optional CA cert for MCIAS TLS
|
||||
service_name = "mcat" # Sent with login requests for policy eval
|
||||
tags = [] # Sent with login requests for policy eval
|
||||
|
||||
[log]
|
||||
level = "info" # debug, info, warn, error
|
||||
```
|
||||
|
||||
## Web Routes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------|------|--------------------------|
|
||||
| GET | `/` | No | Redirect to /dashboard or /login |
|
||||
| GET | `/login` | No | Login form |
|
||||
| POST | `/login` | No | Submit login |
|
||||
| POST | `/logout` | Yes | Revoke token, clear cookie |
|
||||
| GET | `/dashboard`| Yes | Session info display |
|
||||
| GET | `/static/*` | No | Embedded static files |
|
||||
|
||||
## Security
|
||||
|
||||
- TLS 1.3 minimum, no fallback.
|
||||
- CSRF via HMAC-SHA256 double-submit cookies.
|
||||
- Session cookies: HttpOnly, Secure, SameSite=Strict.
|
||||
- All authentication delegated to MCIAS — no local user database.
|
||||
- Token validation cached for 30 seconds to reduce MCIAS load.
|
||||
46
CLAUDE.md
Normal file
46
CLAUDE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
mcat is a lightweight web application for testing MCIAS login policies. It presents a login form, forwards credentials (with configurable `service_name` and `tags`) to MCIAS, and shows whether the login was accepted or denied by policy. Single binary, no database, no gRPC.
|
||||
|
||||
Module path: `git.wntrmute.dev/kyle/mcat`
|
||||
|
||||
MCIAS client library: `git.wntrmute.dev/kyle/mcias/clients/go` (imported as `mcias`), local replace directive in go.mod.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
make mcat # Build the mcat binary (stripped, version-injected)
|
||||
make build # Build all packages
|
||||
make test # Run all tests
|
||||
make vet # Run go vet
|
||||
make lint # Run golangci-lint v2
|
||||
make all # Full pipeline: vet → lint → test → build
|
||||
make devserver # Build and run locally against srv/mcat.toml
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
```bash
|
||||
go test ./internal/auth/ -run TestLoginSuccess
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/mcat/` — Cobra CLI entry point. `server` subcommand wires config → auth → webserver.
|
||||
- `internal/auth/` — Wraps MCIAS client for login/logout/token validation with 30s cache.
|
||||
- `internal/config/` — TOML config loading and validation.
|
||||
- `internal/webserver/` — Chi-based web server with CSRF (HMAC-SHA256 double-submit cookies), session cookies, and template rendering.
|
||||
- `web/` — Embedded templates (layout + page blocks) and static files (htmx, CSS).
|
||||
- `deploy/` — Dockerfile, systemd unit, install script, example config.
|
||||
- `srv/` — Local dev data directory (gitignored).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **No test frameworks**: Use stdlib `testing` only.
|
||||
- **Auth via MCIAS only**: No local user databases.
|
||||
- **TLS 1.3 minimum**, no exceptions.
|
||||
- **CSRF on all mutations**: Double-submit cookie pattern, validated in middleware.
|
||||
- **Session cookies**: HttpOnly, Secure, SameSite=Strict.
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" -o mcat ./cmd/mcat
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk --no-cache add ca-certificates && \
|
||||
adduser -D -h /srv/mcat mcat
|
||||
USER mcat
|
||||
WORKDIR /srv/mcat
|
||||
COPY --from=builder /build/mcat /usr/local/bin/mcat
|
||||
VOLUME /srv/mcat
|
||||
EXPOSE 8443
|
||||
ENTRYPOINT ["mcat"]
|
||||
CMD ["server", "--config", "/srv/mcat/mcat.toml"]
|
||||
26
Makefile
Normal file
26
Makefile
Normal file
@@ -0,0 +1,26 @@
|
||||
.PHONY: build test vet lint clean all devserver
|
||||
|
||||
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
|
||||
|
||||
mcat:
|
||||
go build $(LDFLAGS) -o mcat ./cmd/mcat
|
||||
|
||||
build:
|
||||
go build ./...
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
clean:
|
||||
rm -f mcat
|
||||
|
||||
all: vet lint test mcat
|
||||
|
||||
devserver: mcat
|
||||
./mcat server --config srv/mcat.toml
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# mcat
|
||||
|
||||
mcat is a lightweight web application for testing and auditing MCIAS login
|
||||
policies. It presents a login form that forwards credentials, along with a
|
||||
configurable `service_name` and `tags`, to an MCIAS instance. This lets
|
||||
operators verify that login policy rules behave as expected for a given
|
||||
service context.
|
||||
|
||||
It follows the standard Metacircular Dynamics engineering standards.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make mcat
|
||||
|
||||
# Configure (copy and edit the example config)
|
||||
mkdir -p srv/certs
|
||||
cp deploy/examples/mcat.toml.example srv/mcat.toml
|
||||
# Edit srv/mcat.toml with your MCIAS URL, TLS certs, service_name, and tags
|
||||
|
||||
# Run
|
||||
./mcat server --config srv/mcat.toml
|
||||
```
|
||||
|
||||
Then open `https://localhost:8443` in a browser.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make all # vet, lint, test, build
|
||||
make test # tests only
|
||||
make lint # golangci-lint
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design, routes, config reference
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational procedures
|
||||
68
RUNBOOK.md
Normal file
68
RUNBOOK.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Runbook
|
||||
|
||||
## Service Overview
|
||||
|
||||
mcat is a web application for testing MCIAS login policies. It runs a TLS
|
||||
web server that lets users log in via MCIAS with a configurable service
|
||||
name and tag set.
|
||||
|
||||
## Health Check
|
||||
|
||||
1. Open `https://<host>:8443/login` in a browser.
|
||||
2. If the login form renders, the service is healthy.
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
systemctl start mcat
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
systemctl stop mcat
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
journalctl -u mcat -f
|
||||
```
|
||||
|
||||
### Run Locally
|
||||
|
||||
```bash
|
||||
make mcat
|
||||
./mcat server --config srv/mcat.toml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `/srv/mcat/mcat.toml`
|
||||
|
||||
After changing config, restart the service:
|
||||
|
||||
```bash
|
||||
systemctl restart mcat
|
||||
```
|
||||
|
||||
## TLS Certificate Renewal
|
||||
|
||||
1. Replace `/srv/mcat/certs/cert.pem` and `/srv/mcat/certs/key.pem`.
|
||||
2. Restart: `systemctl restart mcat`.
|
||||
|
||||
## MCIAS Unreachable
|
||||
|
||||
If MCIAS is down, logins and token validation will fail. Users will see
|
||||
generic error messages. Check MCIAS connectivity:
|
||||
|
||||
```bash
|
||||
curl -k https://<mcias-host>:8443/v1/health
|
||||
```
|
||||
|
||||
## Escalation
|
||||
|
||||
If the issue is not covered above, check MCIAS logs and status. mcat has
|
||||
no local state — all authentication is delegated to MCIAS.
|
||||
13
deploy/examples/mcat.toml.example
Normal file
13
deploy/examples/mcat.toml.example
Normal file
@@ -0,0 +1,13 @@
|
||||
[server]
|
||||
listen_addr = ":8443"
|
||||
tls_cert = "/srv/mcat/certs/cert.pem"
|
||||
tls_key = "/srv/mcat/certs/key.pem"
|
||||
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
ca_cert = ""
|
||||
service_name = "mcat"
|
||||
tags = []
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
32
deploy/scripts/install.sh
Executable file
32
deploy/scripts/install.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVICE=mcat
|
||||
SRV_DIR="/srv/${SERVICE}"
|
||||
BIN_DIR="/usr/local/bin"
|
||||
|
||||
# Create system user (idempotent).
|
||||
if ! id "${SERVICE}" >/dev/null 2>&1; then
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir "${SRV_DIR}" "${SERVICE}"
|
||||
echo "Created system user: ${SERVICE}"
|
||||
fi
|
||||
|
||||
# Install binary.
|
||||
install -m 0755 "${SERVICE}" "${BIN_DIR}/${SERVICE}"
|
||||
echo "Installed ${BIN_DIR}/${SERVICE}"
|
||||
|
||||
# Create data directory structure.
|
||||
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}"
|
||||
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}/certs"
|
||||
|
||||
# Copy example config if none exists.
|
||||
if [ ! -f "${SRV_DIR}/${SERVICE}.toml" ]; then
|
||||
install -o "${SERVICE}" -g "${SERVICE}" -m 0600 \
|
||||
deploy/examples/${SERVICE}.toml.example "${SRV_DIR}/${SERVICE}.toml"
|
||||
echo "Installed example config to ${SRV_DIR}/${SERVICE}.toml"
|
||||
fi
|
||||
|
||||
# Install systemd units.
|
||||
install -m 0644 deploy/systemd/${SERVICE}.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
echo "Installed systemd unit. Enable with: systemctl enable --now ${SERVICE}"
|
||||
29
deploy/systemd/mcat.service
Normal file
29
deploy/systemd/mcat.service
Normal file
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=mcat - MCIAS Login Policy Tester
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mcat
|
||||
Group=mcat
|
||||
ExecStart=/usr/local/bin/mcat server --config /srv/mcat/mcat.toml
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
ReadWritePaths=/srv/mcat
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module git.wntrmute.dev/kyle/mcat
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/kyle/mcdsl v0.0.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
|
||||
replace git.wntrmute.dev/kyle/mcdsl => /home/kyle/src/metacircular/mcdsl
|
||||
14
go.sum
Normal file
14
go.sum
Normal file
@@ -0,0 +1,14 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
191
internal/webserver/server.go
Normal file
191
internal/webserver/server.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcdsl/auth"
|
||||
"git.wntrmute.dev/kyle/mcdsl/csrf"
|
||||
"git.wntrmute.dev/kyle/mcdsl/httpserver"
|
||||
"git.wntrmute.dev/kyle/mcdsl/web"
|
||||
|
||||
mcatweb "git.wntrmute.dev/kyle/mcat/web"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "mcat_token"
|
||||
csrfCookieName = "mcat_csrf"
|
||||
csrfFieldName = "csrf_token"
|
||||
)
|
||||
|
||||
// Config holds the webserver configuration. Extracted from the service
|
||||
// config so the webserver doesn't depend on the full config package.
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Server is the mcat web UI server.
|
||||
type Server struct {
|
||||
wsCfg Config
|
||||
auth *auth.Authenticator
|
||||
logger *slog.Logger
|
||||
csrf *csrf.Protect
|
||||
staticFS fs.FS
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
// New creates a new web UI server.
|
||||
func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) {
|
||||
staticFS, err := fs.Sub(mcatweb.FS, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webserver: static fs: %w", err)
|
||||
}
|
||||
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("webserver: generate CSRF secret: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
wsCfg: wsCfg,
|
||||
auth: authenticator,
|
||||
logger: logger,
|
||||
csrf: csrf.New(secret, csrfCookieName, csrfFieldName),
|
||||
staticFS: staticFS,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(s.loggingMiddleware)
|
||||
r.Use(s.csrf.Middleware)
|
||||
s.registerRoutes(r)
|
||||
s.handler = r
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for the web server.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.handler
|
||||
}
|
||||
|
||||
func (s *Server) registerRoutes(r chi.Router) {
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS))))
|
||||
|
||||
r.Get("/", s.handleRoot)
|
||||
r.Get("/login", s.handleLogin)
|
||||
r.Post("/login", s.handleLogin)
|
||||
r.Post("/logout", s.requireAuth(s.handleLogout))
|
||||
r.Get("/dashboard", s.requireAuth(s.handleDashboard))
|
||||
}
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
token := web.GetSessionToken(r, sessionCookieName)
|
||||
if token != "" {
|
||||
if _, err := s.auth.ValidateToken(token); err == nil {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
s.renderTemplate(w, "login.html", map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil { //nolint:gosec // form size bounded by http.Server ReadTimeout
|
||||
s.renderTemplate(w, "login.html", map[string]interface{}{
|
||||
"Error": "Invalid form data.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username") //nolint:gosec // parsed above
|
||||
password := r.FormValue("password") //nolint:gosec // parsed above
|
||||
totpCode := r.FormValue("totp_code") //nolint:gosec // parsed above
|
||||
|
||||
token, _, err := s.auth.Login(username, password, totpCode)
|
||||
if err != nil {
|
||||
msg := "Login failed."
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
msg = "Invalid username or password."
|
||||
} else if errors.Is(err, auth.ErrForbidden) {
|
||||
msg = "Login denied by policy."
|
||||
}
|
||||
s.renderTemplate(w, "login.html", map[string]interface{}{
|
||||
"Error": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
web.SetSessionCookie(w, sessionCookieName, token)
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
token := web.GetSessionToken(r, sessionCookieName)
|
||||
if token != "" {
|
||||
if err := s.auth.Logout(token); err != nil {
|
||||
s.logger.Warn("logout failed", "error", err)
|
||||
}
|
||||
}
|
||||
web.ClearSessionCookie(w, sessionCookieName)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
info := auth.TokenInfoFromContext(r.Context())
|
||||
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"Roles": info.Roles,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
"ServiceName": s.wsCfg.ServiceName,
|
||||
"Tags": s.wsCfg.Tags,
|
||||
})
|
||||
}
|
||||
|
||||
// requireAuth wraps a handler that requires a valid MCIAS session.
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := web.GetSessionToken(r, sessionCookieName)
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.auth.ValidateToken(token)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := auth.ContextWithTokenInfo(r.Context(), info)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
web.RenderTemplate(w, mcatweb.FS, name, data, s.csrf.TemplateFunc(w))
|
||||
}
|
||||
|
||||
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := &httpserver.StatusWriter{ResponseWriter: w, Status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
s.logger.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.Status,
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
13
mcat.toml
Normal file
13
mcat.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[server]
|
||||
listen_addr = ":7443"
|
||||
tls_cert = "/Users/kyle/tmp/ca/mc-localhost/cert.pem"
|
||||
tls_key = "/Users/kyle/tmp/ca/mc-localhost/key.pem"
|
||||
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
ca_cert = "/Users/kyle/tmp/ca/ca.pem"
|
||||
service_name = "mcat"
|
||||
tags = ["env:restricted"]
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates static
|
||||
var FS embed.FS
|
||||
1
web/static/htmx.min.js
vendored
Normal file
1
web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
270
web/static/style.css
Normal file
270
web/static/style.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/* mcat — Nord dark theme */
|
||||
|
||||
/* ===========================
|
||||
Colour tokens (Nord palette)
|
||||
=========================== */
|
||||
:root {
|
||||
--n0: #2E3440;
|
||||
--n1: #3B4252;
|
||||
--n2: #434C5E;
|
||||
--n3: #4C566A;
|
||||
--s0: #D8DEE9;
|
||||
--s1: #E5E9F0;
|
||||
--s2: #ECEFF4;
|
||||
--f0: #8FBCBB;
|
||||
--f1: #88C0D0;
|
||||
--f2: #81A1C1;
|
||||
--f3: #5E81AC;
|
||||
--red: #BF616A;
|
||||
--green: #A3BE8C;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Reset
|
||||
=========================== */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { font-size: 16px; }
|
||||
|
||||
/* ===========================
|
||||
Base
|
||||
=========================== */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
background: var(--n0);
|
||||
color: var(--s0);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: var(--f1); text-decoration: none; }
|
||||
a:hover { color: var(--f0); text-decoration: underline; }
|
||||
p { margin-bottom: 0.875rem; }
|
||||
h2 { font-size: 1.375rem; font-weight: 600; color: var(--s2); margin-bottom: 0.25rem; }
|
||||
code {
|
||||
font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--f0);
|
||||
background: var(--n2);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Top navigation
|
||||
=========================== */
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
height: 52px;
|
||||
background: var(--n1);
|
||||
border-bottom: 1px solid var(--n3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topnav-brand {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--s2);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.topnav-brand:hover { color: var(--f1); text-decoration: none; }
|
||||
.topnav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.topnav-user {
|
||||
font-size: 0.875rem;
|
||||
color: var(--s1);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Page containers
|
||||
=========================== */
|
||||
.page-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-container {
|
||||
max-width: 420px;
|
||||
margin: 5rem auto 2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Auth pages
|
||||
=========================== */
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.auth-header .brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--s2);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.auth-header .tagline {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--f2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Cards
|
||||
=========================== */
|
||||
.card {
|
||||
background: var(--n1);
|
||||
border: 1px solid var(--n3);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card:last-child { margin-bottom: 0; }
|
||||
.card-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--f2);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.625rem;
|
||||
border-bottom: 1px solid var(--n2);
|
||||
}
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ===========================
|
||||
Alerts
|
||||
=========================== */
|
||||
.error {
|
||||
background: rgba(191, 97, 106, 0.12);
|
||||
color: #e07c82;
|
||||
border: 1px solid rgba(191, 97, 106, 0.3);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.success {
|
||||
background: rgba(163, 190, 140, 0.1);
|
||||
border: 1px solid rgba(163, 190, 140, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Buttons
|
||||
=========================== */
|
||||
button, .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--f3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
line-height: 1.4;
|
||||
background: var(--f3);
|
||||
color: var(--s2);
|
||||
}
|
||||
button:hover, .btn:hover {
|
||||
background: var(--f2);
|
||||
border-color: var(--f2);
|
||||
text-decoration: none;
|
||||
color: var(--s2);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--s0);
|
||||
border-color: var(--n3);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--n2);
|
||||
color: var(--s1);
|
||||
border-color: var(--n3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Forms
|
||||
=========================== */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--s0);
|
||||
margin-bottom: 0.375rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--n0);
|
||||
border: 1px solid var(--n3);
|
||||
border-radius: 4px;
|
||||
color: var(--s1);
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--f3);
|
||||
box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2);
|
||||
}
|
||||
.form-group input::placeholder { color: var(--n3); }
|
||||
.form-actions { margin-top: 0.25rem; }
|
||||
|
||||
/* ===========================
|
||||
Session info
|
||||
=========================== */
|
||||
.session-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--s0);
|
||||
}
|
||||
.session-info dt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--f2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.session-info dd {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
.session-info dd:last-child { margin-bottom: 0; }
|
||||
.role-tag {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: rgba(94, 129, 172, 0.2);
|
||||
color: var(--f1);
|
||||
border: 1px solid rgba(94, 129, 172, 0.35);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
19
web/templates/dashboard.html
Normal file
19
web/templates/dashboard.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "title"}} - Dashboard{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div class="card-title">Session</div>
|
||||
<div class="success">Login successful. MCIAS accepted this service context.</div>
|
||||
<dl class="session-info">
|
||||
<dt>Username</dt>
|
||||
<dd>{{.Username}}</dd>
|
||||
<dt>Roles</dt>
|
||||
<dd>{{range .Roles}}<span class="role-tag">{{.}}</span>{{end}}</dd>
|
||||
<dt>Service Name</dt>
|
||||
<dd><code>{{.ServiceName}}</code></dd>
|
||||
{{if .Tags}}
|
||||
<dt>Tags</dt>
|
||||
<dd>{{range .Tags}}<code>{{.}}</code> {{end}}</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
{{end}}
|
||||
27
web/templates/layout.html
Normal file
27
web/templates/layout.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>mcat{{block "title" .}}{{end}}</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<a href="/" class="topnav-brand">mcat</a>
|
||||
{{if .Username}}
|
||||
<div class="topnav-right">
|
||||
<span class="topnav-user">{{.Username}}</span>
|
||||
<form method="POST" action="/logout" style="margin:0">
|
||||
{{csrfField}}
|
||||
<button type="submit" class="btn-ghost btn">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="{{block "container-class" .}}page-container{{end}}">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
30
web/templates/login.html
Normal file
30
web/templates/login.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "title"}} - Login{{end}}
|
||||
{{define "container-class"}}auth-container{{end}}
|
||||
{{define "content"}}
|
||||
<div class="auth-header">
|
||||
<div class="brand">mcat</div>
|
||||
<div class="tagline">MCIAS Login Policy Tester</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Sign In</div>
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
{{csrfField}}
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="totp_code">TOTP Code (optional)</label>
|
||||
<input type="text" id="totp_code" name="totp_code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" placeholder="6-digit code">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user