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