Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
)
|
||||
|
||||
// tokenView is a token as returned to a caller.
|
||||
//
|
||||
// There is no field for the secret because there is no secret to return: only
|
||||
// its hash was stored. The prefix is enough to tell one token from another.
|
||||
type tokenView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Scopes []domain.TokenScope `json:"scopes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func viewToken(t *domain.APIToken, now time.Time) tokenView {
|
||||
v := tokenView{
|
||||
ID: t.ID, Name: t.Name, Prefix: t.Prefix, Scopes: t.Scopes,
|
||||
CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt,
|
||||
LastUsedAt: t.LastUsedAt, RevokedAt: t.RevokedAt,
|
||||
Status: "active",
|
||||
}
|
||||
switch {
|
||||
case t.RevokedAt != nil:
|
||||
v.Status = "revoked"
|
||||
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
|
||||
v.Status = "expired"
|
||||
}
|
||||
if v.Scopes == nil {
|
||||
v.Scopes = []domain.TokenScope{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ListTokens returns the caller's own API tokens.
|
||||
func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
toks, err := h.d.Tokens.List(r.Context(), p)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
out := make([]tokenView, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
out = append(out, viewToken(t, now))
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{"tokens": out, "total": len(out)})
|
||||
}
|
||||
|
||||
// createTokenRequest is the JSON body for issuing a token.
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
// TTLHours is optional; the configured default applies when it is absent.
|
||||
TTLHours int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
// createTokenResponse is the only place a token secret ever appears.
|
||||
type createTokenResponse struct {
|
||||
Token tokenView `json:"token"`
|
||||
// Secret is shown once. It cannot be retrieved again.
|
||||
Secret string `json:"secret"`
|
||||
Warning string `json:"warning"`
|
||||
}
|
||||
|
||||
// CreateToken issues an API token for the caller.
|
||||
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req createTokenRequest
|
||||
if err := DecodeJSON(w, r, 8<<10, &req); err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
scopes, err := parseScopes(req.Scopes)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ttl := time.Duration(req.TTLHours) * time.Hour
|
||||
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
|
||||
Name: req.Name, Scopes: scopes, TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
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})
|
||||
|
||||
WriteJSON(w, http.StatusCreated, createTokenResponse{
|
||||
Token: viewToken(issued.Token, time.Now().UTC()),
|
||||
Secret: issued.Secret,
|
||||
Warning: "This is the only time the token is shown. Store it now; " +
|
||||
"Relata keeps only a hash and cannot show it again.",
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeToken makes one of the caller's tokens unusable.
|
||||
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
|
||||
WriteJSON(w, http.StatusOK, map[string]any{"revoked": true, "id": id})
|
||||
}
|
||||
|
||||
// parseScopes validates requested scopes against the closed set.
|
||||
//
|
||||
// An unrecognised scope is refused rather than ignored: silently dropping one
|
||||
// would issue a token weaker than the caller believes they hold.
|
||||
func parseScopes(raw []string) ([]domain.TokenScope, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, &APIError{
|
||||
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
||||
Message: "Choose at least one scope for the token.",
|
||||
Detail: map[string]string{"available": "ingest, query, review"},
|
||||
}
|
||||
}
|
||||
out := make([]domain.TokenScope, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
sc := domain.TokenScope(s)
|
||||
if !sc.Valid() {
|
||||
return nil, &APIError{
|
||||
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
||||
Message: "That is not a recognised scope.",
|
||||
Detail: map[string]string{"scope": s, "available": "ingest, query, review"},
|
||||
}
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user