fix: harden credential and input handling
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/api"
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
)
|
||||
|
||||
// tokenRow is one token as shown on the page.
|
||||
type tokenRow struct {
|
||||
Token *domain.APIToken
|
||||
Status string
|
||||
Expired bool
|
||||
Revoked bool
|
||||
Scopes string
|
||||
}
|
||||
|
||||
// scopeChoice is one selectable scope on the creation form.
|
||||
type scopeChoice struct {
|
||||
Scope domain.TokenScope
|
||||
Label string
|
||||
Description string
|
||||
Available bool
|
||||
}
|
||||
|
||||
// TokensPage lists the caller's API tokens and offers the creation form.
|
||||
func (h *Handlers) TokensPage(w http.ResponseWriter, r *http.Request) {
|
||||
p := api.PrincipalFrom(r.Context())
|
||||
if p == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTokensPage(w, r, p, "", "", http.StatusOK)
|
||||
}
|
||||
|
||||
// renderTokensPage renders the token list and, after creation, the one-time
|
||||
// secret directly in the POST response. A bearer credential must never travel
|
||||
// through a redirect URL, where it would enter browser history and proxy logs.
|
||||
func (h *Handlers) renderTokensPage(w http.ResponseWriter, r *http.Request, p *domain.Principal,
|
||||
newSecret, newName string, status int) {
|
||||
toks, err := h.d.Tokens.List(r.Context(), p)
|
||||
if err != nil {
|
||||
if newSecret == "" {
|
||||
h.renderError(w, r, http.StatusInternalServerError, "Could not list tokens",
|
||||
"The token list could not be read. Please try again.")
|
||||
return
|
||||
}
|
||||
// Creation has already succeeded and the secret is unrecoverable. Show it
|
||||
// even if the follow-up list refresh failed rather than losing it forever.
|
||||
toks = nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := make([]tokenRow, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
row := tokenRow{Token: t, Status: "Active", Scopes: joinScopes(t.Scopes)}
|
||||
switch {
|
||||
case t.RevokedAt != nil:
|
||||
row.Status, row.Revoked = "Revoked", true
|
||||
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
|
||||
row.Status, row.Expired = "Expired", true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
base
|
||||
Tokens []tokenRow
|
||||
Scopes []scopeChoice
|
||||
// NewSecret is set only immediately after creation.
|
||||
NewSecret string
|
||||
NewName string
|
||||
DefaultTTL int
|
||||
MaxTTL int
|
||||
}{
|
||||
base: h.newBase(r, "API tokens", "settings"),
|
||||
Tokens: rows,
|
||||
Scopes: scopeChoicesFor(p.Role),
|
||||
NewSecret: newSecret,
|
||||
NewName: newName,
|
||||
DefaultTTL: int(h.d.Cfg.Auth.OAuth.TokenDefaultTTL.Hours() / 24),
|
||||
MaxTTL: int(h.d.Cfg.Auth.OAuth.TokenMaxTTL.Hours() / 24),
|
||||
}
|
||||
if err != nil {
|
||||
data.Error = "The token was created, but the token list could not be refreshed. Copy the new token now."
|
||||
}
|
||||
h.d.Renderer.Render(w, r, "tokens.html", status, data)
|
||||
}
|
||||
|
||||
// CreateToken issues a token from the interface.
|
||||
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
p := api.PrincipalFrom(r.Context())
|
||||
if p == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
scopes := make([]domain.TokenScope, 0, 3)
|
||||
for _, raw := range r.PostForm["scopes"] {
|
||||
sc := domain.TokenScope(strings.TrimSpace(raw))
|
||||
if sc.Valid() {
|
||||
scopes = append(scopes, sc)
|
||||
}
|
||||
}
|
||||
|
||||
days, _ := strconv.Atoi(r.PostFormValue("ttl_days"))
|
||||
ttl := time.Duration(days) * 24 * time.Hour
|
||||
|
||||
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
|
||||
Name: r.PostFormValue("name"),
|
||||
Scopes: scopes,
|
||||
TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
observability.LoggerFrom(r.Context()).Warn("token creation refused", "error", err.Error())
|
||||
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Audit.Record(r.Context(), p, domain.AuditTokenCreated, "api_token", issued.Token.ID, "success",
|
||||
map[string]string{"name": issued.Token.Name, "prefix": issued.Token.Prefix})
|
||||
|
||||
// Render the one-time secret in this response. Redirect URLs must never carry
|
||||
// bearer credentials: URLs are retained by browsers, proxies and access logs.
|
||||
h.renderTokensPage(w, r, p, issued.Secret, issued.Token.Name, http.StatusCreated)
|
||||
}
|
||||
|
||||
// RevokeToken makes a token unusable from the interface.
|
||||
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
p := api.PrincipalFrom(r.Context())
|
||||
if p == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
|
||||
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
|
||||
http.Redirect(w, r, "/settings/tokens?ok=token_revoked", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// scopeChoicesFor describes the scopes, marking those the role cannot grant.
|
||||
//
|
||||
// An unavailable scope is shown and disabled rather than hidden, so a viewer
|
||||
// can see that ingestion exists and why it is not offered to them.
|
||||
func scopeChoicesFor(role domain.Role) []scopeChoice {
|
||||
return []scopeChoice{
|
||||
{
|
||||
Scope: domain.ScopeQuery, Label: "Query",
|
||||
Description: "Search, generate answers, and read stored items, entities and relationships.",
|
||||
Available: domain.RoleGrants(role, domain.ScopeQuery),
|
||||
},
|
||||
{
|
||||
Scope: domain.ScopeIngest, Label: "Ingest",
|
||||
Description: "Add items, upload files, reprocess and delete.",
|
||||
Available: domain.RoleGrants(role, domain.ScopeIngest),
|
||||
},
|
||||
{
|
||||
Scope: domain.ScopeReview, Label: "Review",
|
||||
Description: "Confirm, reject and retype relationships; confirm entity aliases.",
|
||||
Available: domain.RoleGrants(role, domain.ScopeReview),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func joinScopes(scopes []domain.TokenScope) string {
|
||||
if len(scopes) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, 0, len(scopes))
|
||||
for _, s := range scopes {
|
||||
parts = append(parts, string(s))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user