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