130 lines
3.9 KiB
Go
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)
|
|
}
|