Files
summercms/cabana/auth.go
Jakub Zych dfa00f7e3a 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
2026-09-24 17:17:19 +02:00

237 lines
6.5 KiB
Go

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