feat: own shared user API tokens
This commit is contained in:
133
console/api_tokens.go
Normal file
133
console/api_tokens.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APITokenCommands are the application-wide token create/list/revoke
|
||||
// commands. Domain plugins deliberately do not mint their own credentials.
|
||||
func APITokenCommands(app *backpack.App) []bonfire.Command {
|
||||
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("golem15.user: app is nil")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return fmt.Errorf("golem15.user: database is not configured")
|
||||
}
|
||||
return fn(db.WithContext(ctx))
|
||||
}
|
||||
return []bonfire.Command{
|
||||
{
|
||||
Name: "user:token:create", Description: "Create one API token usable by application plugins",
|
||||
Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "name", Description: "What the token is for", Default: "api"},
|
||||
{Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"},
|
||||
{Name: "expires", Description: "RFC3339 expiry; empty means no expiry"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
email, _ := in.Argument("email")
|
||||
name, _ := in.Flag("name")
|
||||
rawScopes, _ := in.Flag("scopes")
|
||||
scopes := []string{}
|
||||
for _, scope := range strings.Split(rawScopes, ",") {
|
||||
if scope = strings.TrimSpace(scope); scope != "" {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if raw, _ := in.Flag("expires"); strings.TrimSpace(raw) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("user:token:create: --expires: %w", err)
|
||||
}
|
||||
expiresAt = &parsed
|
||||
}
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("user:token:create: no user with the e-mail %q", email)
|
||||
}
|
||||
prefix, err := classes.APITokenPrefix(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user:token:create: %w", err)
|
||||
}
|
||||
if err := db.Create(token).Error; err != nil {
|
||||
return fmt.Errorf("user:token:create: %w", err)
|
||||
}
|
||||
out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ","))
|
||||
out.Println(secret)
|
||||
out.Success("user:token:create: store the secret now; it is not shown again")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user:token:list", Description: "List API tokens",
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
var tokens []models.APIToken
|
||||
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
||||
return fmt.Errorf("user:token:list: %w", err)
|
||||
}
|
||||
rows := make([][]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
state := "active"
|
||||
if token.RevokedAt != nil {
|
||||
state = "revoked"
|
||||
} else if !token.IsUsableAt(time.Now()) {
|
||||
state = "expired"
|
||||
}
|
||||
name := ""
|
||||
if token.Name != nil {
|
||||
name = *token.Name
|
||||
}
|
||||
rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state})
|
||||
}
|
||||
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user:token:revoke", Description: "Revoke an API token",
|
||||
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
raw, _ := in.Argument("id")
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return fmt.Errorf("user:token:revoke: %q is not a token id", raw)
|
||||
}
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now())
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("user:token:revoke: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return fmt.Errorf("user:token:revoke: no active token with id %d", id)
|
||||
}
|
||||
out.Success(fmt.Sprintf("user:token:revoke: token %d revoked", id))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user