162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
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,
|
|
}
|
|
}
|