feat: add Sense identity RBAC and audit (#22)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user