Files
sm-user-plugin/middleware/api_token.go
2026-10-04 22:30:20 +02:00

130 lines
3.9 KiB
Go

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)
}