418 lines
15 KiB
Go
418 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
// TokensPathPrefix is the URL of the user-facing self-service token
|
|
// management page. Browser GET returns an HTML page; the page fetches
|
|
// the JSON API at TokensAPIPathPrefix.
|
|
const TokensPathPrefix = "/.tokens"
|
|
|
|
// TokensAPIPathPrefix is the URL prefix for the token JSON API.
|
|
//
|
|
// GET /.api/tokens → list current user's tokens (metadata only)
|
|
// POST /.api/tokens → create a new token; returns plaintext once
|
|
// DELETE /.api/tokens/<id> → revoke a token by short ID or full hash
|
|
const TokensAPIPathPrefix = "/.api/tokens"
|
|
|
|
// MaxTokenDescription caps how much free-form text a user can attach
|
|
// to a token. Keeps the on-disk YAML small and the HTML rendering
|
|
// simple.
|
|
const MaxTokenDescription = 200
|
|
|
|
// MaxTokensPerUser is a soft cap; refusing to create a new token at
|
|
// the limit is enough to prevent runaway accumulation. Operators who
|
|
// need more can revoke first.
|
|
const MaxTokensPerUser = 50
|
|
|
|
// tokenAPIView is the JSON shape returned to the management UI. Never
|
|
// includes the plaintext token (which is only returned on creation).
|
|
type tokenAPIView struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Created time.Time `json:"created"`
|
|
Expires time.Time `json:"expires,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// tokenCreateRequest is the body of POST /.api/tokens.
|
|
type tokenCreateRequest struct {
|
|
Description string `json:"description,omitempty"`
|
|
Expires time.Time `json:"expires,omitempty"`
|
|
}
|
|
|
|
// tokenCreateResponse is the body of a successful POST /.api/tokens.
|
|
// The plaintext Token is returned exactly once and is never derivable
|
|
// from the persisted file again.
|
|
type tokenCreateResponse struct {
|
|
Token string `json:"token"`
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Created time.Time `json:"created"`
|
|
Expires time.Time `json:"expires,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ServeTokensAPI dispatches requests to the JSON token API. Anonymous
|
|
// requests are 401; missing token store yields 503 (token issuance is
|
|
// disabled when storage isn't reachable).
|
|
func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if store == nil {
|
|
http.Error(w, "Token store unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix)
|
|
switch {
|
|
case rest == "" || rest == "/":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
handleTokensList(store, email, w)
|
|
case http.MethodPost:
|
|
handleTokensCreate(store, email, w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
case strings.HasPrefix(rest, "/"):
|
|
id := strings.TrimPrefix(rest, "/")
|
|
if id == "" || strings.Contains(id, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodDelete:
|
|
handleTokensRevoke(store, email, id, w)
|
|
default:
|
|
w.Header().Set("Allow", "DELETE")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) {
|
|
list, err := store.List(email)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
views := make([]tokenAPIView, 0, len(list))
|
|
for _, t := range list {
|
|
views = append(views, tokenAPIView{
|
|
ID: t.ID(),
|
|
Email: t.Email,
|
|
Created: t.Created,
|
|
Expires: t.Expires,
|
|
Description: t.Description,
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(views)
|
|
}
|
|
|
|
func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
var req tokenCreateRequest
|
|
if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&req); err != nil {
|
|
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
desc := strings.TrimSpace(req.Description)
|
|
if len(desc) > MaxTokenDescription {
|
|
http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !req.Expires.IsZero() && req.Expires.Before(time.Now()) {
|
|
http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Soft cap to prevent runaway accumulation.
|
|
existing, err := store.List(email)
|
|
if err == nil && len(existing) >= MaxTokensPerUser {
|
|
http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
plaintext, tok, err := store.Generate(email, desc, req.Expires)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(tokenCreateResponse{
|
|
Token: plaintext,
|
|
ID: tok.ID(),
|
|
Email: tok.Email,
|
|
Created: tok.Created,
|
|
Expires: tok.Expires,
|
|
Description: tok.Description,
|
|
})
|
|
}
|
|
|
|
func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) {
|
|
err := store.Revoke(email, id)
|
|
if err == nil {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if errors.Is(err, auth.ErrNotFound) {
|
|
http.NotFound(w, nil)
|
|
return
|
|
}
|
|
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
// ServeTokensPage renders the self-service token management HTML page.
|
|
// The page fetches /.api/tokens via JavaScript; the server-side render
|
|
// only emits a static skeleton plus the authenticated user's email so
|
|
// the page can show "you are signed in as alice@..." without a flash.
|
|
func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
|
|
// Render the token list through the shared tables engine (chrome +
|
|
// declarative columns) with a server-injected collection, instead of a
|
|
// bespoke chrome-less page. Create + revoke are driven by the generic
|
|
// apiActions layer against the existing /.api/tokens endpoints (the
|
|
// tables file-save path is untouched). Falls back to the legacy
|
|
// skeleton if the store or the tables renderer isn't available.
|
|
tablesHTML := EmbeddedTablesHTML()
|
|
if store == nil || len(tablesHTML) == 0 {
|
|
_, _ = w.Write([]byte(renderTokensPage(email, store != nil)))
|
|
return
|
|
}
|
|
injected, err := injectTableContextObj(tablesHTML, buildTokensTableContext(store, email))
|
|
if err != nil {
|
|
_, _ = w.Write([]byte(renderTokensPage(email, true)))
|
|
return
|
|
}
|
|
_, _ = w.Write(injected)
|
|
}
|
|
|
|
// buildTokensTableContext assembles the pre-rendered #table-context for the
|
|
// token page: the user's tokens as read-only rows + the apiActions config that
|
|
// wires create/revoke to /.api/tokens (create surfaces the one-time secret).
|
|
func buildTokensTableContext(store *auth.Store, email string) map[string]interface{} {
|
|
rows := []map[string]interface{}{}
|
|
if list, err := store.List(email); err == nil {
|
|
for _, t := range list {
|
|
exp := "never"
|
|
if !t.Expires.IsZero() {
|
|
exp = t.Expires.Format("2006-01-02")
|
|
}
|
|
rows = append(rows, map[string]interface{}{
|
|
"url": t.ID(),
|
|
"editable": false,
|
|
"data": map[string]interface{}{
|
|
"description": t.Description,
|
|
"created": t.Created.Format("2006-01-02 15:04"),
|
|
"expires": exp,
|
|
"id": t.ID(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
col := func(field, title, width string) map[string]interface{} {
|
|
c := map[string]interface{}{"field": field, "title": title}
|
|
if width != "" {
|
|
c["width"] = width
|
|
}
|
|
return c
|
|
}
|
|
return map[string]interface{}{
|
|
"title": "API tokens",
|
|
"description": "Bearer tokens for CLI / scripted access as " + email + ". A token's secret is shown once, at creation.",
|
|
"addable": false,
|
|
"columns": []map[string]interface{}{
|
|
col("description", "Description", ""),
|
|
col("created", "Created", "12em"),
|
|
col("expires", "Expires", "9em"),
|
|
col("id", "ID", "16em"),
|
|
},
|
|
"rows": rows,
|
|
"apiActions": map[string]interface{}{
|
|
"create": map[string]interface{}{
|
|
"url": TokensAPIPathPrefix,
|
|
"title": "New token",
|
|
"secretField": "token",
|
|
"secretLabel": "New token — copy it now, it is shown only once:",
|
|
"fields": []map[string]interface{}{
|
|
{"name": "description", "label": "Description", "placeholder": "e.g. Field laptop"},
|
|
{"name": "expires", "label": "Expires (optional)", "type": "date"},
|
|
},
|
|
},
|
|
"deleteRow": map[string]interface{}{
|
|
"urlTemplate": TokensAPIPathPrefix + "/{id}",
|
|
"label": "Revoke",
|
|
"confirm": "Revoke this token? Any client using it will stop working.",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// renderTokensPage builds the HTML for the management page. Kept inline
|
|
// (no template files) to match the rest of the handler package; the
|
|
// page is small enough that string-concat is readable and there are no
|
|
// untrusted-data injection concerns beyond the `email` parameter,
|
|
// which is escaped via html.EscapeString.
|
|
func renderTokensPage(email string, storeAvailable bool) string {
|
|
storeNote := ""
|
|
if !storeAvailable {
|
|
storeNote = `<p class="warn">Token store is unavailable — the server failed to initialise <code>.zddc.d/tokens/</code>. Operations on this page will return 503.</p>`
|
|
}
|
|
const css = `
|
|
body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
|
|
.who { color: #666; margin-bottom: 1.5em; }
|
|
fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; }
|
|
legend { font-weight: 600; padding: 0 0.4em; }
|
|
label { display: block; margin: 0.5em 0; }
|
|
label span { display: inline-block; min-width: 9em; }
|
|
input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; }
|
|
button { padding: 0.4em 0.9em; cursor: pointer; }
|
|
.danger { color: #b00; border-color: #b00; }
|
|
.warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 0.5em; }
|
|
th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; }
|
|
.token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; }
|
|
.empty { color: #888; font-style: italic; }
|
|
`
|
|
const js = `
|
|
const api = "/.api/tokens";
|
|
|
|
async function refresh() {
|
|
const tbody = document.querySelector("#tokens tbody");
|
|
tbody.innerHTML = "";
|
|
const r = await fetch(api, { headers: {"Accept": "application/json"} });
|
|
if (!r.ok) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">Could not load tokens (' + r.status + ')</td></tr>';
|
|
return;
|
|
}
|
|
const list = await r.json();
|
|
if (!list.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No tokens issued yet.</td></tr>';
|
|
return;
|
|
}
|
|
for (const t of list) {
|
|
const tr = document.createElement("tr");
|
|
const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)";
|
|
const created = new Date(t.created).toLocaleString();
|
|
tr.innerHTML = '<td><code>' + escapeHTML(t.id) + '</code></td>'
|
|
+ '<td>' + escapeHTML(t.description || "") + '</td>'
|
|
+ '<td>' + escapeHTML(created) + '</td>'
|
|
+ '<td>' + escapeHTML(expires) + '</td>'
|
|
+ '<td><button class="danger" data-id="' + escapeHTML(t.id) + '">Revoke</button></td>';
|
|
tbody.appendChild(tr);
|
|
}
|
|
tbody.querySelectorAll("button.danger").forEach(b => {
|
|
b.addEventListener("click", () => revoke(b.dataset.id));
|
|
});
|
|
}
|
|
|
|
function escapeHTML(s) {
|
|
return String(s).replace(/[&<>"']/g, c =>
|
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
|
}
|
|
|
|
async function revoke(id) {
|
|
if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return;
|
|
const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" });
|
|
if (!r.ok) {
|
|
alert("Revoke failed: " + r.status + " " + r.statusText);
|
|
return;
|
|
}
|
|
refresh();
|
|
}
|
|
|
|
document.getElementById("create").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const desc = document.getElementById("desc").value.trim();
|
|
const expRaw = document.getElementById("expires").value;
|
|
const body = {};
|
|
if (desc) body.description = desc;
|
|
if (expRaw) body.expires = new Date(expRaw).toISOString();
|
|
const r = await fetch(api, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const out = document.getElementById("created");
|
|
if (!r.ok) {
|
|
out.innerHTML = '<p class="warn">Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '</p>';
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
out.innerHTML = '<p>New token (copy now — it is shown only once):</p><div class="token-secret">' + escapeHTML(data.token) + '</div>';
|
|
document.getElementById("desc").value = "";
|
|
document.getElementById("expires").value = "";
|
|
refresh();
|
|
});
|
|
|
|
refresh();
|
|
`
|
|
return `<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>ZDDC tokens</title>
|
|
<style>` + css + `</style>
|
|
</head><body>
|
|
<h1>API tokens</h1>
|
|
<p class="who">Signed in as <strong>` + html.EscapeString(email) + `</strong></p>
|
|
` + storeNote + `
|
|
<fieldset>
|
|
<legend>Create a new token</legend>
|
|
<form id="create">
|
|
<label><span>Description</span><input type="text" id="desc" maxlength="200" placeholder="e.g. Field laptop"></label>
|
|
<label><span>Expires (optional)</span><input type="datetime-local" id="expires"></label>
|
|
<button type="submit">Create token</button>
|
|
</form>
|
|
<div id="created"></div>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Existing tokens</legend>
|
|
<table id="tokens">
|
|
<thead><tr><th>ID</th><th>Description</th><th>Created</th><th>Expires</th><th></th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</fieldset>
|
|
<script>` + js + `</script>
|
|
</body></html>`
|
|
}
|