- 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
237 lines
6.5 KiB
Go
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
|
|
}()
|