feat: add independent Bell authentication and RBAC (#12)

This commit is contained in:
QiuSW
2026-08-12 17:44:31 +08:00
parent a5d846a437
commit a5fc8f5490
28 changed files with 840 additions and 22 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
BELL_ENV=development
BELL_HTTP_ADDRESS=127.0.0.1:8082
BELL_DATABASE_URL=postgres://bell_app:replace-at-deploy@127.0.0.1:5432/bell?sslmode=disable
BELL_SESSION_SECRET=generate-an-independent-random-value-at-deploy
BELL_SHUTDOWN_SECONDS=10
+12
View File
@@ -16,6 +16,8 @@ Exact upstream sources and retained MIT notices are in `LICENSES/`.
Copy variable names from `.env.example` into the process environment. Replace every placeholder at deployment time. Bell intentionally has no built-in database password, session secret or administrator password.
`BELL_SESSION_SECRET` is mandatory, must be at least 32 characters, and must be generated independently from Sense. In production Bell marks its `bell_session` cookie as Secure, HttpOnly and SameSite=Strict.
The PostgreSQL role used by `BELL_DATABASE_URL` must be dedicated to Bell. Do not reuse a Sense role or database.
## Backend
@@ -27,6 +29,16 @@ go run . migrate
go run . serve
```
Create the first administrator only through a one-time process environment value; do not place the password in shell history, source files or command arguments:
```powershell
$env:BELL_BOOTSTRAP_PASSWORD = Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText
go run . create-admin --username bell-admin --display-name "Bell 管理员"
Remove-Item Env:BELL_BOOTSTRAP_PASSWORD
```
Passwords require at least 12 characters with upper-case, lower-case and numeric characters. Bell ships no user or default password. Administrator, operator and viewer roles are seeded with least-privilege permissions; only an administrator can read user and authentication-audit pages.
Health endpoints:
```powershell
+56
View File
@@ -0,0 +1,56 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type Entry struct {
ID int64 `json:"id"`
OccurredAt string `json:"occurred_at"`
ActorUserID *string `json:"actor_user_id,omitempty"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID *string `json:"target_id,omitempty"`
Outcome string `json:"outcome"`
Details map[string]any `json:"details"`
}
type Store struct{ DB *pgxpool.Pool }
func (s Store) Record(ctx context.Context, actorUserID *string, action, targetType string, targetID *string, outcome string, details map[string]any) error {
data, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("encode audit details: %w", err)
}
_, err = s.DB.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,$2,$3,$4,$5,$6)`, actorUserID, action, targetType, targetID, outcome, data)
if err != nil {
return fmt.Errorf("append audit entry: %w", err)
}
return nil
}
func (s Store) List(ctx context.Context, limit int) ([]Entry, error) {
if limit < 1 || limit > 200 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id,occurred_at::text,actor_user_id::text,action,target_type,target_id,outcome,details FROM bell_audit_log ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]Entry, 0, limit)
for rows.Next() {
var e Entry
var data []byte
if err := rows.Scan(&e.ID, &e.OccurredAt, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Outcome, &data); err != nil {
return nil, err
}
_ = json.Unmarshal(data, &e.Details)
entries = append(entries, e)
}
return entries, rows.Err()
}
+178
View File
@@ -0,0 +1,178 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
)
const CookieName = "bell_session"
type HTTP struct {
Service Service
Store Store
Audit audit.Store
SecureCookie bool
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/auth/login", h.login)
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
}
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
var input LoginInput
if err := decodeJSON(w, r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := h.Service.Login(r.Context(), input)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, ErrInvalidCredentials) {
status = http.StatusUnauthorized
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
result.User.PasswordHash = ""
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
}
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, principal(r.Context()))
}
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
cookie, _ := r.Cookie(CookieName)
token := ""
if cookie != nil {
token = cookie.Value
}
user := principal(r.Context())
if err := h.Service.Logout(r.Context(), token, user); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
return
}
h.setCookie(w, "", time.Unix(0, 0))
w.WriteHeader(http.StatusNoContent)
}
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
users, err := h.Store.ListUsers(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": users})
}
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
var input struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := decodeJSON(w, r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
if input.Username == "" || input.DisplayName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
return
}
hash, err := hashPassword(input.Password)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
return
}
actor := principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
var input struct {
Roles []string `json:"roles"`
}
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
return
}
id := r.PathValue("id")
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
return
}
actor := principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
w.WriteHeader(http.StatusNoContent)
}
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
entries, err := h.Audit.List(r.Context(), limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
}
func (h HTTP) authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
return
}
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
})
}
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := principal(r.Context())
if !user.Permissions.Has(permission) {
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
return
}
next.ServeHTTP(w, r)
}))
}
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
}
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(value)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
func validatePassword(value string) error {
if utf8.RuneCountInString(value) < 12 {
return errors.New("密码至少需要 12 个字符")
}
var lower, upper, digit bool
for _, r := range value {
lower = lower || unicode.IsLower(r)
upper = upper || unicode.IsUpper(r)
digit = digit || unicode.IsDigit(r)
}
if !lower || !upper || !digit {
return errors.New("密码必须同时包含大写字母、小写字母和数字")
}
return nil
}
func hashPassword(value string) (string, error) {
if err := validatePassword(value); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
func passwordMatches(hash, value string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value)) == nil
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import "testing"
func TestPasswordPolicy(t *testing.T) {
for _, value := range []string{"short", "alllowercase123", "ALLUPPERCASE123"} {
if validatePassword(value) == nil {
t.Fatalf("expected %q to fail policy", value)
}
}
if err := validatePassword("Bell-Safe-2026"); err != nil {
t.Fatalf("valid password rejected: %v", err)
}
}
func TestPasswordHashRoundTrip(t *testing.T) {
hash, err := hashPassword("Bell-Safe-2026")
if err != nil {
t.Fatal(err)
}
if !passwordMatches(hash, "Bell-Safe-2026") {
t.Fatal("password did not match")
}
if passwordMatches(hash, "Wrong-Safe-2026") {
t.Fatal("wrong password matched")
}
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"github.com/jackc/pgx/v5"
)
var ErrInvalidCredentials = errors.New("用户名或密码错误")
var ErrUnauthorized = errors.New("会话无效或已过期")
type Service struct {
Store Store
Audit audit.Store
Secret []byte
SessionTTL time.Duration
}
func (s Service) digest(token string) []byte {
mac := hmac.New(sha256.New, s.Secret)
_, _ = mac.Write([]byte(token))
return mac.Sum(nil)
}
func (s Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
username := strings.ToLower(strings.TrimSpace(input.Username))
user, err := s.Store.UserByUsername(ctx, username)
if err != nil || !user.Enabled || !passwordMatches(user.PasswordHash, input.Password) {
_ = s.Audit.Record(ctx, nil, "auth.login", "user", nil, "failure", map[string]any{"reason": "invalid_credentials"})
return LoginResult{}, ErrInvalidCredentials
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return LoginResult{}, err
}
token := base64.RawURLEncoding.EncodeToString(bytes)
expires := time.Now().UTC().Add(s.SessionTTL)
if s.SessionTTL == 0 {
expires = time.Now().UTC().Add(8 * time.Hour)
}
if err := s.Store.CreateSession(ctx, user.ID, s.digest(token), expires); err != nil {
return LoginResult{}, err
}
_ = s.Audit.Record(ctx, &user.ID, "auth.login", "user", &user.ID, "success", map[string]any{})
return LoginResult{Token: token, User: user, ExpiresAt: expires.Format(time.RFC3339)}, nil
}
func (s Service) Authenticate(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrUnauthorized
}
user, err := s.Store.UserBySessionDigest(ctx, s.digest(token))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUnauthorized
}
return User{}, err
}
if !user.Enabled {
return User{}, ErrUnauthorized
}
return user, nil
}
func (s Service) Logout(ctx context.Context, token string, user User) error {
if token != "" {
if err := s.Store.RevokeSession(ctx, s.digest(token)); err != nil {
return err
}
}
return s.Audit.Record(ctx, &user.ID, "auth.logout", "user", &user.ID, "success", map[string]any{})
}
func (s Service) BootstrapAdministrator(ctx context.Context, username, displayName, password string) error {
username = strings.ToLower(strings.TrimSpace(username))
if username == "" {
return errors.New("username is required")
}
hash, err := hashPassword(password)
if err != nil {
return err
}
id, err := s.Store.BootstrapAdministrator(ctx, username, displayName, hash)
if err != nil {
return err
}
return s.Audit.Record(ctx, &id, "rbac.bootstrap_administrator", "user", &id, "success", map[string]any{})
}
+164
View File
@@ -0,0 +1,164 @@
package auth
import (
"context"
"fmt"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct{ DB *pgxpool.Pool }
func (s Store) UserByUsername(ctx context.Context, username string) (User, error) {
var user User
err := s.DB.QueryRow(ctx, `SELECT id::text,username,display_name,password_hash,enabled FROM bell_users WHERE username=$1`, username).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
if err != nil {
return User{}, err
}
if err = s.loadAccess(ctx, &user); err != nil {
return User{}, err
}
return user, nil
}
func (s Store) UserBySessionDigest(ctx context.Context, digest []byte) (User, error) {
var user User
err := s.DB.QueryRow(ctx, `SELECT u.id::text,u.username,u.display_name,u.password_hash,u.enabled FROM bell_sessions s JOIN bell_users u ON u.id=s.user_id WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now()`, digest).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
if err != nil {
return User{}, err
}
if err = s.loadAccess(ctx, &user); err != nil {
return User{}, err
}
return user, nil
}
func (s Store) loadAccess(ctx context.Context, user *User) error {
rows, err := s.DB.Query(ctx, `SELECT DISTINCT r.code,p.permission_code FROM bell_user_roles ur JOIN bell_roles r ON r.id=ur.role_id LEFT JOIN bell_role_permissions p ON p.role_id=r.id WHERE ur.user_id=$1 ORDER BY r.code,p.permission_code`, user.ID)
if err != nil {
return err
}
defer rows.Close()
user.Permissions = rbac.Set{}
roleSeen := map[string]bool{}
for rows.Next() {
var role string
var permission *string
if err := rows.Scan(&role, &permission); err != nil {
return err
}
if !roleSeen[role] {
user.Roles = append(user.Roles, role)
roleSeen[role] = true
}
if permission != nil {
user.Permissions[*permission] = struct{}{}
}
}
return rows.Err()
}
func (s Store) CreateSession(ctx context.Context, userID string, digest []byte, expires time.Time) error {
_, err := s.DB.Exec(ctx, `INSERT INTO bell_sessions(user_id,token_digest,expires_at) VALUES($1,$2,$3)`, userID, digest, expires)
return err
}
func (s Store) RevokeSession(ctx context.Context, digest []byte) error {
_, err := s.DB.Exec(ctx, `UPDATE bell_sessions SET revoked_at=COALESCE(revoked_at,now()) WHERE token_digest=$1`, digest)
return err
}
func (s Store) BootstrapAdministrator(ctx context.Context, username, displayName, passwordHash string) (string, error) {
tx, err := s.DB.Begin(ctx)
if err != nil {
return "", err
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) ON CONFLICT(username) DO NOTHING RETURNING id::text`, username, displayName, passwordHash).Scan(&id)
if err != nil {
if err == pgx.ErrNoRows {
return "", fmt.Errorf("administrator %q already exists", username)
}
return "", err
}
if _, err = tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='administrator'`, id); err != nil {
return "", err
}
if err = tx.Commit(ctx); err != nil {
return "", err
}
return id, nil
}
func (s Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.DB.Query(ctx, `SELECT id::text,username,display_name,enabled FROM bell_users ORDER BY username LIMIT 200`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Enabled); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range users {
if err := s.loadAccess(ctx, &users[i]); err != nil {
return nil, err
}
}
return users, nil
}
func (s Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (string, error) {
tx, err := s.DB.Begin(ctx)
if err != nil {
return "", err
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
if err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) RETURNING id::text`, username, displayName, passwordHash).Scan(&id); err != nil {
return "", err
}
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, id, role)
if err != nil {
return "", err
}
if result.RowsAffected() != 1 {
return "", fmt.Errorf("unknown role %q", role)
}
if err = tx.Commit(ctx); err != nil {
return "", err
}
return id, nil
}
func (s Store) ReplaceRoles(ctx context.Context, userID string, roles []string) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `DELETE FROM bell_user_roles WHERE user_id=$1`, userID); err != nil {
return err
}
for _, role := range roles {
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, userID, role)
if err != nil {
return err
}
if result.RowsAffected() != 1 {
return fmt.Errorf("unknown role %q", role)
}
}
return tx.Commit(ctx)
}
+25
View File
@@ -0,0 +1,25 @@
package auth
import "git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Enabled bool `json:"enabled"`
Roles []string `json:"roles"`
Permissions rbac.Set `json:"permissions"`
PasswordHash string `json:"-"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResult struct {
Token string
User User
ExpiresAt string
}
type principalKey struct{}
+31
View File
@@ -0,0 +1,31 @@
package rbac
import (
"encoding/json"
"sort"
)
const (
DashboardRead = "dashboard:read"
AlertsRead = "alerts:read"
AlertsHandle = "alerts:handle"
EventsRead = "events:read"
RulesRead = "rules:read"
RulesWrite = "rules:write"
UsersRead = "users:read"
UsersWrite = "users:write"
AuditRead = "audit:read"
)
type Set map[string]struct{}
func (s Set) Has(permission string) bool { _, ok := s[permission]; return ok }
func (s Set) MarshalJSON() ([]byte, error) {
values := make([]string, 0, len(s))
for value := range s {
values = append(values, value)
}
sort.Strings(values)
return json.Marshal(values)
}
+32 -3
View File
@@ -8,10 +8,15 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/config"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
const Version = "0.1.0"
@@ -25,8 +30,8 @@ func Run(ctx context.Context, args []string) error {
fmt.Println(Version)
return nil
}
if command != "serve" && command != "migrate" {
return fmt.Errorf("unknown command %q (use serve, migrate, or version)", command)
if command != "serve" && command != "migrate" && command != "create-admin" {
return fmt.Errorf("unknown command %q (use serve, migrate, create-admin, or version)", command)
}
cfg, err := config.Load()
if err != nil {
@@ -37,13 +42,28 @@ func Run(ctx context.Context, args []string) error {
return err
}
defer db.Close()
if err := platform.Migrate(ctx, db); err != nil {
if err := migrations.Apply(ctx, db); err != nil {
return err
}
if command == "migrate" {
return nil
}
auditStore := audit.Store{DB: db}
authStore := auth.Store{DB: db}
authService := auth.Service{Store: authStore, Audit: auditStore, Secret: []byte(cfg.SessionSecret), SessionTTL: 8 * time.Hour}
if command == "create-admin" {
username, displayName := argument(args, "--username"), argument(args, "--display-name")
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
if displayName == "" {
displayName = username
}
if password == "" {
return fmt.Errorf("BELL_BOOTSTRAP_PASSWORD is required for create-admin")
}
return authService.BootstrapAdministrator(ctx, username, displayName, password)
}
app := platform.NewHTTPApp(db)
auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}.Register(app.Router())
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -59,3 +79,12 @@ func Run(ctx context.Context, args []string) error {
}
return nil
}
func argument(args []string, name string) string {
for i := 1; i < len(args)-1; i++ {
if strings.EqualFold(args[i], name) {
return args[i+1]
}
}
return ""
}
+7
View File
@@ -11,6 +11,8 @@ type Config struct {
Environment string
HTTPAddress string
DatabaseURL string
SessionSecret string
CookieSecure bool
ShutdownTimeout time.Duration
}
@@ -19,6 +21,7 @@ func Load() (Config, error) {
Environment: value("BELL_ENV", "development"),
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
SessionSecret: os.Getenv("BELL_SESSION_SECRET"),
ShutdownTimeout: 10 * time.Second,
}
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
@@ -31,6 +34,10 @@ func Load() (Config, error) {
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
}
if len(cfg.SessionSecret) < 32 {
return Config{}, fmt.Errorf("BELL_SESSION_SECRET must contain at least 32 characters")
}
cfg.CookieSecure = cfg.Environment == "production"
return cfg, nil
}
+4 -2
View File
@@ -2,13 +2,15 @@ module git.ilapage.cn/ila/yovision/Bell/server
go 1.26.5
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.37.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+2
View File
@@ -28,6 +28,8 @@ func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
return app
}
func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
func (a *HTTPApp) Handler() http.Handler {
return securityHeaders(a.Mux)
}
@@ -2,4 +2,3 @@ CREATE TABLE IF NOT EXISTS bell_runtime_probe (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
started_at timestamptz NOT NULL DEFAULT now()
);
@@ -0,0 +1,62 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE bell_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE CHECK (code IN ('administrator','operator','viewer')),
name text NOT NULL
);
CREATE TABLE bell_permissions (code text PRIMARY KEY, description text NOT NULL);
CREATE TABLE bell_role_permissions (
role_id uuid NOT NULL REFERENCES bell_roles(id),
permission_code text NOT NULL REFERENCES bell_permissions(code),
PRIMARY KEY (role_id, permission_code)
);
CREATE TABLE bell_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE CHECK (username = lower(username)),
display_name text NOT NULL,
password_hash text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE bell_user_roles (
user_id uuid NOT NULL REFERENCES bell_users(id),
role_id uuid NOT NULL REFERENCES bell_roles(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE bell_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES bell_users(id),
token_digest bytea NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
CHECK (expires_at > created_at)
);
CREATE INDEX bell_sessions_user_active_idx ON bell_sessions(user_id, expires_at) WHERE revoked_at IS NULL;
CREATE TABLE bell_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id uuid REFERENCES bell_users(id),
action text NOT NULL,
target_type text NOT NULL,
target_id text,
outcome text NOT NULL CHECK (outcome IN ('success','failure','denied')),
details jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE OR REPLACE FUNCTION bell_reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'bell audit facts are append-only'; END $$;
CREATE TRIGGER bell_audit_no_update BEFORE UPDATE OR DELETE ON bell_audit_log FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
INSERT INTO bell_roles(code,name) VALUES ('administrator','管理员'),('operator','处置员'),('viewer','只读用户');
INSERT INTO bell_permissions(code,description) VALUES
('dashboard:read','查看工作台'),('alerts:read','查看预警'),('alerts:handle','处置预警'),
('events:read','查看事件'),('rules:read','查看规则'),('rules:write','管理规则'),
('users:read','查看用户'),('users:write','管理用户与角色'),('audit:read','查看审计');
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r CROSS JOIN bell_permissions p WHERE r.code='administrator';
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','alerts:handle','events:read','rules:read') WHERE r.code='operator';
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','events:read','rules:read') WHERE r.code='viewer';
@@ -1,4 +1,4 @@
package platform
package migrations
import (
"context"
@@ -10,20 +10,20 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
//go:embed *.sql
var files embed.FS
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := fs.ReadDir(migrationFiles, "migrations")
entries, err := fs.ReadDir(files, ".")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() {
if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
continue
}
var applied bool
@@ -33,7 +33,7 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
if applied {
continue
}
sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name())
sqlBytes, err := files.ReadFile(entry.Name())
if err != nil {
return err
}
+9
View File
@@ -0,0 +1,9 @@
import request from '../../bootstrap/request'
export const login = data => request.post('/api/v1/auth/login', data)
export const logout = () => request.post('/api/v1/auth/logout')
export const currentUser = () => request.get('/api/v1/auth/me')
export const listUsers = () => request.get('/api/v1/users')
export const createUser = data => request.post('/api/v1/users', data)
export const replaceRoles = (id, roles) => request.put(`/api/v1/users/${id}/roles`, { roles })
export const listAudit = () => request.get('/api/v1/audit?limit=100')
+3 -1
View File
@@ -1,4 +1,6 @@
<template><AppLayout><router-view /></AppLayout></template>
<template><router-view v-if="route.meta.public" /><AppLayout v-else><router-view /></AppLayout></template>
<script setup>
import { useRoute } from 'vue-router'
import AppLayout from '../layout/AppLayout.vue'
const route = useRoute()
</script>
+5
View File
@@ -0,0 +1,5 @@
import axios from 'axios'
const request = axios.create({ timeout: 10000, withCredentials: true, headers: { 'Content-Type': 'application/json' } })
request.interceptors.response.use(response => response.data, error => Promise.reject(error.response?.data || { error: '无法连接 Bell 服务' }))
export default request
+22 -2
View File
@@ -1,7 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from './Dashboard.vue'
import Login from '../views/login/Login.vue'
import Users from '../views/system/Users.vue'
import Audit from '../views/system/Audit.vue'
import store from './store'
export default createRouter({
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台' } }]
routes: [
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
]
})
router.beforeEach(async to => {
if (!store.state.identity.checked) await store.dispatch('identity/restore')
const user = store.state.identity.user
if (to.meta.public) return user && to.name === 'login' ? { name: 'dashboard' } : true
if (!user) return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.permission && !user.permissions?.includes(to.meta.permission)) return { name: 'dashboard' }
return true
})
export default router
+2 -1
View File
@@ -1,3 +1,4 @@
import { createStore } from 'vuex'
import identity from '../store/modules/identity'
export default createStore({ state: () => ({ product: 'Bell' }) })
export default createStore({ state: () => ({ product: 'Bell' }), modules: { identity } })
+1 -1
View File
@@ -11,7 +11,7 @@ body { font-family:"Segoe UI Variable","Microsoft YaHei",sans-serif; color:var(-
.nav-item.router-link-active { color:#fff; background:#1f2d3d; }
.workspace { min-width:0; }
.navbar { height:50px; display:flex; align-items:center; justify-content:space-between; padding:0 20px; background:var(--bell-surface); border-bottom:1px solid var(--bell-stroke); }
.navbar__identity { color:var(--bell-muted); }
.navbar__identity { color:var(--bell-muted); display:flex; align-items:center; gap:12px; }
.tags-view { height:34px; display:flex; align-items:end; padding:0 16px; background:#fff; border-bottom:1px solid var(--bell-stroke); }
.tag-current { padding:6px 12px; border:1px solid var(--bell-stroke); border-bottom:2px solid var(--bell-accent); }
.app-main { padding:20px; }
+13 -3
View File
@@ -2,12 +2,22 @@
<div class="shell">
<aside class="sidebar" aria-label="主导航">
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
<nav><router-link to="/" class="nav-item">工作台</router-link></nav>
<nav>
<router-link to="/" class="nav-item">工作台</router-link>
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
</nav>
</aside>
<section class="workspace">
<header class="navbar"><span>学校安全预警</span><span class="navbar__identity">未登录</span></header>
<div class="tags-view"><span class="tag-current">工作台</span></div>
<header class="navbar"><span>学校安全预警</span><div class="navbar__identity"><span>{{ user?.display_name }}</span><el-button link @click="signOut">退出</el-button></div></header>
<div class="tags-view"><span class="tag-current">{{ route.meta.title }}</span></div>
<div class="app-main"><slot /></div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute,useRouter } from 'vue-router'
import { useStore } from 'vuex'
const store=useStore();const route=useRoute();const router=useRouter();const user=computed(()=>store.state.identity.user);const has=permission=>store.getters['identity/has'](permission);async function signOut(){await store.dispatch('identity/logout');await router.replace('/login')}
</script>
+13
View File
@@ -0,0 +1,13 @@
import { currentUser, login, logout } from '../../api/auth'
export default {
namespaced: true,
state: () => ({ user: null, checked: false }),
getters: { has: state => permission => Boolean(state.user?.permissions?.includes(permission)) },
mutations: { setUser (state, user) { state.user = user; state.checked = true } },
actions: {
async restore ({ commit }) { try { commit('setUser', await currentUser()) } catch (_) { commit('setUser', null) } },
async login ({ commit }, form) { const result = await login(form); commit('setUser', result.user); return result.user },
async logout ({ commit }) { try { await logout() } finally { commit('setUser', null) } }
}
}
+21
View File
@@ -0,0 +1,21 @@
<template>
<main class="login-page">
<el-card class="login-card">
<template #header><div><p class="eyebrow">BELL</p><h1>登录预警中心</h1><p>使用 Bell 独立账户。Sense 账户不能登录此系统。</p></div></template>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username" autocomplete="username" autofocus /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="full-button">登录 Bell</el-button>
</el-form>
</el-card>
</main>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
const store=useStore();const route=useRoute();const router=useRouter();const formRef=ref();const loading=ref(false);const error=ref('');const form=reactive({username:'',password:''});const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],password:[{required:true,message:'请输入密码',trigger:'blur'}]}
async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';await store.dispatch('identity/login',form);await router.replace(String(route.query.redirect||'/'))}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
</script>
<style scoped>.login-page{min-height:100vh;display:grid;place-items:center;padding:20px;background:#f3f4f7}.login-card{width:min(420px,100%)}h1{margin:0 0 8px}.full-button{width:100%;min-height:44px}</style>
+4
View File
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>认证审计</h1><p>登录、退出和权限拒绝事实仅追加保存。</p></div></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="occurred_at" label="时间" min-width="180"/><el-table-column prop="action" label="动作" min-width="150"/><el-table-column prop="outcome" label="结果"><template #default="scope"><el-tag :type="scope.row.outcome==='success'?'success':'danger'">{{scope.row.outcome}}</el-tag></template></el-table-column><el-table-column prop="target_type" label="对象"/></el-table></main></template>
<script setup>
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { listAudit } from '../../api/auth';const items=ref([]);const loading=ref(false);onMounted(async()=>{loading.value=true;try{items.value=(await listAudit()).items}catch(e){ElMessage.error(e.error||'读取审计失败')}finally{loading.value=false}})
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>用户与角色</h1><p>Bell 账户与 Sense 完全独立;按最小权限分配角色。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">创建用户</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="username" label="用户名"/><el-table-column prop="display_name" label="显示名称"/><el-table-column label="角色"><template #default="scope"><el-select v-if="canWrite" :model-value="scope.row.roles[0]" aria-label="角色" @change="role=>changeRole(scope.row,role)"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select><span v-else>{{roleName(scope.row.roles[0])}}</span></template></el-table-column><el-table-column label="状态"><template #default="scope"><el-tag :type="scope.row.enabled?'success':'info'">{{scope.row.enabled?'启用':'停用'}}</el-tag></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚未创建用户"/><el-dialog v-model="dialog" title="创建 Bell 用户" width="min(480px, calc(100vw - 32px))" @closed="reset"><el-alert title="不会生成默认密码;密码必须由管理员安全传递。" type="info" :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username"/></el-form-item><el-form-item label="显示名称" prop="display_name"><el-input v-model.trim="form.display_name"/></el-form-item><el-form-item label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password"/></el-form-item><el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width:100%"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建用户</el-button></template></el-dialog></main></template>
<script setup>
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createUser,listUsers,replaceRoles } from '../../api/auth';const store=useStore();const items=ref([]);const loading=ref(false);const dialog=ref(false);const saving=ref(false);const formRef=ref();const form=reactive({username:'',display_name:'',password:'',role:'operator'});const canWrite=computed(()=>store.getters['identity/has']('users:write'));const roleOptions=[{value:'administrator',label:'管理员'},{value:'operator',label:'处置员'},{value:'viewer',label:'只读用户'}];const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],display_name:[{required:true,message:'请输入显示名称',trigger:'blur'}],password:[{required:true,message:'请输入初始密码',trigger:'blur'},{min:12,message:'至少 12 个字符',trigger:'blur'}],role:[{required:true,message:'请选择角色',trigger:'change'}]};const roleName=value=>roleOptions.find(x=>x.value===value)?.label||value;async function load(){loading.value=true;try{items.value=(await listUsers()).items}catch(e){ElMessage.error(e.error||'读取用户失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;await createUser(form);ElMessage.success('用户已创建');dialog.value=false;await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function changeRole(user,role){try{await replaceRoles(user.id,[role]);ElMessage.success('角色已更新');await load()}catch(e){ElMessage.error(e.error||'角色更新失败')}}function reset(){form.username='';form.display_name='';form.password='';form.role='operator';formRef.value?.clearValidate()}onMounted(load)
</script>