From a5fc8f5490a9b281643346a85aab949daee5d498 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 12 Aug 2026 17:44:31 +0800 Subject: [PATCH] feat: add independent Bell authentication and RBAC (#12) --- Bell/.env.example | 2 +- Bell/README.md | 12 ++ Bell/server/app/audit/store.go | 56 ++++++ Bell/server/app/auth/http.go | 178 ++++++++++++++++++ Bell/server/app/auth/password.go | 41 ++++ Bell/server/app/auth/password_test.go | 27 +++ Bell/server/app/auth/service.go | 94 +++++++++ Bell/server/app/auth/store.go | 164 ++++++++++++++++ Bell/server/app/auth/types.go | 25 +++ Bell/server/app/rbac/permissions.go | 31 +++ Bell/server/cmd/bell/root.go | 35 +++- Bell/server/config/config.go | 7 + Bell/server/go.mod | 6 +- Bell/server/internal/platform/http.go | 2 + .../2026081200_platform.sql} | 1 - Bell/server/migrations/2026081201_auth.sql | 62 ++++++ .../migrations.go => migrations/runner.go} | 14 +- Bell/web/src/api/auth/index.js | 9 + Bell/web/src/bootstrap/App.vue | 4 +- Bell/web/src/bootstrap/request.js | 5 + Bell/web/src/bootstrap/router.js | 24 ++- Bell/web/src/bootstrap/store.js | 3 +- Bell/web/src/bootstrap/theme.css | 2 +- Bell/web/src/layout/AppLayout.vue | 16 +- Bell/web/src/store/modules/identity.js | 13 ++ Bell/web/src/views/login/Login.vue | 21 +++ Bell/web/src/views/system/Audit.vue | 4 + Bell/web/src/views/system/Users.vue | 4 + 28 files changed, 840 insertions(+), 22 deletions(-) create mode 100644 Bell/server/app/audit/store.go create mode 100644 Bell/server/app/auth/http.go create mode 100644 Bell/server/app/auth/password.go create mode 100644 Bell/server/app/auth/password_test.go create mode 100644 Bell/server/app/auth/service.go create mode 100644 Bell/server/app/auth/store.go create mode 100644 Bell/server/app/auth/types.go create mode 100644 Bell/server/app/rbac/permissions.go rename Bell/server/{internal/platform/migrations/0001_platform.sql => migrations/2026081200_platform.sql} (99%) create mode 100644 Bell/server/migrations/2026081201_auth.sql rename Bell/server/{internal/platform/migrations.go => migrations/runner.go} (80%) create mode 100644 Bell/web/src/api/auth/index.js create mode 100644 Bell/web/src/bootstrap/request.js create mode 100644 Bell/web/src/store/modules/identity.js create mode 100644 Bell/web/src/views/login/Login.vue create mode 100644 Bell/web/src/views/system/Audit.vue create mode 100644 Bell/web/src/views/system/Users.vue diff --git a/Bell/.env.example b/Bell/.env.example index b798e5a..e28d9ca 100644 --- a/Bell/.env.example +++ b/Bell/.env.example @@ -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 - diff --git a/Bell/README.md b/Bell/README.md index b9b06fc..072ad58 100644 --- a/Bell/README.md +++ b/Bell/README.md @@ -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 diff --git a/Bell/server/app/audit/store.go b/Bell/server/app/audit/store.go new file mode 100644 index 0000000..dafbd65 --- /dev/null +++ b/Bell/server/app/audit/store.go @@ -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() +} diff --git a/Bell/server/app/auth/http.go b/Bell/server/app/auth/http.go new file mode 100644 index 0000000..17016bb --- /dev/null +++ b/Bell/server/app/auth/http.go @@ -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) +} diff --git a/Bell/server/app/auth/password.go b/Bell/server/app/auth/password.go new file mode 100644 index 0000000..789bc65 --- /dev/null +++ b/Bell/server/app/auth/password.go @@ -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 +} diff --git a/Bell/server/app/auth/password_test.go b/Bell/server/app/auth/password_test.go new file mode 100644 index 0000000..f07e1f6 --- /dev/null +++ b/Bell/server/app/auth/password_test.go @@ -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") + } +} diff --git a/Bell/server/app/auth/service.go b/Bell/server/app/auth/service.go new file mode 100644 index 0000000..42fc942 --- /dev/null +++ b/Bell/server/app/auth/service.go @@ -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{}) +} diff --git a/Bell/server/app/auth/store.go b/Bell/server/app/auth/store.go new file mode 100644 index 0000000..29ed08b --- /dev/null +++ b/Bell/server/app/auth/store.go @@ -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) +} diff --git a/Bell/server/app/auth/types.go b/Bell/server/app/auth/types.go new file mode 100644 index 0000000..0871f4f --- /dev/null +++ b/Bell/server/app/auth/types.go @@ -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{} diff --git a/Bell/server/app/rbac/permissions.go b/Bell/server/app/rbac/permissions.go new file mode 100644 index 0000000..22ca365 --- /dev/null +++ b/Bell/server/app/rbac/permissions.go @@ -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) +} diff --git a/Bell/server/cmd/bell/root.go b/Bell/server/cmd/bell/root.go index 659d747..c70038a 100644 --- a/Bell/server/cmd/bell/root.go +++ b/Bell/server/cmd/bell/root.go @@ -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 "" +} diff --git a/Bell/server/config/config.go b/Bell/server/config/config.go index b2a9982..f8f87de 100644 --- a/Bell/server/config/config.go +++ b/Bell/server/config/config.go @@ -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 } diff --git a/Bell/server/go.mod b/Bell/server/go.mod index f135f6f..a836d5a 100644 --- a/Bell/server/go.mod +++ b/Bell/server/go.mod @@ -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 ) diff --git a/Bell/server/internal/platform/http.go b/Bell/server/internal/platform/http.go index 0bc554c..7fee0b6 100644 --- a/Bell/server/internal/platform/http.go +++ b/Bell/server/internal/platform/http.go @@ -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) } diff --git a/Bell/server/internal/platform/migrations/0001_platform.sql b/Bell/server/migrations/2026081200_platform.sql similarity index 99% rename from Bell/server/internal/platform/migrations/0001_platform.sql rename to Bell/server/migrations/2026081200_platform.sql index 9bfd923..9b06344 100644 --- a/Bell/server/internal/platform/migrations/0001_platform.sql +++ b/Bell/server/migrations/2026081200_platform.sql @@ -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() ); - diff --git a/Bell/server/migrations/2026081201_auth.sql b/Bell/server/migrations/2026081201_auth.sql new file mode 100644 index 0000000..be47d0e --- /dev/null +++ b/Bell/server/migrations/2026081201_auth.sql @@ -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'; diff --git a/Bell/server/internal/platform/migrations.go b/Bell/server/migrations/runner.go similarity index 80% rename from Bell/server/internal/platform/migrations.go rename to Bell/server/migrations/runner.go index 842c376..d97c04f 100644 --- a/Bell/server/internal/platform/migrations.go +++ b/Bell/server/migrations/runner.go @@ -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 } diff --git a/Bell/web/src/api/auth/index.js b/Bell/web/src/api/auth/index.js new file mode 100644 index 0000000..a207421 --- /dev/null +++ b/Bell/web/src/api/auth/index.js @@ -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') diff --git a/Bell/web/src/bootstrap/App.vue b/Bell/web/src/bootstrap/App.vue index 1f43a84..f4983b1 100644 --- a/Bell/web/src/bootstrap/App.vue +++ b/Bell/web/src/bootstrap/App.vue @@ -1,4 +1,6 @@ - + diff --git a/Bell/web/src/bootstrap/request.js b/Bell/web/src/bootstrap/request.js new file mode 100644 index 0000000..97c9032 --- /dev/null +++ b/Bell/web/src/bootstrap/request.js @@ -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 diff --git a/Bell/web/src/bootstrap/router.js b/Bell/web/src/bootstrap/router.js index e6134c2..492ce28 100644 --- a/Bell/web/src/bootstrap/router.js +++ b/Bell/web/src/bootstrap/router.js @@ -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 diff --git a/Bell/web/src/bootstrap/store.js b/Bell/web/src/bootstrap/store.js index 8fad473..e769b93 100644 --- a/Bell/web/src/bootstrap/store.js +++ b/Bell/web/src/bootstrap/store.js @@ -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 } }) diff --git a/Bell/web/src/bootstrap/theme.css b/Bell/web/src/bootstrap/theme.css index 2745010..46c7dba 100644 --- a/Bell/web/src/bootstrap/theme.css +++ b/Bell/web/src/bootstrap/theme.css @@ -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; } diff --git a/Bell/web/src/layout/AppLayout.vue b/Bell/web/src/layout/AppLayout.vue index a486afe..9c57d47 100644 --- a/Bell/web/src/layout/AppLayout.vue +++ b/Bell/web/src/layout/AppLayout.vue @@ -2,12 +2,22 @@
- -
工作台
+ +
{{ route.meta.title }}
+ diff --git a/Bell/web/src/store/modules/identity.js b/Bell/web/src/store/modules/identity.js new file mode 100644 index 0000000..cda6d87 --- /dev/null +++ b/Bell/web/src/store/modules/identity.js @@ -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) } } + } +} diff --git a/Bell/web/src/views/login/Login.vue b/Bell/web/src/views/login/Login.vue new file mode 100644 index 0000000..f8647bc --- /dev/null +++ b/Bell/web/src/views/login/Login.vue @@ -0,0 +1,21 @@ + + + diff --git a/Bell/web/src/views/system/Audit.vue b/Bell/web/src/views/system/Audit.vue new file mode 100644 index 0000000..ce3b549 --- /dev/null +++ b/Bell/web/src/views/system/Audit.vue @@ -0,0 +1,4 @@ + + diff --git a/Bell/web/src/views/system/Users.vue b/Bell/web/src/views/system/Users.vue new file mode 100644 index 0000000..c494903 --- /dev/null +++ b/Bell/web/src/views/system/Users.vue @@ -0,0 +1,4 @@ + +