feat(09-01): implement separate-admin genre list tracer
- Audience-aware mint, verify, refresh, and backend guard keep frontend tokens compatible - Cabana mounts raw admin login, list schema, and record list behind admin.jwt.secret - Framework migration seeds Winter backend users and developer/publisher roles
This commit is contained in:
236
cabana/auth.go
Normal file
236
cabana/auth.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
msgInvalidCredentials = "Invalid credentials"
|
||||
msgUnauthenticated = "Unauthenticated"
|
||||
msgForbidden = "Forbidden"
|
||||
msgNotFound = "Not found"
|
||||
msgServerError = "Server error"
|
||||
)
|
||||
|
||||
// BackendUserRole is the Winter backend_user_roles row.
|
||||
type BackendUserRole struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Code string `gorm:"column:code"`
|
||||
Description string `gorm:"column:description"`
|
||||
Permissions string `gorm:"column:permissions"`
|
||||
IsSystem bool `gorm:"column:is_system"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
||||
|
||||
// BackendUser is the Winter backend_users row. It is not a frontend user.
|
||||
type BackendUser struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
Login string `gorm:"column:login"`
|
||||
Email string `gorm:"column:email"`
|
||||
Password string `gorm:"column:password"`
|
||||
IsActivated bool `gorm:"column:is_activated"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser"`
|
||||
RoleID *uint `gorm:"column:role_id"`
|
||||
LastLogin *time.Time `gorm:"column:last_login"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
Role BackendUserRole
|
||||
}
|
||||
|
||||
func (BackendUser) TableName() string { return "backend_users" }
|
||||
|
||||
// BackendUsers loads activated backend principals. It never reads frontend users.
|
||||
type BackendUsers struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||
if p.DB == nil || id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var user BackendUser
|
||||
err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.IsActivated {
|
||||
return nil, nil
|
||||
}
|
||||
return principalFrom(user), nil
|
||||
}
|
||||
|
||||
func principalFrom(user BackendUser) *bouncer.Principal {
|
||||
return &bouncer.Principal{
|
||||
ID: user.ID,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
PermissionGrants: parseGrants(user.Role.Permissions),
|
||||
}
|
||||
}
|
||||
|
||||
func parseGrants(raw string) map[string]bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "{}" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(decoded))
|
||||
for code, value := range decoded {
|
||||
if truthyGrant(value) {
|
||||
out[code] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truthyGrant(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case float64:
|
||||
return v == 1
|
||||
case string:
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
case json.Number:
|
||||
return v.String() == "1"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type loginBody struct {
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
||||
var body loginBody
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||
return
|
||||
}
|
||||
identifier := strings.TrimSpace(body.Login)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(body.Email)
|
||||
}
|
||||
if identifier == "" || body.Password == "" {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
hash := user.Password
|
||||
if !found {
|
||||
hash = dummyPasswordHash
|
||||
}
|
||||
if !found || !user.IsActivated || !bouncer.CheckPassword(hash, body.Password) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||
return
|
||||
}
|
||||
token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, map[string]string{
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
}, map[string]any{})
|
||||
}
|
||||
|
||||
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
|
||||
email := strings.ToLower(identifier)
|
||||
var user BackendUser
|
||||
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BackendUser{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return BackendUser{}, false, err
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
func (s *service) db() (*gorm.DB, error) {
|
||||
if s == nil || s.app == nil {
|
||||
return nil, errors.New("cabana: database is not configured")
|
||||
}
|
||||
db, ok := s.app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("cabana: database is not configured")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func adminSecret(app *backpack.App) (string, error) {
|
||||
secret := ""
|
||||
if app != nil && app.Config != nil {
|
||||
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
||||
}
|
||||
if secret == "" {
|
||||
return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)")
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func adminTTL(app *backpack.App) time.Duration {
|
||||
minutes := 60
|
||||
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 {
|
||||
minutes = app.Config.Int("admin.jwt.ttl")
|
||||
}
|
||||
return time.Duration(minutes) * time.Minute
|
||||
}
|
||||
|
||||
func adminIssuer(app *backpack.App) string {
|
||||
base := ""
|
||||
if app != nil && app.Config != nil {
|
||||
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
||||
}
|
||||
if base == "" {
|
||||
return "/_admin/api/v1/auth/login"
|
||||
}
|
||||
return base + "/_admin/api/v1/auth/login"
|
||||
}
|
||||
|
||||
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
||||
var dummyPasswordHash = func() string {
|
||||
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hash
|
||||
}()
|
||||
Reference in New Issue
Block a user