Files
junhong_cmp_fiber/internal/service/auth/service.go
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

530 lines
18 KiB
Go

package auth
import (
"context"
"sort"
"strconv"
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
securityAudit accountauditapp.Writer
accountStore *postgres.AccountStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
permissionStore *postgres.PermissionStore
shopStore *postgres.ShopStore
tokenManager *auth.TokenManager
logger *zap.Logger
}
// SetSecurityAudit 注入后台认证安全状态的统一审计接缝。
func (s *Service) SetSecurityAudit(db *gorm.DB, writer accountauditapp.Writer) {
s.db = db
s.securityAudit = writer
}
func New(
accountStore *postgres.AccountStore,
accountRoleStore *postgres.AccountRoleStore,
rolePermStore *postgres.RolePermissionStore,
permissionStore *postgres.PermissionStore,
shopStore *postgres.ShopStore,
tokenManager *auth.TokenManager,
logger *zap.Logger,
) *Service {
return &Service{
accountStore: accountStore,
accountRoleStore: accountRoleStore,
rolePermStore: rolePermStore,
permissionStore: permissionStore,
shopStore: shopStore,
tokenManager: tokenManager,
logger: logger,
}
}
func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
account, err := s.accountStore.GetByUsernameOrPhone(ctx, req.Username)
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("登录失败:用户名不存在", zap.String("username", req.Username), zap.String("ip", clientIP))
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
device := req.Device
if device == "" {
device = "web"
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.Password)); err != nil {
s.logger.Warn("登录失败:密码错误", zap.String("username", req.Username), zap.String("ip", clientIP))
appErr := errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
if account.Status != 1 {
s.logger.Warn("登录失败:账号已禁用", zap.String("username", req.Username), zap.Uint("user_id", account.ID))
appErr := errors.New(errors.CodeAccountDisabled, "账号已禁用")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
// 检查店铺状态(代理账号必须关联店铺且店铺必须启用)
if account.ShopID != nil && *account.ShopID > 0 {
shop, err := s.shopStore.GetByID(ctx, *account.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("登录失败:关联店铺不存在", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
appErr := errors.New(errors.CodeShopNotFound, "关联店铺不存在")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败")
}
if shop.Status != constants.StatusEnabled {
s.logger.Warn("登录失败:关联店铺已禁用", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
appErr := errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
}
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
}
if account.EnterpriseID != nil {
enterpriseID = *account.EnterpriseID
}
tokenInfo := &auth.TokenInfo{
UserID: account.ID,
UserType: account.UserType,
ShopID: shopID,
EnterpriseID: enterpriseID,
Username: account.Username,
Device: device,
IP: clientIP,
}
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, err
}
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogin, Summary: "后台账号登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":" + device,
Authentication: authenticationData(account.ID, device, "password", "authenticated"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogin, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
permissions, menus, buttons, err := s.getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)
if err != nil {
s.logger.Error("查询用户权限失败", zap.Uint("user_id", account.ID), zap.Error(err))
permissions = []string{}
menus = []dto.MenuNode{}
buttons = []string{}
}
userInfo := s.buildUserInfo(account)
s.logger.Info("用户登录成功",
zap.Uint("user_id", account.ID),
zap.String("username", account.Username),
zap.String("device", device),
zap.String("ip", clientIP),
)
return &dto.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(constants.DefaultAccessTokenTTL.Seconds()),
User: userInfo,
Permissions: permissions,
Menus: menus,
Buttons: buttons,
}, nil
}
func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string) error {
if err := s.tokenManager.RevokeToken(ctx, accessToken); err != nil {
if account := s.loadAuditAccount(ctx); account != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogout, "后台账号退出登录失败", constants.AuditResultFailed, "", err)
}
return err
}
if refreshToken != "" {
if err := s.tokenManager.RevokeToken(ctx, refreshToken); err != nil {
s.logger.Warn("撤销 refresh token 失败", zap.Error(err))
}
}
if account := s.loadAuditAccount(ctx); account != nil {
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogout, Summary: "后台账号退出登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":session",
Authentication: authenticationData(account.ID, "", "token", "revoked"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogout, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
}
return nil
}
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
return s.tokenManager.RefreshAccessToken(ctx, refreshToken)
}
func (s *Service) GetCurrentUser(ctx context.Context, userID uint) (*dto.UserInfo, []string, error) {
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
permissions, err := s.getUserPermissions(ctx, userID)
if err != nil {
s.logger.Error("查询用户权限失败", zap.Uint("user_id", userID), zap.Error(err))
permissions = []string{}
}
userInfo := s.buildUserInfo(account)
return &userInfo, permissions, nil
}
func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword, newPassword string) error {
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
appErr := errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "拒绝修改账号密码", constants.AuditResultDenied, "", appErr)
return appErr
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
}
if s.db == nil || s.securityAudit == nil {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewAccountStore(tx, nil).UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
return err
}
return s.securityAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAccountPasswordChanged, Summary: "修改账号密码", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, Account: account,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":password",
Authentication: authenticationData(account.ID, "", "password", "changed"),
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
AfterData: map[string]any{"credentials_configured": true},
})
}); err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "更新密码失败")
}
if err := s.tokenManager.RevokeAllUserTokens(ctx, userID); err != nil {
s.logger.Warn("撤销用户所有 token 失败", zap.Uint("user_id", userID), zap.Error(err))
}
s.logger.Info("用户修改密码成功", zap.Uint("user_id", userID))
return nil
}
func (s *Service) writeSecurityAudit(ctx context.Context, account *model.Account, audit accountauditapp.SecurityAudit) error {
if s.db == nil || s.securityAudit == nil || account == nil || account.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
audit.Account = account
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.securityAudit.WriteAccountSecurity(ctx, tx, audit)
})
}
func (s *Service) recordSecurityFailure(ctx context.Context, account *model.Account, actionCode, summary, result, device string, originalErr error) {
if account == nil || account.ID == 0 {
return
}
errorCode := strconv.Itoa(errors.CodeInternalError)
if appErr, ok := originalErr.(*errors.AppError); ok {
errorCode = strconv.Itoa(appErr.Code)
}
err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, ErrorSummary: summary,
ActorID: account.ID, ActorName: account.Username,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":security",
Authentication: authenticationData(account.ID, device, "password", result),
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, contextRequestID(ctx), contextRequestID(ctx), errorCode, err)
}
}
func (s *Service) loadAuditAccount(ctx context.Context) *model.Account {
userID := middleware.GetUserIDFromContext(ctx)
return s.loadAuditAccountByID(ctx, userID)
}
func (s *Service) loadAuditAccountByID(ctx context.Context, userID uint) *model.Account {
if userID == 0 || s.db == nil {
return nil
}
var account model.Account
if err := s.db.WithContext(ctx).Unscoped().First(&account, userID).Error; err != nil {
return nil
}
return &account
}
func authenticationData(accountID uint, device, method, state string) map[string]any {
return map[string]any{"account_id": accountID, "device": device, "auth_method": method, "state": state}
}
func contextRequestID(ctx context.Context) string {
value := middleware.GetRequestIDFromContext(ctx)
if value == nil {
return ""
}
return *value
}
func (s *Service) getUserPermissions(ctx context.Context, userID uint) ([]string, error) {
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询用户角色失败")
}
if len(accountRoles) == 0 {
return []string{}, nil
}
roleIDs := make([]uint, 0, len(accountRoles))
for _, ar := range accountRoles {
roleIDs = append(roleIDs, ar.RoleID)
}
permIDs, err := s.rolePermStore.GetPermIDsByRoleIDs(ctx, roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色权限失败")
}
if len(permIDs) == 0 {
return []string{}, nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询权限详情失败")
}
permCodes := make([]string, 0, len(permissions))
for _, perm := range permissions {
permCodes = append(permCodes, perm.PermCode)
}
return permCodes, nil
}
func (s *Service) buildUserInfo(account *model.Account) dto.UserInfo {
userTypeName := s.getUserTypeName(account.UserType)
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
}
if account.EnterpriseID != nil {
enterpriseID = *account.EnterpriseID
}
return dto.UserInfo{
ID: account.ID,
Username: account.Username,
Phone: account.Phone,
UserType: account.UserType,
UserTypeName: userTypeName,
ShopID: shopID,
EnterpriseID: enterpriseID,
}
}
func (s *Service) getUserTypeName(userType int) string {
switch userType {
case constants.UserTypeSuperAdmin:
return "超级管理员"
case constants.UserTypePlatform:
return "平台用户"
case constants.UserTypeAgent:
return "代理账号"
case constants.UserTypeEnterprise:
return "企业账号"
default:
return "未知"
}
}
func (s *Service) getUserPermissionsAndMenus(ctx context.Context, userID uint, userType int, device string) ([]string, []dto.MenuNode, []string, error) {
if userType == constants.UserTypeSuperAdmin {
return s.getAllPermissionsForSuperAdmin(ctx, device)
}
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询用户角色失败")
}
if len(accountRoles) == 0 {
return []string{}, []dto.MenuNode{}, []string{}, nil
}
roleIDs := make([]uint, 0, len(accountRoles))
for _, ar := range accountRoles {
roleIDs = append(roleIDs, ar.RoleID)
}
permIDs, err := s.rolePermStore.GetPermIDsByRoleIDs(ctx, roleIDs)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询角色权限失败")
}
if len(permIDs) == 0 {
return []string{}, []dto.MenuNode{}, []string{}, nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询权限详情失败")
}
return s.classifyPermissions(permissions, device)
}
func (s *Service) getAllPermissionsForSuperAdmin(ctx context.Context, device string) ([]string, []dto.MenuNode, []string, error) {
permissions, err := s.permissionStore.GetAll(ctx, nil, nil)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询所有权限失败")
}
return s.classifyPermissions(permissions, device)
}
func (s *Service) classifyPermissions(permissions []*model.Permission, device string) ([]string, []dto.MenuNode, []string, error) {
var menuPerms []*model.Permission
var buttonCodes []string
var allCodes []string
for _, perm := range permissions {
if perm.Status != constants.StatusEnabled {
continue
}
if perm.Platform != constants.PlatformAll && perm.Platform != device {
continue
}
allCodes = append(allCodes, perm.PermCode)
if perm.PermType == constants.PermissionTypeMenu {
menuPerms = append(menuPerms, perm)
} else if perm.PermType == constants.PermissionTypeButton {
buttonCodes = append(buttonCodes, perm.PermCode)
}
}
menuTree := s.buildMenuTree(menuPerms)
return allCodes, menuTree, buttonCodes, nil
}
func (s *Service) buildMenuTree(permissions []*model.Permission) []dto.MenuNode {
if len(permissions) == 0 {
return []dto.MenuNode{}
}
permMap := make(map[uint]*model.Permission)
for _, p := range permissions {
permMap[p.ID] = p
}
var roots []dto.MenuNode
for _, p := range permissions {
if p.ParentID == nil || *p.ParentID == 0 {
roots = append(roots, s.buildNode(p, permMap))
} else if _, ok := permMap[*p.ParentID]; !ok {
s.logger.Warn("检测到孤儿节点",
zap.Uint("child_id", p.ID),
zap.String("perm_code", p.PermCode),
zap.Uint("parent_id", *p.ParentID),
)
roots = append(roots, s.buildNode(p, permMap))
}
}
s.sortMenuNodes(roots)
return roots
}
func (s *Service) buildNode(perm *model.Permission, permMap map[uint]*model.Permission) dto.MenuNode {
node := dto.MenuNode{
ID: perm.ID,
PermCode: perm.PermCode,
Name: perm.PermName,
URL: perm.URL,
Sort: perm.Sort,
Children: []dto.MenuNode{},
}
for _, p := range permMap {
if p.ParentID != nil && *p.ParentID == perm.ID {
node.Children = append(node.Children, s.buildNode(p, permMap))
}
}
return node
}
func (s *Service) sortMenuNodes(nodes []dto.MenuNode) {
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Sort < nodes[j].Sort
})
for i := range nodes {
if len(nodes[i].Children) > 0 {
s.sortMenuNodes(nodes[i].Children)
}
}
}