feat: own shared user API tokens
This commit is contained in:
161
controllers/api_tokens.go
Normal file
161
controllers/api_tokens.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APITokenStore mints one shared personal API token for the authenticated
|
||||
// user. The clear-text token appears only in this response.
|
||||
func APITokenStore(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, user, ok := apiTokenDeps(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(strField(fields, "name"))
|
||||
if name == "" || len(name) > 255 {
|
||||
writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}})
|
||||
return
|
||||
}
|
||||
scopes, err := apiTokenScopes(fields["scopes"])
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"scopes": {err.Error()}})
|
||||
return
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if raw := strings.TrimSpace(strField(fields, "expires_at")); raw != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"expires_at": {"The expires_at field must be a valid RFC3339 date."}})
|
||||
return
|
||||
}
|
||||
expiresAt = &parsed
|
||||
}
|
||||
prefix, err := classes.APITokenPrefix(app)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
if err != nil || db.WithContext(r.Context()).Create(token).Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"token": secret, "meta": apiTokenJSON(token)})
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenIndex lists the caller's live personal tokens without hashes.
|
||||
func APITokenIndex(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, user, ok := apiTokenDeps(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rows []models.APIToken
|
||||
if err := db.WithContext(r.Context()).
|
||||
Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID).
|
||||
Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
data := make([]map[string]any, 0, len(rows))
|
||||
for i := range rows {
|
||||
data = append(data, apiTokenJSON(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)})
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenDestroy revokes one live personal token owned by the caller.
|
||||
func APITokenDestroy(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, user, ok := apiTokenDeps(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"})
|
||||
return
|
||||
}
|
||||
var token models.APIToken
|
||||
err = db.WithContext(r.Context()).Where("user_id = ? AND oauth_client_id IS NULL", user.ID).First(&token, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"})
|
||||
return
|
||||
}
|
||||
if err != nil || classes.RevokeAPIToken(db.WithContext(r.Context()), &token) != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
||||
}
|
||||
}
|
||||
|
||||
func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) {
|
||||
if app == nil {
|
||||
writeOpaque500(w)
|
||||
return nil, nil, false
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
writeOpaque500(w)
|
||||
return nil, nil, false
|
||||
}
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if !ok || user == nil || user.ID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return nil, nil, false
|
||||
}
|
||||
return db, user, true
|
||||
}
|
||||
|
||||
func apiTokenScopes(raw any) ([]string, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("The scopes field must be an array.")
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, rawScope := range values {
|
||||
scope, ok := rawScope.(string)
|
||||
if !ok || !slices.Contains(classes.AllowedAPITokenScopes, scope) {
|
||||
return nil, errors.New("The selected scope is invalid.")
|
||||
}
|
||||
out = append(out, scope)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func apiTokenJSON(token *models.APIToken) map[string]any {
|
||||
name := ""
|
||||
if token.Name != nil {
|
||||
name = *token.Name
|
||||
}
|
||||
return map[string]any{
|
||||
"id": token.ID, "name": name, "scopes": wire.Slice(token.Scopes.Get()),
|
||||
"last_used_at": token.LastUsedAt, "last_used_ip": token.LastUsedIP,
|
||||
"expires_at": token.ExpiresAt, "revoked_at": token.RevokedAt,
|
||||
"created_at": token.CreatedAt,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user