[SEN] 重建 Sense 登录、RBAC 与审计 (#64) #82
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
id, b64s, _, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
|
||||
)
|
||||
|
||||
const bootstrapTokenHeader = "X-Sense-Bootstrap-Token"
|
||||
|
||||
type bootstrapRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
NickName string `json:"nickName"`
|
||||
}
|
||||
|
||||
// Bootstrap creates the first Sense administrator. The one-time authorization
|
||||
// token is supplied out of band and never accepted in the JSON request body.
|
||||
func (e System) Bootstrap(c *gin.Context) {
|
||||
if err := e.MakeContext(c).MakeOrm().Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "安全初始化失败")
|
||||
return
|
||||
}
|
||||
db := e.Orm
|
||||
var req bootstrapRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
e.Custom(gin.H{"code": http.StatusBadRequest, "msg": "安全初始化不可用或输入不符合要求"})
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.NickName == "" {
|
||||
req.NickName = req.Username
|
||||
}
|
||||
if err := validateBootstrapInput(req); err != nil || !validBootstrapToken(c.GetHeader(bootstrapTokenHeader)) {
|
||||
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
|
||||
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
|
||||
return
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("LOCK TABLE sys_user IN EXCLUSIVE MODE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&models.SysUser{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return errors.New("identity already initialized")
|
||||
}
|
||||
var role models.SysRole
|
||||
if err := tx.Where("role_key = ? AND status = ?", "admin", "2").First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.SysUser{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
NickName: req.NickName,
|
||||
RoleId: role.RoleId,
|
||||
Status: "2",
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
|
||||
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
|
||||
return
|
||||
}
|
||||
handler.WriteIdentityAudit(c, "2", "首个管理员创建成功", req.Username)
|
||||
e.Custom(gin.H{"code": http.StatusOK, "msg": "管理员创建成功"})
|
||||
}
|
||||
|
||||
func validateBootstrapInput(req bootstrapRequest) error {
|
||||
if len(req.Username) < 3 || len(req.Username) > 64 || strings.ContainsAny(req.Username, " \t\r\n") {
|
||||
return errors.New("invalid username")
|
||||
}
|
||||
return service.ValidatePassword(req.Password)
|
||||
}
|
||||
|
||||
func validBootstrapToken(candidate string) bool {
|
||||
expected := os.Getenv("SENSE_BOOTSTRAP_TOKEN")
|
||||
if len(expected) < 32 || len(candidate) != len(expected) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(expected)) == 1
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package apis
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateBootstrapInput(t *testing.T) {
|
||||
if err := validateBootstrapInput(bootstrapRequest{Username: "admin", Password: "abcdef"}); err != nil {
|
||||
t.Fatalf("expected valid bootstrap input: %v", err)
|
||||
}
|
||||
for _, req := range []bootstrapRequest{
|
||||
{Username: "ab", Password: "abcdef"},
|
||||
{Username: "bad name", Password: "abcdef"},
|
||||
{Username: "admin", Password: "abcde"},
|
||||
} {
|
||||
if err := validateBootstrapInput(req); err == nil {
|
||||
t.Fatalf("expected invalid bootstrap input: %+v", req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidBootstrapToken(t *testing.T) {
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
t.Setenv("SENSE_BOOTSTRAP_TOKEN", token)
|
||||
if !validBootstrapToken(token) {
|
||||
t.Fatal("expected configured token to be accepted")
|
||||
}
|
||||
if validBootstrapToken("wrong") {
|
||||
t.Fatal("expected wrong token to be rejected")
|
||||
}
|
||||
t.Setenv("SENSE_BOOTSTRAP_TOKEN", "short")
|
||||
if validBootstrapToken("short") {
|
||||
t.Fatal("expected short configured token to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service/dto"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
|
||||
)
|
||||
|
||||
type SysUser struct {
|
||||
@@ -113,6 +113,10 @@ func (e SysUser) Insert(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = service.ValidatePassword(req.Password); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
return
|
||||
}
|
||||
// 设置创建人
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
@@ -306,6 +310,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = service.ValidatePassword(req.Password); err != nil {
|
||||
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
@@ -314,9 +323,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
|
||||
|
||||
err = s.ResetPwd(&req, p)
|
||||
if err != nil {
|
||||
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
handler.WriteIdentityAudit(c, "2", "管理员重置密码成功", user.GetUserName(c))
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
@@ -346,18 +357,21 @@ func (e SysUser) UpdatePwd(c *gin.Context) {
|
||||
|
||||
// 数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var hash []byte
|
||||
if hash, err = bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost); err != nil {
|
||||
req.NewPassword = string(hash)
|
||||
if err = service.ValidatePassword(req.NewPassword); err != nil {
|
||||
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = s.UpdatePwd(user.GetUserId(c), req.OldPassword, req.NewPassword, p)
|
||||
if err != nil {
|
||||
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
|
||||
e.Logger.Error(err)
|
||||
e.Error(http.StatusForbidden, err, "密码修改失败")
|
||||
return
|
||||
}
|
||||
|
||||
handler.WriteIdentityAudit(c, "2", "修改密码成功", user.GetUserName(c))
|
||||
e.OK(nil, "密码修改成功")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ package router
|
||||
import (
|
||||
"os"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
// InitRouter 路由初始化,不要怀疑,这里用到了
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerSysApiRouter)
|
||||
}
|
||||
|
||||
// registerSysApiRouter
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerSysConfigRouter)
|
||||
}
|
||||
|
||||
// 需认证的路由代码
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -24,9 +24,9 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
|
||||
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
|
||||
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
r1.GET("/deptTree", api.Get2Tree)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
//r1.GET("/menuids", api.GetMenuIDS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func registerSysRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
|
||||
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
r1.PUT("/role-status", api.Update2Status)
|
||||
r1.PUT("/roledatascope", api.Update2DataScope)
|
||||
|
||||
@@ -68,6 +68,8 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/captcha", apis.System{}.GenerateCaptchaHandler)
|
||||
v1.POST("/bootstrap", apis.System{}.Bootstrap)
|
||||
v1.POST("/login", authMiddleware.LoginHandler)
|
||||
// Refresh time can be longer than token timeout
|
||||
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
|
||||
@@ -81,8 +83,7 @@ func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlewar
|
||||
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
|
||||
//v1.GET("/menuTreeselect", api.GetMenuTreeSelect)
|
||||
v1auth.GET("/roleDeptTreeselect/:roleId", api2.GetDeptTreeRoleSelect)
|
||||
v1auth.POST("/logout", handler.LogOut)
|
||||
}
|
||||
v1.Group("").Use(authMiddleware.MiddlewareFunc()).POST("/logout", handler.LogOut)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -24,16 +24,19 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
|
||||
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(actions.PermissionAction())
|
||||
{
|
||||
user.GET("/profile", api.GetProfile)
|
||||
user.POST("/avatar", api.InsetAvatar)
|
||||
user.PUT("/pwd/set", api.UpdatePwd)
|
||||
user.PUT("/pwd/reset", api.ResetPwd)
|
||||
user.PUT("/status", api.UpdateStatus)
|
||||
}
|
||||
userAdmin := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
{
|
||||
userAdmin.PUT("/pwd/reset", api.ResetPwd)
|
||||
userAdmin.PUT("/status", api.UpdateStatus)
|
||||
}
|
||||
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc())
|
||||
{
|
||||
v1auth.GET("/getinfo", api.GetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MinPasswordLength = 6
|
||||
MaxPasswordBytes = 72
|
||||
)
|
||||
|
||||
// ValidatePassword enforces the frozen Sense policy. Lowercase-only passwords
|
||||
// are valid; complexity rules are intentionally not added here.
|
||||
func ValidatePassword(password string) error {
|
||||
if utf8.RuneCountInString(password) < MinPasswordLength {
|
||||
return errors.New("密码至少需要 6 个字符")
|
||||
}
|
||||
if len(password) > MaxPasswordBytes {
|
||||
return errors.New("密码不能超过 72 个字节")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "lowercase accepted", value: "chengma", wantErr: false},
|
||||
{name: "six characters accepted", value: "abcdef", wantErr: false},
|
||||
{name: "too short", value: "abcde", wantErr: true},
|
||||
{name: "bcrypt byte limit", value: strings.Repeat("a", MaxPasswordBytes+1), wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ValidatePassword(tt.value); (got != nil) != tt.wantErr {
|
||||
t.Fatalf("ValidatePassword() error = %v, wantErr %v", got, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,13 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
|
||||
return err
|
||||
}
|
||||
|
||||
mp := make(map[string]interface{}, 0)
|
||||
mp := make(map[string]struct{})
|
||||
polices := make([][]string, 0)
|
||||
for _, menu := range dataMenu {
|
||||
for _, api := range menu.SysApi {
|
||||
if mp[data.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
|
||||
mp[data.RoleKey+"-"+api.Path+"-"+api.Action] = ""
|
||||
key := data.RoleKey + "-" + api.Path + "-" + api.Action
|
||||
if _, exists := mp[key]; !exists {
|
||||
mp[key] = struct{}{}
|
||||
polices = append(polices, []string{data.RoleKey, api.Path, api.Action})
|
||||
}
|
||||
}
|
||||
@@ -169,12 +170,13 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
|
||||
e.Log.Errorf("delete policy error:%s", err)
|
||||
return err
|
||||
}
|
||||
mp := make(map[string]interface{}, 0)
|
||||
mp := make(map[string]struct{})
|
||||
polices := make([][]string, 0)
|
||||
for _, menu := range mlist {
|
||||
for _, api := range menu.SysApi {
|
||||
if mp[model.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
|
||||
mp[model.RoleKey+"-"+api.Path+"-"+api.Action] = ""
|
||||
key := model.RoleKey + "-" + api.Path + "-" + api.Action
|
||||
if _, exists := mp[key]; !exists {
|
||||
mp[key] = struct{}{}
|
||||
//_, err = cb.AddNamedPolicy("p", model.RoleKey, api.Path, api.Action)
|
||||
polices = append(polices, []string{model.RoleKey, api.Path, api.Action})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ func (e *SysUser) UpdateStatus(c *dto.UpdateSysUserStatusReq, p *actions.DataPer
|
||||
// ResetPwd 重置用户密码
|
||||
func (e *SysUser) ResetPwd(c *dto.ResetSysUserPwdReq, p *actions.DataPermission) error {
|
||||
var err error
|
||||
if err = ValidatePassword(c.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
var model models.SysUser
|
||||
db := e.Orm.Scopes(
|
||||
actions.Permission(model.TableName(), p),
|
||||
@@ -204,8 +207,8 @@ func (e *SysUser) Remove(c *dto.SysUserById, p *actions.DataPermission) error {
|
||||
func (e *SysUser) UpdatePwd(id int, oldPassword, newPassword string, p *actions.DataPermission) error {
|
||||
var err error
|
||||
|
||||
if newPassword == "" {
|
||||
return nil
|
||||
if err = ValidatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
c := &models.SysUser{}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
type identityRoleDefinition struct {
|
||||
name string
|
||||
key string
|
||||
sort int
|
||||
menuIDs []int
|
||||
policies [][2]string
|
||||
}
|
||||
|
||||
type identityCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V0 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V1 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V2 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V3 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V4 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V5 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
}
|
||||
|
||||
func (identityCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseIdentityBaseline)
|
||||
}
|
||||
|
||||
func migrateSenseIdentityBaseline(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&identityCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
resetPasswordMenu, err := ensureIdentityButton(tx, "SenseResetPassword", "重置用户密码", "admin:sysUser:resetPassword", 154)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changeStatusMenu, err := ensureIdentityButton(tx, "SenseChangeUserStatus", "启停用户", "admin:sysUser:edit", 155)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
definitions := []identityRoleDefinition{
|
||||
{name: "系统管理员", key: "admin", sort: 1},
|
||||
{
|
||||
name: "实施运维", key: "implementation_operator", sort: 10,
|
||||
menuIDs: []int{2, 211, 212, 216, 248, 250},
|
||||
policies: [][2]string{{"/api/v1/sys-login-log", "GET"}, {"/api/v1/sys-login-log/:id", "GET"}, {"/api/v1/sys-opera-log", "GET"}, {"/api/v1/sys-opera-log/:id", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
|
||||
},
|
||||
{
|
||||
name: "站点管理员", key: "site_admin", sort: 20,
|
||||
menuIDs: []int{2, 3, 43, 44, 45, resetPasswordMenu.MenuId, changeStatusMenu.MenuId},
|
||||
policies: [][2]string{{"/api/v1/sys-user", "GET"}, {"/api/v1/sys-user/:id", "GET"}, {"/api/v1/sys-user", "POST"}, {"/api/v1/sys-user", "PUT"}, {"/api/v1/user/pwd/reset", "PUT"}, {"/api/v1/user/status", "PUT"}, {"/api/v1/deptTree", "GET"}, {"/api/v1/post", "GET"}, {"/api/v1/role", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
|
||||
},
|
||||
{name: "只读用户", key: "viewer", sort: 30},
|
||||
}
|
||||
|
||||
for _, definition := range definitions {
|
||||
role, err := ensureIdentityRole(tx, definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = attachIdentityMenus(tx, &role, definition.menuIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range definition.policies {
|
||||
rule := identityCasbinRule{Ptype: "p", V0: definition.key, V1: policy[0], V2: policy[1]}
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureIdentityRole(tx *gorm.DB, definition identityRoleDefinition) (models.SysRole, error) {
|
||||
var role models.SysRole
|
||||
err := tx.Where("role_key = ?", definition.key).First(&role).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return role, err
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
role = models.SysRole{RoleName: definition.name, RoleKey: definition.key, RoleSort: definition.sort, Status: "2", Admin: definition.key == "admin", DataScope: "1", Remark: "Sense 内置最小权限角色"}
|
||||
return role, tx.Create(&role).Error
|
||||
}
|
||||
err = tx.Model(&role).Updates(map[string]interface{}{"role_name": definition.name, "status": "2", "role_sort": definition.sort, "admin": definition.key == "admin"}).Error
|
||||
return role, err
|
||||
}
|
||||
|
||||
func ensureIdentityButton(tx *gorm.DB, name, title, permission string, apiID int) (models.SysMenu, error) {
|
||||
menu := models.SysMenu{MenuName: name, Title: title, MenuType: "F", Action: "PUT", Permission: permission, ParentId: 3, Paths: "/0/2/3", Sort: 25, Visible: "1", IsFrame: "1"}
|
||||
if err := tx.Where("menu_name = ?", name).FirstOrCreate(&menu).Error; err != nil {
|
||||
return menu, err
|
||||
}
|
||||
var api models.SysApi
|
||||
if err := tx.First(&api, apiID).Error; err != nil {
|
||||
return menu, err
|
||||
}
|
||||
return menu, tx.Model(&menu).Association("SysApi").Append(&api)
|
||||
}
|
||||
|
||||
func attachIdentityMenus(tx *gorm.DB, role *models.SysRole, menuIDs []int) error {
|
||||
if len(menuIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var menus []models.SysMenu
|
||||
if err := tx.Where("menu_id IN ?", menuIDs).Find(&menus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(role).Association("SysMenu").Append(&menus)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package middleware
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
|
||||
)
|
||||
|
||||
// AuthInit jwt验证new
|
||||
@@ -19,7 +19,7 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
}
|
||||
}
|
||||
return jwt.New(&jwt.GinJWTMiddleware{
|
||||
Realm: "test zone",
|
||||
Realm: "Sense",
|
||||
Key: []byte(config.JwtConfig.Secret),
|
||||
Timeout: timeout,
|
||||
MaxRefresh: time.Hour,
|
||||
@@ -28,9 +28,9 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
Authenticator: handler.Authenticator,
|
||||
Authorizator: handler.Authorizator,
|
||||
Unauthorized: handler.Unauthorized,
|
||||
TokenLookup: "header: Authorization, query: token, cookie: jwt",
|
||||
TokenLookup: "header: Authorization, cookie: sense_session",
|
||||
TokenHeadName: "Bearer",
|
||||
TimeFunc: time.Now,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/mssola/user_agent"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
||||
)
|
||||
|
||||
func PayloadFunc(data interface{}) jwt.MapClaims {
|
||||
@@ -28,7 +26,7 @@ func PayloadFunc(data interface{}) jwt.MapClaims {
|
||||
jwt.RoleKey: r.RoleKey,
|
||||
jwt.NiceKey: u.Username,
|
||||
jwt.DataScopeKey: r.DataScope,
|
||||
jwt.RoleNameKey: r.RoleName,
|
||||
jwt.RoleNameKey: r.RoleKey,
|
||||
}
|
||||
}
|
||||
return jwt.MapClaims{}
|
||||
@@ -108,36 +106,44 @@ func Authenticator(c *gin.Context) (interface{}, error) {
|
||||
|
||||
// LoginLogToDB Write log to database
|
||||
func LoginLogToDB(c *gin.Context, status string, msg string, username string) {
|
||||
if !config.LoggerConfig.EnabledDB {
|
||||
WriteIdentityAudit(c, status, msg, username)
|
||||
}
|
||||
|
||||
// WriteIdentityAudit persists identity/security events synchronously. It never
|
||||
// records request bodies, credentials, tokens, captcha answers, or cookies.
|
||||
func WriteIdentityAudit(c *gin.Context, status string, msg string, username string) {
|
||||
log := api.GetRequestLogger(c)
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
log.Errorf("identity audit database unavailable: %s", err.Error())
|
||||
return
|
||||
}
|
||||
log := api.GetRequestLogger(c)
|
||||
l := make(map[string]interface{})
|
||||
|
||||
ua := user_agent.New(c.Request.UserAgent())
|
||||
l["ipaddr"] = common.GetClientIP(c)
|
||||
l["loginLocation"] = "" // pkg.GetLocation(common.GetClientIP(c),gaConfig.ExtConfig.AMap.Key)
|
||||
l["loginTime"] = pkg.GetCurrentTime()
|
||||
l["status"] = status
|
||||
l["remark"] = c.Request.UserAgent()
|
||||
browserName, browserVersion := ua.Browser()
|
||||
l["browser"] = browserName + " " + browserVersion
|
||||
l["os"] = ua.OS()
|
||||
l["platform"] = ua.Platform()
|
||||
l["username"] = username
|
||||
l["msg"] = msg
|
||||
|
||||
q := sdk.Runtime.GetMemoryQueue(c.Request.Host)
|
||||
message, err := sdk.Runtime.GetStreamMessage("", global.LoginLog, l)
|
||||
if err != nil {
|
||||
log.Errorf("GetStreamMessage error, %s", err.Error())
|
||||
//日志报错错误,不中断请求
|
||||
} else {
|
||||
err = q.Append(message)
|
||||
if err != nil {
|
||||
log.Errorf("Append message error, %s", err.Error())
|
||||
}
|
||||
l := models.SysLoginLog{
|
||||
Username: truncateAuditText(username, 128),
|
||||
Status: status,
|
||||
Ipaddr: truncateAuditText(common.GetClientIP(c), 255),
|
||||
LoginLocation: "",
|
||||
Browser: truncateAuditText(browserName+" "+browserVersion, 255),
|
||||
Os: truncateAuditText(ua.OS(), 255),
|
||||
Platform: truncateAuditText(ua.Platform(), 255),
|
||||
LoginTime: pkg.GetCurrentTime(),
|
||||
Remark: truncateAuditText(c.Request.Method+" "+c.FullPath(), 255),
|
||||
Msg: truncateAuditText(msg, 255),
|
||||
}
|
||||
if err = db.Create(&l).Error; err != nil {
|
||||
log.Errorf("identity audit write failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func truncateAuditText(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
// LogOut
|
||||
@@ -164,7 +170,7 @@ func Authorizator(data interface{}, c *gin.Context) bool {
|
||||
if v, ok := data.(map[string]interface{}); ok {
|
||||
u, _ := v["user"].(models.SysUser)
|
||||
r, _ := v["role"].(models.SysRole)
|
||||
c.Set("role", r.RoleName)
|
||||
c.Set("role", r.RoleKey)
|
||||
c.Set("roleIds", r.RoleId)
|
||||
c.Set("userId", u.UserId)
|
||||
c.Set("userName", u.Username)
|
||||
@@ -175,6 +181,9 @@ func Authorizator(data interface{}, c *gin.Context) bool {
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, code int, message string) {
|
||||
if c.FullPath() != "/api/v1/login" {
|
||||
WriteIdentityAudit(c, "1", "未认证访问被拒绝", "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": message,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateAuditText(t *testing.T) {
|
||||
if got := truncateAuditText("中文审计记录", 3); got != "中文审" {
|
||||
t.Fatalf("unexpected truncation: %q", got)
|
||||
}
|
||||
if got := truncateAuditText("short", 10); got != "short" {
|
||||
t.Fatalf("unexpected unchanged value: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -95,11 +95,54 @@ func LoggerToFile() gin.HandlerFunc {
|
||||
log.Fields(map[string]interface{}{})
|
||||
}()
|
||||
if c.Request.Method != "OPTIONS" && config.LoggerConfig.EnabledDB && statusCode != 404 {
|
||||
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, statusBus)
|
||||
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, sanitizeAuditJSON(body), sanitizeAuditJSON(result), statusBus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAuditJSON(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
return "[内容已隐藏]"
|
||||
}
|
||||
redactAuditValue(decoded)
|
||||
encoded, err := json.Marshal(decoded)
|
||||
if err != nil {
|
||||
return "[内容已隐藏]"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func redactAuditValue(value interface{}) {
|
||||
switch current := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, item := range current {
|
||||
if isSensitiveAuditKey(key) {
|
||||
current[key] = "[已隐藏]"
|
||||
continue
|
||||
}
|
||||
redactAuditValue(item)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range current {
|
||||
redactAuditValue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveAuditKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||
switch normalized {
|
||||
case "password", "oldpassword", "newpassword", "token", "refreshtoken", "secret", "code", "uuid":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
|
||||
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeAuditJSON(t *testing.T) {
|
||||
input := `{"username":"operator","password":"secret-value","nested":{"newPassword":"another-secret"},"token":"jwt-value"}`
|
||||
got := sanitizeAuditJSON(input)
|
||||
for _, secret := range []string{"secret-value", "another-secret", "jwt-value"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sensitive value leaked: %s", got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "operator") {
|
||||
t.Fatalf("non-sensitive context was unexpectedly removed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
|
||||
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
|
||||
t.Fatalf("unstructured body leaked: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/casbin/casbin/v2/util"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
@@ -49,6 +50,7 @@ func AuthCheckRole() gin.HandlerFunc {
|
||||
c.Next()
|
||||
} else {
|
||||
log.Warnf("isTrue: %v role: %s method: %s path: %s message: %s", res, v["rolekey"], c.Request.Method, c.Request.URL.Path, "当前request无权限,请管理员确认!")
|
||||
handler.WriteIdentityAudit(c, "1", "拒绝访问 "+c.Request.Method+" "+c.FullPath(), userNameFromClaims(v))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 403,
|
||||
"msg": "对不起,您没有该接口访问权限,请联系管理员",
|
||||
@@ -59,3 +61,10 @@ func AuthCheckRole() gin.HandlerFunc {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func userNameFromClaims(claims jwtauth.MapClaims) string {
|
||||
if value, ok := claims["nice"].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,38 +6,4 @@ type UrlInfo struct {
|
||||
}
|
||||
|
||||
// CasbinExclude casbin 排除的路由列表
|
||||
var CasbinExclude = []UrlInfo{
|
||||
{Url: "/api/v1/dict/type-option-select", Method: "GET"},
|
||||
{Url: "/api/v1/dict-data/option-select", Method: "GET"},
|
||||
{Url: "/api/v1/deptTree", Method: "GET"},
|
||||
{Url: "/api/v1/db/tables/page", Method: "GET"},
|
||||
{Url: "/api/v1/db/columns/page", Method: "GET"},
|
||||
{Url: "/api/v1/gen/toproject/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/todb/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/tabletree", Method: "GET"},
|
||||
{Url: "/api/v1/gen/preview/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/apitofile/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/getCaptcha", Method: "GET"},
|
||||
{Url: "/api/v1/getinfo", Method: "GET"},
|
||||
{Url: "/api/v1/menuTreeselect", Method: "GET"},
|
||||
{Url: "/api/v1/menurole", Method: "GET"},
|
||||
{Url: "/api/v1/menuids", Method: "GET"},
|
||||
{Url: "/api/v1/roleMenuTreeselect/:roleId", Method: "GET"},
|
||||
{Url: "/api/v1/roleDeptTreeselect/:roleId", Method: "GET"},
|
||||
{Url: "/api/v1/refresh_token", Method: "GET"},
|
||||
{Url: "/api/v1/configKey/:configKey", Method: "GET"},
|
||||
{Url: "/api/v1/app-config", Method: "GET"},
|
||||
{Url: "/api/v1/user/profile", Method: "GET"},
|
||||
{Url: "/info", Method: "GET"},
|
||||
{Url: "/api/v1/login", Method: "POST"},
|
||||
{Url: "/api/v1/logout", Method: "POST"},
|
||||
{Url: "/api/v1/user/avatar", Method: "POST"},
|
||||
{Url: "/api/v1/user/pwd", Method: "PUT"},
|
||||
{Url: "/api/v1/metrics", Method: "GET"},
|
||||
{Url: "/api/v1/health", Method: "GET"},
|
||||
{Url: "/", Method: "GET"},
|
||||
{Url: "/api/v1/server-monitor", Method: "GET"},
|
||||
{Url: "/api/v1/public/uploadFile", Method: "POST"},
|
||||
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
|
||||
{Url: "/api/v1/sys-user", Method: "PUT"},
|
||||
}
|
||||
var CasbinExclude = []UrlInfo{}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# 首次创建管理员时临时设置为至少 32 个字符的随机值。
|
||||
# 初始化成功后应从运行环境移除;不要把真实值写入本文件或仓库。
|
||||
SENSE_BOOTSTRAP_TOKEN=
|
||||
@@ -19,7 +19,7 @@ settings:
|
||||
stdout: '' #控制台日志,启用后,不输出到文件
|
||||
# 日志等级, trace, debug, info, warn, error, fatal
|
||||
level: info
|
||||
# 数据库日志开关
|
||||
# 通用操作数据库日志开关;登录、退出、密码和拒绝访问等身份审计始终写入数据库。
|
||||
enableddb: false
|
||||
jwt:
|
||||
# 必填。生产环境至少 32 个字符;不得提交真实值。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
const TokenKey = 'Admin-Token'
|
||||
const TokenKey = 'Sense-Admin-Token'
|
||||
|
||||
export function getToken() {
|
||||
return Cookies.get(TokenKey)
|
||||
|
||||
@@ -353,8 +353,6 @@ export default {
|
||||
open: false,
|
||||
// 部门名称
|
||||
deptName: undefined,
|
||||
// 默认密码
|
||||
initPassword: undefined,
|
||||
// 日期范围
|
||||
dateRange: [],
|
||||
// 状态数据字典
|
||||
@@ -400,7 +398,10 @@ export default {
|
||||
username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
|
||||
nickName: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }],
|
||||
deptId: [{ required: true, message: '归属部门不能为空', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '用户密码不能为空', trigger: 'blur' },
|
||||
{ min: 6, max: 72, message: '密码长度为 6 至 72 个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '邮箱地址不能为空', trigger: 'blur' },
|
||||
{ type: 'email', message: "'请输入正确的邮箱地址", trigger: ['blur', 'change'] }
|
||||
@@ -427,9 +428,6 @@ export default {
|
||||
this.getDicts('sys_user_sex').then(response => {
|
||||
this.sexOptions = response.data
|
||||
})
|
||||
this.getConfigKey('sys_user_initPassword').then(response => {
|
||||
this.initPassword = response.data.configValue
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
/** 查询用户列表 */
|
||||
@@ -556,7 +554,6 @@ export default {
|
||||
})
|
||||
this.open = true
|
||||
this.title = '添加用户'
|
||||
this.form.password = this.initPassword
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
@@ -580,7 +577,9 @@ export default {
|
||||
handleResetPwd(row) {
|
||||
this.$prompt('请输入"' + row.username + '"的新密码', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /^.{6,72}$/,
|
||||
inputErrorMessage: '密码长度为 6 至 72 个字符'
|
||||
}).then(({ value }) => {
|
||||
resetUserPwd(row.userId, value).then(response => {
|
||||
if (response.code === 200) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 7619efaad2d1e5567ceb9474b8a059c48f34973e
|
||||
synchronized_at: 2026-08-14T03:13:56Z
|
||||
wiki_revision: eb1174040657f2064e26b6448ca92e7a93666423
|
||||
synchronized_at: 2026-08-14T04:09:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -83,7 +83,11 @@ Sense/Brain 生成事件
|
||||
<!-- sense-runtime:start -->
|
||||
## Sense 新代码入口
|
||||
|
||||
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。初始可见模块仅保留用户、角色、菜单和登录/操作审计;上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。仓库不提供默认账号、默认密码或可用 JWT 密钥。
|
||||
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。
|
||||
|
||||
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
|
||||
|
||||
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 85ddfe99c2f9c97b18d5e39849a50b8c5fca276e
|
||||
synchronized_at: 2026-08-14T01:17:28Z
|
||||
wiki_revision: 53a9c59534560841cd65fec74492ac3a55ffebe1
|
||||
synchronized_at: 2026-08-14T04:09:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -60,13 +60,22 @@ synchronized_at: 2026-08-14T01:17:28Z
|
||||
|
||||
- GoAdmin/go-admin-ui 的固定上游 commit、许可证记录和裁剪模块;
|
||||
- Go、Node、Python、CUDA、PostgreSQL、MediaMTX 的精确版本;
|
||||
- Sense/Bell 数据库 schema、Cookie 名称、部署域名和备份命令;
|
||||
- Bell 数据库 schema、Sense/Bell 部署域名和备份命令;Sense 浏览器 Cookie 与后端会话名称已由工单 #64 固定;
|
||||
- Brain 模型、推理 FPS、硬件容量和商用许可;
|
||||
- 试点最终留存期、通知供应商和验收阈值;
|
||||
- 三个交付单元的正式版本与发布流程。
|
||||
|
||||
未确认项不得从旧代码或模板默认值推断为当前事实。
|
||||
|
||||
## Sense 身份与权限规则
|
||||
|
||||
- Sense 仅使用自己的用户库、JWT 密钥和会话;浏览器 Cookie 为 `Sense-Admin-Token`,后端可接受的独立会话 Cookie 为 `sense_session`,不得与 Bell 共享。
|
||||
- 当前身份管理固定四个角色:`admin`(系统管理员)、`implementation_operator`(实施/运维)、`site_admin`(站点管理员)、`viewer`(只读用户)。业务设备权限由后续设备工单在这些角色上扩展,不复制新的身份事实源。
|
||||
- 仓库和迁移不创建默认账户或默认密码。首位管理员只能通过仓库外的 `SENSE_BOOTSTRAP_TOKEN` 一次性创建;令牌至少 32 个字符,初始化完成后必须从进程环境移除,已有任一用户时接口拒绝再次初始化。
|
||||
- Sense 密码至少 6 个字符、最多 72 字节,不要求大小写、数字或符号组合,允许全小写;交付时仍应使用难猜且不复用的密码。
|
||||
- 登录成功/失败、登出、密码变更和鉴权拒绝必须留下身份审计;密码、令牌、Cookie、验证码、数据库连接和摄像头凭据不得进入审计正文。
|
||||
- `site_admin` 可在账户维护流程中读取角色、部门、岗位和字典等必要支撑数据,但不能修改角色、菜单或系统配置;`implementation_operator` 与 `viewer` 不具备账户管理权限。未注册的配置和接口管理路由对所有角色返回 404。
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
## Sense 旧 MVP 规则状态
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: e97ea48e8028118b2dcdf4e5fb0199d2ad7e824e
|
||||
synchronized_at: 2026-08-14T03:14:04Z
|
||||
wiki_revision: 9007f0ff45178b7535c2a38b6e38d44628c3210d
|
||||
synchronized_at: 2026-08-14T04:09:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -114,7 +114,7 @@ pnpm --version
|
||||
<!-- sense-runtime:start -->
|
||||
## Sense 本地构建与验证
|
||||
|
||||
Sense 后端与前端已由工单 #61 从冻结源码建立。先确认 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,再执行:
|
||||
Sense 后端与前端已从冻结源码建立。先确认 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,再执行:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
@@ -129,7 +129,28 @@ corepack pnpm@9.15.1 test:unit
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
数据库固定为 PostgreSQL。复制 `Sense/server/config/settings.yml` 到仓库外,填写连接串和至少 32 个字符的随机 JWT 密钥;仓库模板本身不可直接启动生产服务,也不包含默认管理员。迁移和启动命令分别为 `sense migrate -c <配置路径>` 与 `sense server -c <配置路径>`。详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`.
|
||||
数据库固定为 PostgreSQL。复制 `Sense/server/config/settings.yml` 到仓库外,填写连接串和至少 32 个字符的随机 JWT 密钥;仓库模板本身不可直接启动生产服务。首次运行先执行 `sense migrate -c <配置路径>`,再通过只存在于当前进程的高熵令牌启动服务并创建首位管理员:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_BOOTSTRAP_TOKEN = Read-Host "输入至少 32 个字符的一次性初始化令牌"
|
||||
sense server -c <配置路径>
|
||||
|
||||
# 在另一个 PowerShell 中输入相同令牌;不要把真实值写入脚本或命令历史
|
||||
$bootstrapToken = Read-Host "输入一次性初始化令牌"
|
||||
$bootstrapBody = @{
|
||||
username = "admin"
|
||||
password = Read-Host "输入至少 6 个字符的管理员密码"
|
||||
nickname = "系统管理员"
|
||||
} | ConvertTo-Json
|
||||
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap" -Headers @{ "X-Sense-Bootstrap-Token" = $bootstrapToken } -ContentType "application/json" -Body $bootstrapBody
|
||||
Remove-Variable bootstrapToken, bootstrapBody
|
||||
```
|
||||
|
||||
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
|
||||
|
||||
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
|
||||
|
||||
详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Documentation-Guide
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
|
||||
wiki_revision: d4f2bfcef46493361ba019839b36957ed9947003
|
||||
synchronized_at: 2026-08-14T01:18:58Z
|
||||
wiki_revision: c754ebd705c55329a46da12e1860ffd7d137f99a
|
||||
synchronized_at: 2026-08-14T04:09:55Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 交付文档指南
|
||||
@@ -84,12 +84,14 @@ synchronized_at: 2026-08-14T01:18:58Z
|
||||
|
||||
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
|
||||
|
||||
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名;仍应由管理员选择难猜且不复用的密码。
|
||||
- 实施/运维:添加设备、只写更新凭据、在获准网卡发现或手工接入、检查主辅码流、处理媒体进程与对账。
|
||||
- 站点管理员:查看/维护站点设备,实时监看,绘制和发布区域版本;无权创建系统管理员。
|
||||
- 只读用户:查看设备、媒体状态、实时画面和区域,不能修改。
|
||||
- 系统管理员:使用仓库外、至少 32 个字符的一次性初始化令牌创建首位管理员;系统已有用户后不得再次初始化。随后维护 Sense 用户、角色并查看登录和操作记录。仓库没有默认账号或默认密码。
|
||||
- 实施/运维:查看实施所需日志和字典支撑信息;设备接入、凭据更新、码流检查与媒体服务处置能力由后续业务工单逐步启用。
|
||||
- 站点管理员:维护普通账户并读取角色、部门、岗位和字典选项,但不能修改角色、菜单或系统配置;设备、实时监看和区域能力由后续业务工单逐步启用。
|
||||
- 只读用户:当前不能进入账户、权限或系统管理接口;后续只获得设备、媒体状态、实时画面和区域的查看权限。
|
||||
|
||||
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前自动化已覆盖内部状态和安全边界;真实 PostgreSQL、MediaMTX、摄像机兼容与目标浏览器画面仍需实施人员验证。
|
||||
所有 Sense 密码至少 6 个字符、最多 72 字节,可使用全小写,不强制字符组合;管理员仍应选择难猜、不复用的密码。登录成功/失败、登出、密码变更和权限拒绝会进入身份审计,交付检查必须确认审计不含密码、令牌、Cookie、验证码或摄像头凭据。
|
||||
|
||||
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前身份自动化已覆盖四角色权限矩阵和安全边界;真实生产 PostgreSQL、反向代理、目标浏览器和后续设备链路仍需实施人员按相应工单验证。
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-64-Sense登录RBAC与审计
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-64-Sense%E7%99%BB%E5%BD%95RBAC%E4%B8%8E%E5%AE%A1%E8%AE%A1.-
|
||||
wiki_revision: 6167ebdd6a1d8df9f085d7e2af3e3683e4f02bc7
|
||||
synchronized_at: 2026-08-14T06:23:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 64 Sense登录RBAC与审计
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8 / Sense MVP
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- 验收:2026-08-14,用户明确回复“#64 验收通过”
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/64
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/82
|
||||
- Wiki 页面:Task-64-Sense登录RBAC与审计
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
工单 #61 只建立了冻结 GoAdmin/go-admin-ui 产品骨架,没有提供可交付的首位管理员初始化、四角色权限边界和完整身份审计。本工单在该基线上重建 Sense 自有登录、用户、角色、菜单/按钮、会话与审计能力,使系统管理员、实施/运维、站点管理员和只读用户具有可自动验证的最小权限,并确保 Sense 不与 Bell 共享账户、JWT 或 Cookie。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 增加公开的 `POST /api/v1/bootstrap`,只接受请求头中的仓库外 `SENSE_BOOTSTRAP_TOKEN`;令牌至少 32 个字符,通过常量时间比较校验,数据库已有任一用户后永久拒绝再次初始化。仓库、迁移和配置模板不包含默认账户、默认密码或有效令牌。
|
||||
- PostgreSQL 迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色、菜单映射与 Casbin 最小策略。修复上游角色策略重复写入问题,确保迁移和角色创建可重复执行。
|
||||
- JWT realm 固定为 `Sense`;前端 Cookie 使用 `Sense-Admin-Token`,后端仅从 Authorization Bearer 或独立 `sense_session` Cookie 取令牌,不再接受查询参数令牌。
|
||||
- 密码策略统一为 6–72 字节,不强制大小写、数字或符号组合,允许全小写;用户创建、管理员重置和本人改密共用同一规则,并修复上游改密重复哈希问题。
|
||||
- 登录成功/失败、登出、密码变更和未登录/无权限拒绝同步写入身份审计。通用操作审计递归清除密码、令牌、Cookie、验证码、secret 等字段,对无法结构化的正文不做原文记录。
|
||||
- 继续复用 go-admin-ui 的动态菜单、Store、Axios、Layout、权限指令和 Element Plus 通用组件;没有新增通用 UI 组件。配置、接口管理等无关管理路由不注册,即使管理员直接访问也返回 404。
|
||||
- 相对建单方案的范围校正:上游迁移真实入口位于 `Sense/server/cmd/migrate/migration/version/`,前端 Cookie 封装位于 `Sense/ui/src/utils/auth.js`;两项均已在工单中先行记录,目标和验收标准未改变。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/admin/apis/`、`router/`、`middleware/`:一次性初始化、认证入口、路由保护和身份审计。
|
||||
- `Sense/server/app/admin/models/`、`service/`、`dto/`:四角色策略、密码规则、改密与审计脱敏。
|
||||
- `Sense/server/cmd/migrate/migration/version/*identity*`:角色、按钮权限、菜单映射与 Casbin 策略迁移。
|
||||
- `Sense/server/config/`:独立 JWT/Cookie 安全配置与空白初始化令牌示例。
|
||||
- `Sense/ui/src/api/login.js`、`store/`、`utils/auth.js`、用户/角色页面:Sense 会话名称、角色标识、密码校验和移除默认初始密码。
|
||||
- 架构、业务规则、本地验证和交付指南 Wiki:身份边界、初始化步骤、角色操作路径与审计要求。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| Sense 可独立完成首位管理员初始化、登录、登出和密码变更,无默认账户或密码 | 通过 |
|
||||
| admin、implementation_operator、site_admin、viewer 四角色菜单、按钮和直接 API 权限符合最小边界 | 通过 |
|
||||
| 密码至少 6 个字符并允许全小写,过短密码被一致拒绝 | 通过 |
|
||||
| 登录成功/失败、登出、改密和权限拒绝可审计且不记录秘密 | 通过 |
|
||||
| Sense JWT、Cookie、账户库不与 Bell 共享,查询参数令牌被禁用 | 通过 |
|
||||
| 无关 GoAdmin 模块没有可见入口或可直接调用的后端路由 | 通过 |
|
||||
| 复用 go-admin-ui 现有外壳、权限和通用组件,没有重复新增组件 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- Go 工具链:`go version go1.26.5 windows/amd64`。
|
||||
- 后端:`go test ./...`、`go vet ./...`、`go build ./...` 全通过;新增密码策略、初始化输入/令牌、审计截断和审计脱敏单元测试。
|
||||
- 前端:`corepack pnpm@9.15.1 install --frozen-lockfile` 通过;lint 0 error,保留 30 条冻结上游 warning;7 suites / 29 tests 全通过;生产构建通过,保留 6 条冻结上游兼容/体积 warning。
|
||||
- PostgreSQL 17 隔离集成:新库迁移后用户 0、角色 4、策略 15;错误初始化令牌 403、正确令牌 200、二次初始化 403;验证码接口 200;未登录直接访问用户接口 401 且有审计。
|
||||
- 四角色矩阵:admin 可访问账户和审计;implementation_operator 可查看日志/字典但用户、部门、岗位、角色接口 403;site_admin 可维护用户并读取角色、部门、岗位、字典但日志 403;viewer 上述管理接口均 403。
|
||||
- 安全回归:配置/接口管理路由对管理员与站点管理员均 404;短密码返回 400,6 位全小写密码改密及重新登录成功;登录、登出、改密和拒绝审计中没有测试密码或令牌。
|
||||
- Wiki 镜像:`python dev_scripts/sync_wiki_docs.py --check` 通过。
|
||||
- **未验证部分**:未在真实生产 PostgreSQL、反向代理和目标浏览器执行人工 UI 验收;真实设备业务权限由后续 Sense 工单覆盖。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 冻结 go-admin-ui 上游仍有 30 条 lint warning 和 6 条构建 warning,本工单不扩大范围清理。
|
||||
- 设备、视频接入、媒体服务、实时监看和区域业务权限由后续独立工单在四角色基础上扩展。
|
||||
- 登出沿用 GoAdmin 的无状态 JWT 语义,由客户端删除令牌;本工单未增加令牌黑名单或服务端即时吊销机制。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `0411aa3` feat: 重建 Sense 认证权限与审计 (#64)
|
||||
- `1dd29b6` docs: 记录 Sense 身份权限规则 (#64)
|
||||
- Pull Request:#82
|
||||
@@ -103,6 +103,10 @@
|
||||
{
|
||||
"page": "Task-61-Sense冻结GoAdmin源码产品骨架",
|
||||
"path": "docs/task/61-Sense冻结GoAdmin源码产品骨架.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-64-Sense登录RBAC与审计",
|
||||
"path": "docs/task/64-Sense登录RBAC与审计.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user