- Add list/create/revoke/scopes handlers, validation, and soft revoke - Regenerate admin OpenAPI and TS types; inventory and conformance cases - Add server-side api_tokens phrase keys (en/pl) Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
9.4 KiB
Go
306 lines
9.4 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
)
|
|
|
|
// AdminAPITokenScope is one scope on a token or in the picker list.
|
|
type AdminAPITokenScope struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Known bool `json:"known"`
|
|
}
|
|
|
|
// AdminAPIToken is one personal API token row in list/revoke responses.
|
|
type AdminAPIToken struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Hint string `json:"hint"`
|
|
Scopes []AdminAPITokenScope `json:"scopes"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsedAt *time.Time `json:"last_used_at"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
RevokedAt *time.Time `json:"revoked_at"`
|
|
Expired bool `json:"expired"`
|
|
}
|
|
|
|
// AdminAPITokenCreated is the create response: every AdminAPIToken field plus
|
|
// the plaintext token (shown exactly once).
|
|
type AdminAPITokenCreated struct {
|
|
AdminAPIToken
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// AdminAPITokenRequest is the POST /account/tokens body.
|
|
type AdminAPITokenRequest struct {
|
|
Name string `json:"name"`
|
|
Expires string `json:"expires"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
var allowedTokenCreateKeys = map[string]bool{"name": true, "expires": true, "scopes": true}
|
|
|
|
func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
showRevoked := false
|
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("revoked"))) {
|
|
case "1", "true":
|
|
showRevoked = true
|
|
}
|
|
q := db.WithContext(r.Context()).Where("backend_user_id = ?", principal.ID)
|
|
if !showRevoked {
|
|
q = q.Where("revoked_at IS NULL")
|
|
}
|
|
var rows []BackendAPIToken
|
|
if err := q.Order("created_at DESC").Order("id DESC").Find(&rows).Error; err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
now := time.Now().UTC()
|
|
out := make([]AdminAPIToken, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, mapAdminAPIToken(r.Context(), tr, s.reg, &rows[i], now))
|
|
}
|
|
WriteData(w, http.StatusOK, out, nil)
|
|
}
|
|
|
|
func (s *service) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
body, err := s.decodeCappedObject(w, r)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
if details := refusedTokenCreateKeys(r.Context(), tr, body); len(details) > 0 {
|
|
writeCRUDError(w, &ValidationError{Details: details})
|
|
return
|
|
}
|
|
name, _ := body["name"].(string)
|
|
expires, _ := body["expires"].(string)
|
|
var scopes []string
|
|
switch v := body["scopes"].(type) {
|
|
case []any:
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
scopes = append(scopes, s)
|
|
}
|
|
}
|
|
case []string:
|
|
scopes = v
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account token create without a database", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
plain, row, details, err := issueAPIToken(db, s.reg, principal, name, expires, scopes, now, false)
|
|
if err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account token create", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
if details != nil {
|
|
writeCRUDError(w, &ValidationError{Details: translateTokenDetails(r.Context(), tr, details)})
|
|
return
|
|
}
|
|
created := AdminAPITokenCreated{
|
|
AdminAPIToken: mapAdminAPIToken(r.Context(), tr, s.reg, row, now),
|
|
Token: plain,
|
|
}
|
|
WriteData(w, http.StatusCreated, created, nil)
|
|
}
|
|
|
|
func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
revokedBy := principal.ID
|
|
row, notFound, err := revokeAPIToken(db, principal.ID, uint(id), &revokedBy)
|
|
if err != nil {
|
|
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke", "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
if notFound {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
|
|
}
|
|
|
|
func (s *service) accountTokenScopes(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
var out []AdminAPITokenScope
|
|
if s.reg != nil && s.reg.tokenScopes != nil {
|
|
for _, scope := range s.reg.tokenScopes.ordered {
|
|
if !scopeOverlaps(s.reg, principal, scope.Name) {
|
|
continue
|
|
}
|
|
out = append(out, mapCompiledScope(r.Context(), tr, scope))
|
|
}
|
|
}
|
|
WriteData(w, http.StatusOK, out, nil)
|
|
}
|
|
|
|
func refusedTokenCreateKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
|
|
details := map[string]any{}
|
|
for key := range body {
|
|
if !allowedTokenCreateKeys[key] {
|
|
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed.")}
|
|
}
|
|
}
|
|
return details
|
|
}
|
|
|
|
func translateTokenDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
for field, raw := range details {
|
|
keys, _ := raw.([]string)
|
|
msgs := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok {
|
|
msgs = append(msgs, backendMessageParams(ctx, tr, "api_tokens.scope_unavailable", "The scope :name is not available to you.", map[string]string{"name": name}))
|
|
continue
|
|
}
|
|
switch key {
|
|
case "api_tokens.name_required":
|
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Enter a name for the token."))
|
|
case "api_tokens.name_too_long":
|
|
msgs = append(msgs, backendMessage(ctx, tr, key, "The name may have at most 100 characters."))
|
|
case "api_tokens.expires_invalid":
|
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose a valid expiry."))
|
|
case "api_tokens.scopes_required":
|
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose at least one scope."))
|
|
default:
|
|
msgs = append(msgs, backendMessage(ctx, tr, key, key))
|
|
}
|
|
}
|
|
out[field] = msgs
|
|
}
|
|
return out
|
|
}
|
|
|
|
func backendMessageParams(ctx context.Context, tr *phrasebook.Translator, key, fallback string, params map[string]string) string {
|
|
full := "backend::lang." + key
|
|
if tr != nil && tr.Has(full) {
|
|
if s := tr.Get(ctx, full, params); s != "" && s != full {
|
|
return s
|
|
}
|
|
}
|
|
msg := fallback
|
|
for k, v := range params {
|
|
msg = strings.ReplaceAll(msg, ":"+k, v)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func mapAdminAPIToken(ctx context.Context, tr *phrasebook.Translator, reg *Registry, row *BackendAPIToken, now time.Time) AdminAPIToken {
|
|
stored := []string{}
|
|
if row.Scopes.Valid {
|
|
stored = row.Scopes.Get()
|
|
}
|
|
scopes := make([]AdminAPITokenScope, 0, len(stored))
|
|
for _, name := range stored {
|
|
scopes = append(scopes, mapStoredScope(ctx, tr, reg, name))
|
|
}
|
|
expired := row.ExpiresAt != nil && !row.ExpiresAt.After(now) && row.RevokedAt == nil
|
|
return AdminAPIToken{
|
|
ID: row.ID,
|
|
Name: row.Name,
|
|
Hint: row.TokenHint,
|
|
Scopes: scopes,
|
|
CreatedAt: row.CreatedAt,
|
|
LastUsedAt: row.LastUsedAt,
|
|
ExpiresAt: row.ExpiresAt,
|
|
RevokedAt: row.RevokedAt,
|
|
Expired: expired,
|
|
}
|
|
}
|
|
|
|
func mapStoredScope(ctx context.Context, tr *phrasebook.Translator, reg *Registry, name string) AdminAPITokenScope {
|
|
if reg != nil && reg.tokenScopes != nil {
|
|
if scope, ok := reg.tokenScopes.byName[name]; ok {
|
|
return mapCompiledScope(ctx, tr, scope)
|
|
}
|
|
}
|
|
return AdminAPITokenScope{Name: name, Label: name, Description: "", ReadOnly: false, Known: false}
|
|
}
|
|
|
|
func mapCompiledScope(ctx context.Context, tr *phrasebook.Translator, scope *compiledTokenScope) AdminAPITokenScope {
|
|
label := scope.Label
|
|
desc := scope.Description
|
|
if strings.Contains(label, "::") {
|
|
label = resolveScopePhrase(ctx, tr, label, label)
|
|
}
|
|
if strings.Contains(desc, "::") {
|
|
desc = resolveScopePhrase(ctx, tr, desc, desc)
|
|
}
|
|
readOnly := true
|
|
for m := range scope.Methods {
|
|
if m != "GET" && m != "HEAD" {
|
|
readOnly = false
|
|
break
|
|
}
|
|
}
|
|
if scope.Full && scope.Name == scopeAll {
|
|
readOnly = false
|
|
}
|
|
return AdminAPITokenScope{
|
|
Name: scope.Name,
|
|
Label: label,
|
|
Description: desc,
|
|
ReadOnly: readOnly,
|
|
Known: true,
|
|
}
|
|
}
|
|
|
|
func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
|
|
if tr != nil && tr.Has(key) {
|
|
if s := tr.Get(ctx, key, nil); s != "" && s != key {
|
|
return s
|
|
}
|
|
}
|
|
return fallback
|
|
} |