From b9416ebc0880eb048d80c7089abd8b0a759c5fad Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 12 Aug 2026 17:06:26 +0800 Subject: [PATCH] feat: add Sense identity RBAC and audit (#22) --- Sense/server/app/sense/identity/http.go | 222 ++++++++++++++++++ Sense/server/app/sense/identity/http_test.go | 59 +++++ Sense/server/app/sense/identity/migration.go | 30 +++ Sense/server/app/sense/identity/password.go | 37 +++ .../server/app/sense/identity/permissions.go | 42 ++++ Sense/server/app/sense/identity/postgres.go | 132 +++++++++++ Sense/server/app/sense/identity/service.go | 159 +++++++++++++ .../server/app/sense/identity/service_test.go | 77 ++++++ Sense/server/app/sense/identity/store.go | 171 ++++++++++++++ Sense/server/app/sense/identity/token.go | 50 ++++ Sense/server/app/sense/identity/types.go | 59 +++++ Sense/server/cmd/sense/modules_identity.go | 42 ++++ .../sense_identity/2026081201_identity.sql | 27 +++ Sense/server/go.mod | 10 +- Sense/server/go.sum | 12 +- Sense/server/internal/platform/http.go | 6 +- Sense/tests/identity/README.md | 10 + Sense/ui/src/api/sense/identity/index.js | 10 + Sense/ui/src/layout/AppLayout.vue | 11 +- Sense/ui/src/router/modules/sense-identity.js | 25 ++ Sense/ui/src/store/modules/sense-identity.js | 45 ++++ Sense/ui/src/views/sense/identity/Audit.vue | 30 +++ Sense/ui/src/views/sense/identity/Login.vue | 81 +++++++ Sense/ui/src/views/sense/identity/Users.vue | 102 ++++++++ 24 files changed, 1438 insertions(+), 11 deletions(-) create mode 100644 Sense/server/app/sense/identity/http.go create mode 100644 Sense/server/app/sense/identity/http_test.go create mode 100644 Sense/server/app/sense/identity/migration.go create mode 100644 Sense/server/app/sense/identity/password.go create mode 100644 Sense/server/app/sense/identity/permissions.go create mode 100644 Sense/server/app/sense/identity/postgres.go create mode 100644 Sense/server/app/sense/identity/service.go create mode 100644 Sense/server/app/sense/identity/service_test.go create mode 100644 Sense/server/app/sense/identity/store.go create mode 100644 Sense/server/app/sense/identity/token.go create mode 100644 Sense/server/app/sense/identity/types.go create mode 100644 Sense/server/cmd/sense/modules_identity.go create mode 100644 Sense/server/database/migrations/sense_identity/2026081201_identity.sql create mode 100644 Sense/tests/identity/README.md create mode 100644 Sense/ui/src/api/sense/identity/index.js create mode 100644 Sense/ui/src/router/modules/sense-identity.js create mode 100644 Sense/ui/src/store/modules/sense-identity.js create mode 100644 Sense/ui/src/views/sense/identity/Audit.vue create mode 100644 Sense/ui/src/views/sense/identity/Login.vue create mode 100644 Sense/ui/src/views/sense/identity/Users.vue diff --git a/Sense/server/app/sense/identity/http.go b/Sense/server/app/sense/identity/http.go new file mode 100644 index 0000000..977eee0 --- /dev/null +++ b/Sense/server/app/sense/identity/http.go @@ -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") +} diff --git a/Sense/server/app/sense/identity/http_test.go b/Sense/server/app/sense/identity/http_test.go new file mode 100644 index 0000000..d96302c --- /dev/null +++ b/Sense/server/app/sense/identity/http_test.go @@ -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) + } +} diff --git a/Sense/server/app/sense/identity/migration.go b/Sense/server/app/sense/identity/migration.go new file mode 100644 index 0000000..90442a9 --- /dev/null +++ b/Sense/server/app/sense/identity/migration.go @@ -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); +` diff --git a/Sense/server/app/sense/identity/password.go b/Sense/server/app/sense/identity/password.go new file mode 100644 index 0000000..578e336 --- /dev/null +++ b/Sense/server/app/sense/identity/password.go @@ -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 +} diff --git a/Sense/server/app/sense/identity/permissions.go b/Sense/server/app/sense/identity/permissions.go new file mode 100644 index 0000000..3ac8bca --- /dev/null +++ b/Sense/server/app/sense/identity/permissions.go @@ -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 +} diff --git a/Sense/server/app/sense/identity/postgres.go b/Sense/server/app/sense/identity/postgres.go new file mode 100644 index 0000000..6eb9268 --- /dev/null +++ b/Sense/server/app/sense/identity/postgres.go @@ -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() +} diff --git a/Sense/server/app/sense/identity/service.go b/Sense/server/app/sense/identity/service.go new file mode 100644 index 0000000..bca385b --- /dev/null +++ b/Sense/server/app/sense/identity/service.go @@ -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} +} diff --git a/Sense/server/app/sense/identity/service_test.go b/Sense/server/app/sense/identity/service_test.go new file mode 100644 index 0000000..a6adccb --- /dev/null +++ b/Sense/server/app/sense/identity/service_test.go @@ -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") + } +} diff --git a/Sense/server/app/sense/identity/store.go b/Sense/server/app/sense/identity/store.go new file mode 100644 index 0000000..d2d7c98 --- /dev/null +++ b/Sense/server/app/sense/identity/store.go @@ -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) +} diff --git a/Sense/server/app/sense/identity/token.go b/Sense/server/app/sense/identity/token.go new file mode 100644 index 0000000..2ab5dc5 --- /dev/null +++ b/Sense/server/app/sense/identity/token.go @@ -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 +} diff --git a/Sense/server/app/sense/identity/types.go b/Sense/server/app/sense/identity/types.go new file mode 100644 index 0000000..07e1721 --- /dev/null +++ b/Sense/server/app/sense/identity/types.go @@ -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:"-"` +} diff --git a/Sense/server/cmd/sense/modules_identity.go b/Sense/server/cmd/sense/modules_identity.go new file mode 100644 index 0000000..ce553d6 --- /dev/null +++ b/Sense/server/cmd/sense/modules_identity.go @@ -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 + }) +} diff --git a/Sense/server/database/migrations/sense_identity/2026081201_identity.sql b/Sense/server/database/migrations/sense_identity/2026081201_identity.sql new file mode 100644 index 0000000..d5a9a86 --- /dev/null +++ b/Sense/server/database/migrations/sense_identity/2026081201_identity.sql @@ -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); + diff --git a/Sense/server/go.mod b/Sense/server/go.mod index 2029838..b2054d9 100644 --- a/Sense/server/go.mod +++ b/Sense/server/go.mod @@ -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 ) diff --git a/Sense/server/go.sum b/Sense/server/go.sum index 87a6c8a..823716d 100644 --- a/Sense/server/go.sum +++ b/Sense/server/go.sum @@ -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= diff --git a/Sense/server/internal/platform/http.go b/Sense/server/internal/platform/http.go index f49bf25..8bef4cc 100644 --- a/Sense/server/internal/platform/http.go +++ b/Sense/server/internal/platform/http.go @@ -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 } diff --git a/Sense/tests/identity/README.md b/Sense/tests/identity/README.md new file mode 100644 index 0000000..f3a058c --- /dev/null +++ b/Sense/tests/identity/README.md @@ -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. diff --git a/Sense/ui/src/api/sense/identity/index.js b/Sense/ui/src/api/sense/identity/index.js new file mode 100644 index 0000000..62faa6d --- /dev/null +++ b/Sense/ui/src/api/sense/identity/index.js @@ -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') + diff --git a/Sense/ui/src/layout/AppLayout.vue b/Sense/ui/src/layout/AppLayout.vue index ff05b0a..fbe0b96 100644 --- a/Sense/ui/src/layout/AppLayout.vue +++ b/Sense/ui/src/layout/AppLayout.vue @@ -56,13 +56,17 @@ diff --git a/Sense/ui/src/router/modules/sense-identity.js b/Sense/ui/src/router/modules/sense-identity.js new file mode 100644 index 0000000..427fa52 --- /dev/null +++ b/Sense/ui/src/router/modules/sense-identity.js @@ -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' } + } +] + diff --git a/Sense/ui/src/store/modules/sense-identity.js b/Sense/ui/src/store/modules/sense-identity.js new file mode 100644 index 0000000..c645509 --- /dev/null +++ b/Sense/ui/src/store/modules/sense-identity.js @@ -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) + } + } + } +} + diff --git a/Sense/ui/src/views/sense/identity/Audit.vue b/Sense/ui/src/views/sense/identity/Audit.vue new file mode 100644 index 0000000..7ac0c46 --- /dev/null +++ b/Sense/ui/src/views/sense/identity/Audit.vue @@ -0,0 +1,30 @@ + + + + diff --git a/Sense/ui/src/views/sense/identity/Login.vue b/Sense/ui/src/views/sense/identity/Login.vue new file mode 100644 index 0000000..341d831 --- /dev/null +++ b/Sense/ui/src/views/sense/identity/Login.vue @@ -0,0 +1,81 @@ + + + + + + diff --git a/Sense/ui/src/views/sense/identity/Users.vue b/Sense/ui/src/views/sense/identity/Users.vue new file mode 100644 index 0000000..88c4145 --- /dev/null +++ b/Sense/ui/src/views/sense/identity/Users.vue @@ -0,0 +1,102 @@ + + + + + +