feat: own shared user API tokens
This commit is contained in:
129
middleware/api_token.go
Normal file
129
middleware/api_token.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errInvalidAPIToken = errors.New("invalid API token")
|
||||
|
||||
// APITokenGuard authenticates the shared golem15.user API token and stores
|
||||
// both its user principal and *models.APIToken credential on the request.
|
||||
type APITokenGuard struct {
|
||||
App *backpack.App
|
||||
DB func() *gorm.DB
|
||||
Prefix string
|
||||
ClientIP func(*http.Request) string
|
||||
}
|
||||
|
||||
var (
|
||||
_ bouncer.CredentialGuard = (*APITokenGuard)(nil)
|
||||
_ bouncer.UnauthorizedWriter = (*APITokenGuard)(nil)
|
||||
)
|
||||
|
||||
func (g *APITokenGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) {
|
||||
if g == nil || r == nil {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
prefix := g.Prefix
|
||||
if prefix == "" {
|
||||
var err error
|
||||
prefix, err = classes.APITokenPrefix(g.App)
|
||||
if err != nil {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
}
|
||||
secret := BearerToken(r)
|
||||
if secret == "" || !strings.HasPrefix(secret, prefix) {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
var db *gorm.DB
|
||||
if g.DB != nil {
|
||||
db = g.DB()
|
||||
} else if g.App != nil {
|
||||
db, _ = g.App.Lookup[*gorm.DB]()
|
||||
}
|
||||
if db == nil {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
db = db.WithContext(r.Context())
|
||||
var token models.APIToken
|
||||
result := db.Where("token_hash = ?", classes.HashAPIToken(secret)).Limit(1).Find(&token)
|
||||
now := time.Now()
|
||||
if result.Error != nil || result.RowsAffected == 0 || !token.IsUsableAt(now) {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
principal, err := classes.PrincipalByID(r.Context(), db, token.UserID)
|
||||
if err != nil || principal == nil {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
used := map[string]any{"last_used_at": now}
|
||||
if g.ClientIP != nil {
|
||||
if ip := strings.TrimSpace(g.ClientIP(r)); ip != "" {
|
||||
used["last_used_ip"] = ip
|
||||
token.LastUsedIP = &ip
|
||||
}
|
||||
}
|
||||
if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
token.LastUsedAt = &now
|
||||
return principal, &token, nil
|
||||
}
|
||||
|
||||
func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
|
||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
||||
}
|
||||
|
||||
// Scope requires one delegated scope from the shared token credential.
|
||||
func Scope(scope string) pact.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
credential, _ := bouncer.Credential(r.Context())
|
||||
token, ok := credential.(*models.APIToken)
|
||||
if !ok || token == nil || !token.HasScope(scope) {
|
||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Permission requires one current user-group permission. Tokens never carry
|
||||
// a permission snapshot: revoking the user's grant takes effect immediately.
|
||||
func Permission(code string) pact.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
if !cabana.Allows(principal, []string{code}) {
|
||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BearerToken returns the Authorization bearer value or an empty string.
|
||||
func BearerToken(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
Reference in New Issue
Block a user