feat: add Sense identity RBAC and audit (#22)

This commit is contained in:
QiuSW
2026-08-12 17:06:26 +08:00
parent 2c0e37eff7
commit b9416ebc08
24 changed files with 1438 additions and 11 deletions
+222
View File
@@ -0,0 +1,222 @@
package identity
import (
"context"
"crypto/rand"
"errors"
"net/http"
"strings"
"sync/atomic"
"time"
"yovision.local/sense/internal/platform"
)
const sessionCookieName = "sense_session"
type principalContextKey struct{}
type Module struct {
service *Service
cfg Config
}
var activeModule atomic.Pointer[Module]
func NewModule(service *Service, cfg Config) *Module { return &Module{service: service, cfg: cfg} }
func (m *Module) Register(app *platform.App) {
activeModule.Store(m)
app.HandleFunc("POST /api/v1/identity/bootstrap", m.bootstrap)
app.HandleFunc("POST /api/v1/identity/login", m.login)
app.Handle("POST /api/v1/identity/logout", m.Authenticated(http.HandlerFunc(m.logout)))
app.Handle("GET /api/v1/identity/me", m.Authenticated(http.HandlerFunc(m.me)))
app.Handle("GET /api/v1/identity/users", m.Require(PermissionUsersRead, http.HandlerFunc(m.users)))
app.Handle("POST /api/v1/identity/users", m.Require(PermissionUsersWrite, http.HandlerFunc(m.users)))
app.Handle("PATCH /api/v1/identity/users/{id}", m.Require(PermissionUsersWrite, http.HandlerFunc(m.updateUser)))
app.Handle("GET /api/v1/identity/audit", m.Require(PermissionAuditRead, http.HandlerFunc(m.audit)))
}
func Require(permission string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
module := activeModule.Load()
if module == nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"})
return
}
module.Require(permission, next).ServeHTTP(w, r)
})
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(Principal)
return principal, ok
}
func (m *Module) Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Product") != "sense" {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
principal, err := m.service.Authenticate(r.Context(), cookie.Value)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal)))
})
}
func (m *Module) Require(permission string, next http.Handler) http.Handler {
return m.Authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
if !HasPermission(principal.Role, permission) {
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "forbidden", Message: "没有执行此操作的权限"})
return
}
next.ServeHTTP(w, r)
}))
}
func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) {
var request struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
user, err := m.service.Bootstrap(r.Context(), r.Header.Get("X-Sense-Bootstrap-Token"), request.Username, request.DisplayName, request.Password)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "bootstrap_denied", Message: "安全初始化不可用或输入不符合要求"})
return
}
platform.WriteJSON(w, http.StatusCreated, user)
}
func (m *Module) login(w http.ResponseWriter, r *http.Request) {
var request struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, token, err := m.service.Login(r.Context(), request.Username, request.Password)
if err != nil {
// A small constant delay and one generic message reduce account discovery.
time.Sleep(80*time.Millisecond + randomJitter())
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "用户名或密码不正确"})
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: token, Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: int(m.cfg.SessionTTL.Seconds())})
platform.WriteJSON(w, http.StatusOK, principal)
}
func randomJitter() time.Duration {
var value [1]byte
_, _ = rand.Read(value[:])
return time.Duration(value[0]%40) * time.Millisecond
}
func (m *Module) logout(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
if err := m.service.Logout(r.Context(), principal); err != nil {
platform.WriteError(w, err)
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
w.WriteHeader(http.StatusNoContent)
}
func (m *Module) me(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
platform.WriteJSON(w, http.StatusOK, principal)
}
func (m *Module) users(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
users, err := m.service.ListUsers(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": users, "total": len(users)})
return
}
var request struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, _ := PrincipalFromContext(r.Context())
user, err := m.service.CreateUser(r.Context(), principal, request.Username, request.DisplayName, request.Role, request.Password)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_created", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusCreated, user)
}
func (m *Module) updateUser(w http.ResponseWriter, r *http.Request) {
var request struct {
DisplayName string `json:"display_name"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, _ := PrincipalFromContext(r.Context())
user, err := m.service.UpdateUser(r.Context(), principal, r.PathValue("id"), request.DisplayName, request.Role, request.Enabled)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_updated", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusOK, user)
}
func (m *Module) audit(w http.ResponseWriter, r *http.Request) {
entries, err := m.service.ListAudit(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
}
func SigningKeyOrRandom(raw string, allowRandom bool) ([]byte, error) {
if len(raw) >= 32 {
return []byte(raw), nil
}
if !allowRandom {
return nil, errors.New("SENSE_IDENTITY_SIGNING_KEY must contain at least 32 bytes")
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
return key, nil
}
func SecureCookieFromEnvironment(value string, memoryMode bool) bool {
if memoryMode {
return strings.EqualFold(value, "true")
}
return !strings.EqualFold(value, "false")
}
@@ -0,0 +1,59 @@
package identity
import (
"bytes"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"yovision.local/sense/internal/platform"
)
func TestHTTPLoginCookieAndProductBoundary(t *testing.T) {
store := NewMemoryStore()
cfg := Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "bootstrap-test", SessionTTL: time.Hour}
service, err := NewService(store, cfg)
if err != nil {
t.Fatal(err)
}
app := platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
NewModule(service, cfg).Register(app)
bootstrap := httptest.NewRequest(http.MethodPost, "/api/v1/identity/bootstrap", bytes.NewBufferString(`{"username":"admin","display_name":"管理员","password":"StrongPass2026"}`))
bootstrap.Header.Set("X-Sense-Bootstrap-Token", "bootstrap-test")
bootstrapResult := httptest.NewRecorder()
app.Handler().ServeHTTP(bootstrapResult, bootstrap)
if bootstrapResult.Code != http.StatusCreated {
t.Fatalf("bootstrap status = %d, body = %s", bootstrapResult.Code, bootstrapResult.Body.String())
}
login := httptest.NewRequest(http.MethodPost, "/api/v1/identity/login", bytes.NewBufferString(`{"username":"admin","password":"StrongPass2026"}`))
loginResult := httptest.NewRecorder()
app.Handler().ServeHTTP(loginResult, login)
if loginResult.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", loginResult.Code, loginResult.Body.String())
}
cookies := loginResult.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("unexpected session cookies: %#v", cookies)
}
me := httptest.NewRequest(http.MethodGet, "/api/v1/identity/me", nil)
me.AddCookie(cookies[0])
me.Header.Set("X-Product", "bell")
foreignResult := httptest.NewRecorder()
app.Handler().ServeHTTP(foreignResult, me)
if foreignResult.Code != http.StatusUnauthorized {
t.Fatalf("Bell product header status = %d", foreignResult.Code)
}
me.Header.Set("X-Product", "sense")
senseResult := httptest.NewRecorder()
app.Handler().ServeHTTP(senseResult, me)
if senseResult.Code != http.StatusOK {
t.Fatalf("Sense product header status = %d", senseResult.Code)
}
}
@@ -0,0 +1,30 @@
package identity
const MigrationSQL = `
CREATE TABLE IF NOT EXISTS sense_identity_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_audit (
id TEXT PRIMARY KEY,
actor_id TEXT NULL REFERENCES sense_identity_users(id),
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
`
@@ -0,0 +1,37 @@
package identity
import (
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
func ValidatePassword(username, password string) error {
if len([]rune(password)) < 12 {
return fmt.Errorf("密码至少需要 12 个字符")
}
var upper, lower, digit bool
for _, value := range password {
upper = upper || unicode.IsUpper(value)
lower = lower || unicode.IsLower(value)
digit = digit || unicode.IsDigit(value)
}
if !upper || !lower || !digit {
return fmt.Errorf("密码必须同时包含大写字母、小写字母和数字")
}
if username != "" && strings.Contains(strings.ToLower(password), strings.ToLower(username)) {
return fmt.Errorf("密码不能包含用户名")
}
return nil
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
@@ -0,0 +1,42 @@
package identity
var rolePermissions = map[string][]string{
RoleAdministrator: {
PermissionUsersRead, PermissionUsersWrite, PermissionAuditRead,
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionMediaWrite,
PermissionAreaRead, PermissionAreaWrite,
},
RoleOperator: {
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionMediaWrite,
PermissionAreaRead, PermissionAreaWrite,
},
RoleSiteAdmin: {
PermissionUsersRead, PermissionAuditRead,
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionAreaRead, PermissionAreaWrite,
},
RoleViewer: {
PermissionDeviceRead, PermissionMediaRead, PermissionAreaRead,
},
}
func ValidRole(role string) bool {
_, ok := rolePermissions[role]
return ok
}
func PermissionsForRole(role string) []string {
permissions := rolePermissions[role]
return append([]string(nil), permissions...)
}
func HasPermission(role, permission string) bool {
for _, candidate := range rolePermissions[role] {
if candidate == permission {
return true
}
}
return false
}
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type PostgresStore struct{ database *sql.DB }
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
func (s *PostgresStore) BootstrapUser(ctx context.Context, user User) error {
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM sense_identity_users`).Scan(&count); err != nil {
return err
}
if count != 0 {
return ErrBootstrapDone
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
return tx.Commit()
}
func (s *PostgresStore) CreateUser(ctx context.Context, user User) error {
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt)
return err
}
func (s *PostgresStore) UpdateUser(ctx context.Context, user User) error {
result, err := s.database.ExecContext(ctx, `UPDATE sense_identity_users SET display_name=$2, role=$3, enabled=$4, password_hash=$5, updated_at=$6 WHERE id=$1`, user.ID, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.UpdatedAt)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) FindUserByUsername(ctx context.Context, username string) (User, error) {
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE lower(username)=lower($1)`, username))
}
func (s *PostgresStore) FindUserByID(ctx context.Context, id string) (User, error) {
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE id=$1`, id))
}
type rowScanner interface{ Scan(...any) error }
func scanUser(row rowScanner) (User, error) {
var user User
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &user.Enabled, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return user, err
}
func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.database.QueryContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s *PostgresStore) CreateSession(ctx context.Context, session Session) error {
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_sessions(id,user_id,expires_at) VALUES($1,$2,$3)`, session.ID, session.UserID, session.ExpiresAt)
return err
}
func (s *PostgresStore) SessionActive(ctx context.Context, id string, now time.Time) (bool, error) {
var active bool
err := s.database.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_identity_sessions WHERE id=$1 AND revoked_at IS NULL AND expires_at>$2)`, id, now).Scan(&active)
return active, err
}
func (s *PostgresStore) RevokeSession(ctx context.Context, id string, now time.Time) error {
_, err := s.database.ExecContext(ctx, `UPDATE sense_identity_sessions SET revoked_at=$2 WHERE id=$1`, id, now)
return err
}
func (s *PostgresStore) AppendAudit(ctx context.Context, entry AuditEntry) error {
metadata, err := json.Marshal(entry.Metadata)
if err != nil {
return err
}
_, err = s.database.ExecContext(ctx, `INSERT INTO sense_identity_audit(id,actor_id,action,target,outcome,metadata,created_at) VALUES($1,NULLIF($2,''),$3,$4,$5,$6,$7)`, entry.ID, entry.ActorID, entry.Action, entry.Target, entry.Outcome, metadata, entry.CreatedAt)
return err
}
func (s *PostgresStore) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.database.QueryContext(ctx, `SELECT id,COALESCE(actor_id,''),action,target,outcome,metadata,created_at FROM sense_identity_audit ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var entry AuditEntry
var metadata []byte
if err := rows.Scan(&entry.ID, &entry.ActorID, &entry.Action, &entry.Target, &entry.Outcome, &metadata, &entry.CreatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal(metadata, &entry.Metadata)
entries = append(entries, entry)
}
return entries, rows.Err()
}
+159
View File
@@ -0,0 +1,159 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
)
type Config struct {
SigningKey []byte
BootstrapToken string
SecureCookie bool
SessionTTL time.Duration
}
type Service struct {
store Store
cfg Config
now func() time.Time
}
func NewService(store Store, cfg Config) (*Service, error) {
if len(cfg.SigningKey) < 32 {
return nil, fmt.Errorf("Sense identity signing key must contain at least 32 bytes")
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 8 * time.Hour
}
return &Service{store: store, cfg: cfg, now: time.Now}, nil
}
func (s *Service) Bootstrap(ctx context.Context, token, username, displayName, password string) (User, error) {
if s.cfg.BootstrapToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.BootstrapToken)) != 1 {
return User{}, errors.New("bootstrap denied")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdministrator, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.BootstrapUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, user.ID, "identity.bootstrap", user.ID, "success", nil)
return user, nil
}
func (s *Service) Login(ctx context.Context, username, password string) (Principal, string, error) {
user, err := s.store.FindUserByUsername(ctx, strings.TrimSpace(username))
if err != nil || !user.Enabled || !VerifyPassword(user.PasswordHash, password) {
s.audit(ctx, "", "identity.login", "", "failure", map[string]any{"reason": "invalid_credentials"})
return Principal{}, "", errors.New("invalid credentials")
}
now := s.now().UTC()
session := Session{ID: newID("ses"), UserID: user.ID, ExpiresAt: now.Add(s.cfg.SessionTTL)}
if err := s.store.CreateSession(ctx, session); err != nil {
return Principal{}, "", err
}
raw, err := issueToken(s.cfg.SigningKey, user, session, now)
if err != nil {
return Principal{}, "", err
}
s.audit(ctx, user.ID, "identity.login", user.ID, "success", nil)
return principalFor(user, session.ID), raw, nil
}
func (s *Service) Authenticate(ctx context.Context, raw string) (Principal, error) {
claims, err := parseToken(s.cfg.SigningKey, raw, s.now())
if err != nil {
return Principal{}, err
}
active, err := s.store.SessionActive(ctx, claims.ID, s.now())
if err != nil || !active {
return Principal{}, errors.New("inactive Sense session")
}
user, err := s.store.FindUserByID(ctx, claims.Subject)
if err != nil || !user.Enabled || user.Role != claims.Role {
return Principal{}, errors.New("invalid Sense principal")
}
return principalFor(user, claims.ID), nil
}
func (s *Service) Logout(ctx context.Context, principal Principal) error {
if err := s.store.RevokeSession(ctx, principal.SessionID, s.now().UTC()); err != nil && !errors.Is(err, ErrNotFound) {
return err
}
s.audit(ctx, principal.UserID, "identity.logout", principal.UserID, "success", nil)
return nil
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) }
func (s *Service) ListAudit(ctx context.Context) ([]AuditEntry, error) {
return s.store.ListAudit(ctx, 100)
}
func (s *Service) CreateUser(ctx context.Context, actor Principal, username, displayName, role, password string) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: role, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.CreateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.create", user.ID, "success", map[string]any{"role": role})
return user, nil
}
func (s *Service) UpdateUser(ctx context.Context, actor Principal, id, displayName, role string, enabled bool) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
user, err := s.store.FindUserByID(ctx, id)
if err != nil {
return User{}, err
}
if actor.UserID == id && !enabled {
return User{}, fmt.Errorf("不能停用当前登录账户")
}
user.DisplayName = strings.TrimSpace(displayName)
user.Role = role
user.Enabled = enabled
user.UpdatedAt = s.now().UTC()
if err := s.store.UpdateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.update", user.ID, "success", map[string]any{"role": role, "enabled": enabled})
return user, nil
}
func (s *Service) audit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
_ = s.store.AppendAudit(ctx, AuditEntry{ID: newID("aud"), ActorID: actor, Action: action, Target: target, Outcome: outcome, Metadata: metadata, CreatedAt: s.now().UTC()})
}
func principalFor(user User, sessionID string) Principal {
return Principal{UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Role: user.Role, Permissions: PermissionsForRole(user.Role), SessionID: sessionID}
}
@@ -0,0 +1,77 @@
package identity
import (
"context"
"testing"
"time"
)
func newTestService(t *testing.T) (*Service, *MemoryStore) {
t.Helper()
store := NewMemoryStore()
service, err := NewService(store, Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "one-time-token", SessionTTL: time.Hour})
if err != nil {
t.Fatal(err)
}
return service, store
}
func TestBootstrapLoginRolesAndRevocation(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
admin, err := service.Bootstrap(ctx, "one-time-token", "admin", "管理员", "StrongPass2026")
if err != nil {
t.Fatalf("Bootstrap() error = %v", err)
}
if _, err := service.Bootstrap(ctx, "one-time-token", "another", "", "SecondStrong2026"); err != ErrBootstrapDone {
t.Fatalf("second Bootstrap() error = %v", err)
}
principal, raw, err := service.Login(ctx, "admin", "StrongPass2026")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if principal.Role != RoleAdministrator || !HasPermission(principal.Role, PermissionUsersWrite) {
t.Fatalf("unexpected principal: %#v", principal)
}
authenticated, err := service.Authenticate(ctx, raw)
if err != nil || authenticated.UserID != admin.ID {
t.Fatalf("Authenticate() = %#v, %v", authenticated, err)
}
if err := service.Logout(ctx, authenticated); err != nil {
t.Fatal(err)
}
if _, err := service.Authenticate(ctx, raw); err == nil {
t.Fatal("revoked token remained valid")
}
}
func TestInvalidLoginIsUniformAndAudited(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
if _, _, err := service.Login(ctx, "missing", "WrongPass2026"); err == nil {
t.Fatal("missing user login succeeded")
}
entries, err := service.ListAudit(ctx)
if err != nil || len(entries) != 1 || entries[0].Outcome != "failure" {
t.Fatalf("audit entries = %#v, %v", entries, err)
}
}
func TestBellTokenIsRejected(t *testing.T) {
service, _ := newTestService(t)
if _, err := service.Authenticate(context.Background(), "not-a-sense-token"); err == nil {
t.Fatal("foreign token accepted")
}
}
func TestRoleMatrix(t *testing.T) {
if HasPermission(RoleViewer, PermissionDeviceWrite) {
t.Fatal("viewer received write permission")
}
if !HasPermission(RoleOperator, PermissionMediaWrite) {
t.Fatal("operator missing media write permission")
}
if HasPermission(RoleSiteAdmin, PermissionUsersWrite) {
t.Fatal("site admin received user write permission")
}
}
+171
View File
@@ -0,0 +1,171 @@
package identity
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"sort"
"strings"
"sync"
"time"
)
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrBootstrapDone = errors.New("bootstrap already completed")
)
type Store interface {
BootstrapUser(context.Context, User) error
CreateUser(context.Context, User) error
UpdateUser(context.Context, User) error
FindUserByUsername(context.Context, string) (User, error)
FindUserByID(context.Context, string) (User, error)
ListUsers(context.Context) ([]User, error)
CreateSession(context.Context, Session) error
SessionActive(context.Context, string, time.Time) (bool, error)
RevokeSession(context.Context, string, time.Time) error
AppendAudit(context.Context, AuditEntry) error
ListAudit(context.Context, int) ([]AuditEntry, error)
}
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
byName map[string]string
sessions map[string]Session
audit []AuditEntry
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{users: map[string]User{}, byName: map[string]string{}, sessions: map[string]Session{}}
}
func (s *MemoryStore) BootstrapUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.users) != 0 {
return ErrBootstrapDone
}
return s.createUserLocked(user)
}
func (s *MemoryStore) CreateUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.createUserLocked(user)
}
func (s *MemoryStore) createUserLocked(user User) error {
key := strings.ToLower(user.Username)
if _, exists := s.byName[key]; exists {
return ErrAlreadyExists
}
s.users[user.ID] = user
s.byName[key] = user.ID
return nil
}
func (s *MemoryStore) UpdateUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
previous, exists := s.users[user.ID]
if !exists {
return ErrNotFound
}
delete(s.byName, strings.ToLower(previous.Username))
s.users[user.ID] = user
s.byName[strings.ToLower(user.Username)] = user.ID
return nil
}
func (s *MemoryStore) FindUserByUsername(_ context.Context, username string) (User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
id, exists := s.byName[strings.ToLower(username)]
if !exists {
return User{}, ErrNotFound
}
return s.users[id], nil
}
func (s *MemoryStore) FindUserByID(_ context.Context, id string) (User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, exists := s.users[id]
if !exists {
return User{}, ErrNotFound
}
return user, nil
}
func (s *MemoryStore) ListUsers(_ context.Context) ([]User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]User, 0, len(s.users))
for _, user := range s.users {
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool { return users[i].Username < users[j].Username })
return users, nil
}
func (s *MemoryStore) CreateSession(_ context.Context, session Session) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.ID] = session
return nil
}
func (s *MemoryStore) SessionActive(_ context.Context, id string, now time.Time) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[id]
return exists && session.RevokedAt == nil && now.Before(session.ExpiresAt), nil
}
func (s *MemoryStore) RevokeSession(_ context.Context, id string, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
session, exists := s.sessions[id]
if !exists {
return ErrNotFound
}
session.RevokedAt = &now
s.sessions[id] = session
return nil
}
func (s *MemoryStore) AppendAudit(_ context.Context, entry AuditEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.audit = append(s.audit, entry)
return nil
}
func (s *MemoryStore) ListAudit(_ context.Context, limit int) ([]AuditEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 200 {
limit = 100
}
start := len(s.audit) - limit
if start < 0 {
start = 0
}
entries := append([]AuditEntry(nil), s.audit[start:]...)
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
entries[left], entries[right] = entries[right], entries[left]
}
return entries, nil
}
func newID(prefix string) string {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
panic("cryptographic random source unavailable")
}
return prefix + "_" + hex.EncodeToString(buffer)
}
+50
View File
@@ -0,0 +1,50 @@
package identity
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
tokenIssuer = "yovision-sense"
tokenAudience = "sense-ui"
)
type sessionClaims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func issueToken(key []byte, user User, session Session, now time.Time) (string, error) {
claims := sessionClaims{
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Subject: user.ID,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(session.ExpiresAt),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)),
ID: session.ID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
}
func parseToken(key []byte, raw string, now time.Time) (*sessionClaims, error) {
claims := &sessionClaims{}
token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return key, nil
}, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience), jwt.WithTimeFunc(func() time.Time { return now }))
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid Sense session")
}
return claims, nil
}
+59
View File
@@ -0,0 +1,59 @@
package identity
import "time"
const (
RoleAdministrator = "administrator"
RoleOperator = "operator"
RoleSiteAdmin = "site_admin"
RoleViewer = "viewer"
)
const (
PermissionUsersRead = "identity.users.read"
PermissionUsersWrite = "identity.users.write"
PermissionAuditRead = "identity.audit.read"
PermissionDeviceRead = "device.read"
PermissionDeviceWrite = "device.write"
PermissionMediaRead = "media.read"
PermissionMediaWrite = "media.write"
PermissionAreaRead = "area.read"
PermissionAreaWrite = "area.write"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct {
ID string
UserID string
ExpiresAt time.Time
RevokedAt *time.Time
}
type AuditEntry struct {
ID string `json:"id"`
ActorID string `json:"actor_id,omitempty"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Outcome string `json:"outcome"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Principal struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
SessionID string `json:"-"`
}
@@ -0,0 +1,42 @@
package sense
import (
"fmt"
"os"
"time"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
memoryMode := app.Config().DatabaseMode == platform.DatabaseModeMemory
key, err := identity.SigningKeyOrRandom(os.Getenv("SENSE_IDENTITY_SIGNING_KEY"), memoryMode)
if err != nil {
return err
}
cfg := identity.Config{
SigningKey: key,
BootstrapToken: os.Getenv("SENSE_BOOTSTRAP_TOKEN"),
SecureCookie: identity.SecureCookieFromEnvironment(os.Getenv("SENSE_COOKIE_SECURE"), memoryMode),
SessionTTL: 8 * time.Hour,
}
var store identity.Store
if memoryMode {
store = identity.NewMemoryStore()
} else {
if cfg.BootstrapToken == "" {
return fmt.Errorf("SENSE_BOOTSTRAP_TOKEN is required until secure initialization is completed")
}
store = identity.NewPostgresStore(app.Database())
}
service, err := identity.NewService(store, cfg)
if err != nil {
return err
}
app.RegisterMigration(platform.Migration{Version: 2026081201, Name: "sense_identity", SQL: identity.MigrationSQL})
identity.NewModule(service, cfg).Register(app)
return nil
})
}
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS sense_identity_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_audit (
id TEXT PRIMARY KEY,
actor_id TEXT NULL REFERENCES sense_identity_users(id),
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
+7 -3
View File
@@ -2,12 +2,16 @@ module yovision.local/sense
go 1.26.5
require github.com/jackc/pgx/v5 v5.8.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
golang.org/x/crypto v0.55.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/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+8 -4
View File
@@ -1,6 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -16,10 +18,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+5 -1
View File
@@ -3,6 +3,7 @@ package platform
import (
"encoding/json"
"errors"
"io"
"net/http"
)
@@ -30,10 +31,13 @@ func WriteError(w http.ResponseWriter, err error) {
}
func DecodeJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
decoder := json.NewDecoder(io.LimitReader(r.Body, (1<<20)+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
}
return nil
}
+10
View File
@@ -0,0 +1,10 @@
# Identity acceptance boundary
Automated identity tests live with the Go package at
`Sense/server/app/sense/identity`. They use generated, non-production values
and never contain a real password, token, cookie or signing key.
The acceptance checks cover bootstrap uniqueness, password hashing, login,
role permissions, product-specific token validation, session revocation and
audit records. PostgreSQL migration execution remains a deployment-environment
integration check.
+10
View File
@@ -0,0 +1,10 @@
import request from '../../../bootstrap/request'
export const login = (data) => request.post('/identity/login', data)
export const logout = () => request.post('/identity/logout')
export const currentUser = () => request.get('/identity/me')
export const listUsers = () => request.get('/identity/users')
export const createUser = (data) => request.post('/identity/users', data)
export const updateUser = (id, data) => request.patch(`/identity/users/${id}`, data)
export const listAudit = () => request.get('/identity/audit')
+8 -3
View File
@@ -56,13 +56,17 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
const router = useRouter()
const store = useStore()
const collapsed = ref(false)
const navigation = computed(() => {
const root = router.getRoutes().find((route) => route.path === '/')
const permissions = store.getters['sense-identity/permissions'] || []
return (root?.children || [])
.filter((route) => route.meta?.title && !route.meta?.hidden)
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
.map((route) => ({
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
title: route.meta.title,
@@ -72,9 +76,10 @@ const navigation = computed(() => {
.sort((left, right) => left.order - right.order)
})
function logout() {
window.sessionStorage.removeItem('sense_session')
if (router.hasRoute('sense-login')) router.push({ name: 'sense-login' })
async function logout() {
if (store.hasModule('sense-identity')) await store.dispatch('sense-identity/logout')
else window.sessionStorage.removeItem('sense_session')
if (router.hasRoute('sense-login')) await router.push({ name: 'sense-login' })
}
</script>
@@ -0,0 +1,25 @@
import Login from '../../views/sense/identity/Login.vue'
import Users from '../../views/sense/identity/Users.vue'
import Audit from '../../views/sense/identity/Audit.vue'
export default [
{
path: '/login',
name: 'sense-login',
component: Login,
meta: { title: '登录', public: true, hidden: true }
},
{
path: 'identity/users',
name: 'sense-users',
component: Users,
meta: { title: '用户与权限', icon: 'UserFilled', order: 90, permission: 'identity.users.read' }
},
{
path: 'identity/audit',
name: 'sense-audit',
component: Audit,
meta: { title: '操作记录', icon: 'Tickets', order: 91, permission: 'identity.audit.read' }
}
]
@@ -0,0 +1,45 @@
import * as identityApi from '../../api/sense/identity'
function loadPrincipal() {
try {
return JSON.parse(window.sessionStorage.getItem('sense_session'))
} catch {
return null
}
}
export default {
namespaced: true,
state: () => ({ principal: loadPrincipal() }),
getters: {
permissions: (state) => state.principal?.permissions || [],
hasPermission: (state) => (permission) => (state.principal?.permissions || []).includes(permission)
},
mutations: {
setPrincipal(state, principal) {
state.principal = principal
if (principal) window.sessionStorage.setItem('sense_session', JSON.stringify(principal))
else window.sessionStorage.removeItem('sense_session')
}
},
actions: {
async login({ commit }, credentials) {
const principal = await identityApi.login(credentials)
commit('setPrincipal', principal)
return principal
},
async refresh({ commit }) {
const principal = await identityApi.currentUser()
commit('setPrincipal', principal)
return principal
},
async logout({ commit }) {
try {
await identityApi.logout()
} finally {
commit('setPrincipal', null)
}
}
}
}
@@ -0,0 +1,30 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>操作记录</h1><p>查看登录和权限变更结果;记录中不会保存密码、Cookie 或令牌。</p></div></div>
<div class="table-container">
<div class="table-actions"><strong>最近 100 条</strong><el-button :icon="Refresh" @click="load">刷新</el-button></div>
<el-table v-loading="loading" :data="entries" border>
<el-table-column prop="created_at" label="时间" width="190" />
<el-table-column prop="action" label="操作" min-width="180" />
<el-table-column prop="actor_id" label="操作人" min-width="180" />
<el-table-column prop="target" label="对象" min-width="180" />
<el-table-column label="结果" width="100"><template #default="scope"><el-tag :type="scope.row.outcome === 'success' ? 'success' : 'danger'">{{ scope.row.outcome === 'success' ? '成功' : '失败' }}</el-tag></template></el-table-column>
</el-table>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { listAudit } from '../../../api/sense/identity'
const loading = ref(false)
const entries = ref([])
async function load() {
loading.value = true
try { entries.value = (await listAudit()).items || [] } finally { loading.value = false }
}
onMounted(load)
</script>
@@ -0,0 +1,81 @@
<template>
<main class="login-page">
<section class="login-panel">
<div class="product-block">
<div class="product-mark">S</div>
<h1>Sense 视频感知管理</h1>
<p>面向网管与现场人员的设备、视频和区域配置工具</p>
</div>
<el-card shadow="never" class="login-card">
<template #header>
<div>
<h2>登录</h2>
<span>使用 Sense 独立账户,不接受 Bell 登录状态</span>
</div>
</template>
<el-alert v-if="errorMessage" :title="errorMessage" 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="form.username" autocomplete="username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" @keyup.enter="submit" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="login-button" @click="submit">登录</el-button>
</el-form>
<el-divider />
<p class="bootstrap-hint">首次部署请由实施人员使用仓库外一次性令牌完成安全初始化;系统不提供默认账户或默认密码。</p>
</el-card>
</section>
</main>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
const route = useRoute()
const router = useRouter()
const store = useStore()
const formRef = ref()
const loading = ref(false)
const errorMessage = 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
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMessage.value = ''
try {
await store.dispatch('sense-identity/login', form)
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/'
await router.replace(redirect)
} catch (error) {
errorMessage.value = error.message || '用户名或密码不正确'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { min-height: 100vh; padding: 48px; background: linear-gradient(135deg, #001529, #073b66); }
.login-panel { display: grid; grid-template-columns: minmax(360px, 1fr) 420px; max-width: 1060px; min-height: 620px; margin: 0 auto; align-items: center; gap: 80px; }
.product-block { color: #fff; }
.product-mark { display: inline-flex; width: 56px; height: 56px; align-items: center; justify-content: center; border-radius: 12px; background: #1890ff; font-size: 32px; font-weight: 700; }
.product-block h1 { margin: 24px 0 12px; font-size: 34px; }
.product-block p { color: rgba(255, 255, 255, .72); font-size: 16px; }
.login-card { border: 0; }
.login-card h2 { margin: 0 0 6px; font-size: 22px; }
.login-card span, .bootstrap-hint { color: #86909c; font-size: 13px; line-height: 1.7; }
.login-card :deep(.el-alert) { margin-bottom: 16px; }
.login-button { width: 100%; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<section class="page-container">
<div class="page-heading">
<div><h1>用户与权限</h1><p>管理谁可以配置设备、视频和区域;权限由后端最终校验。</p></div>
</div>
<div class="search-toolbar">
<el-form :inline="true">
<el-form-item label="用户名"><el-input v-model="keyword" clearable placeholder="搜索用户名或姓名" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search">查询</el-button><el-button @click="keyword = ''">重置</el-button></el-form-item>
</el-form>
</div>
<div class="table-container">
<div class="table-actions">
<strong>账户列表</strong>
<el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">新增用户</el-button>
</div>
<el-table v-loading="loading" :data="filteredUsers" border>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column prop="display_name" label="姓名" min-width="140" />
<el-table-column label="角色" width="150"><template #default="scope"><el-tag>{{ roleLabel(scope.row.role) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="190" />
<el-table-column v-if="canWrite" label="操作" width="100" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination background layout="total, prev, pager, next" :total="filteredUsers.length" :page-size="20" /></div>
</div>
<el-dialog v-model="dialogVisible" :title="editingID ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
<el-form-item label="用户名" prop="username"><el-input v-model="form.username" :disabled="Boolean(editingID)" /></el-form-item>
<el-form-item label="姓名" prop="display_name"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width: 100%"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item>
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 12 位,包含大小写字母和数字,且不含用户名。</div></el-form-item>
<el-form-item v-else label="状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import { createUser, listUsers, updateUser } from '../../../api/sense/identity'
const store = useStore()
const loading = ref(false)
const saving = ref(false)
const keyword = ref('')
const users = ref([])
const dialogVisible = ref(false)
const editingID = ref('')
const formRef = ref()
const roles = [
{ value: 'administrator', label: '系统管理员' },
{ value: 'operator', label: '实施/运维' },
{ value: 'site_admin', label: '站点管理员' },
{ value: 'viewer', label: '只读用户' }
]
const form = reactive({ username: '', display_name: '', role: 'viewer', password: '', enabled: true })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
display_name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
password: [{ required: true, min: 12, message: '请输入至少 12 位初始密码', trigger: 'blur' }]
}
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('identity.users.write'))
const filteredUsers = computed(() => {
const value = keyword.value.trim().toLowerCase()
return value ? users.value.filter((item) => `${item.username} ${item.display_name}`.toLowerCase().includes(value)) : users.value
})
function roleLabel(value) { return roles.find((item) => item.value === value)?.label || value }
function resetForm() { Object.assign(form, { username: '', display_name: '', role: 'viewer', password: '', enabled: true }) }
function openCreate() { editingID.value = ''; resetForm(); dialogVisible.value = true }
function openEdit(user) { editingID.value = user.id; Object.assign(form, { ...user, password: '' }); dialogVisible.value = true }
async function load() {
loading.value = true
try { users.value = (await listUsers()).items || [] } finally { loading.value = false }
}
async function save() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingID.value) await updateUser(editingID.value, { display_name: form.display_name, role: form.role, enabled: form.enabled })
else await createUser(form)
ElMessage.success('保存成功')
dialogVisible.value = false
await load()
} catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false }
}
onMounted(load)
</script>
<style scoped>
.field-hint { color: #86909c; font-size: 12px; line-height: 1.5; }
</style>