0b4a1b4594
- Updated OSSStorage validation errors to use internationalized messages. - Changed error messages in provider.go for unsupported file storage types. - Refactored tag_service.go to replace hardcoded error messages with internationalized versions. - Updated ticket_service.go to use internationalized error messages for various validation checks. - Refactored ticket_tag_service.go to use internationalized error messages for tag validation. - Changed ticket_view_service.go to use internationalized error messages for view validation. - Updated tool_catalog_service.go to use internationalized error messages for tool code validation. - Refactored user_service.go to replace error messages with internationalized versions. - Updated ws_service.go to use internationalized error messages for WebSocket handling. - Refactored wxwork_kf_inbound_service.go to use internationalized error messages for message handling. - Updated wxwork_kf_outbound_service.go to use internationalized error messages for outbound message handling. - Refactored wxwork_login_service.go to use internationalized error messages for login handling. - Updated login.go in wxwork package to use internationalized error messages for login state and ticket validation.
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/constants"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/dto/request"
|
|
"agent-desk/internal/pkg/dto/response"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/errorsx"
|
|
"agent-desk/internal/repositories"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/mlogclub/simple/common/strs"
|
|
"github.com/mlogclub/simple/sqls"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
authPrincipalContextKey = "authPrincipal"
|
|
)
|
|
|
|
var AuthService = newAuthService()
|
|
|
|
func newAuthService() *authService {
|
|
return &authService{}
|
|
}
|
|
|
|
type authService struct {
|
|
}
|
|
|
|
func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
v, _ := ctx.Get(authPrincipalContextKey)
|
|
if principal, ok := v.(*dto.AuthPrincipal); ok {
|
|
return principal
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal {
|
|
principal := &dto.AuthPrincipal{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Nickname: user.Nickname,
|
|
Avatar: user.Avatar,
|
|
Status: user.Status,
|
|
Roles: roles,
|
|
Permissions: permissions,
|
|
}
|
|
ctx.Set(authPrincipalContextKey, principal)
|
|
return principal
|
|
}
|
|
|
|
func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) {
|
|
if principal = s.GetAuthPrincipal(ctx); principal == nil {
|
|
if principal, err = s.Authenticate(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if principal == nil {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
if !s.HasPermission(ctx, permission.Code) {
|
|
return principal, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
|
username := strings.TrimSpace(req.Username)
|
|
principal := normalizeLoginPrincipal(username)
|
|
password := req.Password
|
|
if username == "" || strings.TrimSpace(password) == "" {
|
|
return nil, errorsx.InvalidParamI18n("error.e0258")
|
|
}
|
|
|
|
if s.isCredentialLocked(principal, authCfg) {
|
|
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "credential locked")
|
|
return nil, errorsx.CredentialLockedI18n("error.e0270")
|
|
}
|
|
|
|
user := UserService.GetByUsername(username)
|
|
if user == nil || user.Status != enums.StatusOk {
|
|
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found")
|
|
return nil, errorsx.InvalidAccountI18n("error.e0260")
|
|
}
|
|
if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
|
_ = s.createLoginCredentialLog(principal, user.ID, false, clientIP, userAgent, "password mismatch")
|
|
return nil, errorsx.InvalidAccountI18n("error.e0260")
|
|
}
|
|
|
|
var ret *response.LoginResponse
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
var dbErr error
|
|
ret, dbErr = s.issueTokens(ctx, user, clientIP, userAgent, authCfg)
|
|
if dbErr != nil {
|
|
return dbErr
|
|
}
|
|
if dbErr = repositories.UserRepository.Updates(ctx.Tx, user.ID, map[string]any{
|
|
"last_login_at": time.Now(),
|
|
"last_login_ip": clientIP,
|
|
"update_user_id": user.ID,
|
|
"update_user_name": user.Username,
|
|
"updated_at": time.Now(),
|
|
}); dbErr != nil {
|
|
return dbErr
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = s.createLoginCredentialLog(principal, user.ID, true, clientIP, userAgent, "")
|
|
return ret, nil
|
|
}
|
|
|
|
func (s *authService) Logout(accessToken string) error {
|
|
accessToken = s.extractBearerToken(accessToken)
|
|
now := time.Now()
|
|
if accessToken != "" {
|
|
if session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token", accessToken)); session != nil && session.RevokedAt == nil {
|
|
if err := LoginSessionService.Updates(session.ID, map[string]any{
|
|
"revoked_at": now,
|
|
"updated_at": now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error) {
|
|
if principal := s.GetAuthPrincipal(ctx); principal != nil {
|
|
return principal, nil
|
|
}
|
|
|
|
token := s.extractBearerToken(ctx.GetHeader("Authorization"))
|
|
if token == "" {
|
|
token = strings.TrimSpace(ctx.Query("accessToken"))
|
|
}
|
|
if token == "" {
|
|
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
|
}
|
|
|
|
session, err := s.validateSessionToken(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user := UserService.Get(session.UserID)
|
|
if user == nil || user.Status != enums.StatusOk {
|
|
return nil, errorsx.UnauthorizedI18n("error.e0256")
|
|
}
|
|
|
|
roles, permissions, err := s.loadUserAuthScope(sqls.DB(), user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
principal := s.setAuthPrincipal(ctx, user, roles, permissions)
|
|
|
|
now := time.Now()
|
|
_ = LoginSessionService.Updates(session.ID, map[string]any{
|
|
"last_seen_at": now,
|
|
"updated_at": now,
|
|
})
|
|
|
|
return principal, nil
|
|
}
|
|
|
|
func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool {
|
|
principal := s.GetAuthPrincipal(ctx)
|
|
if principal == nil {
|
|
return false
|
|
}
|
|
return slices.Contains(principal.Permissions, permissionCode)
|
|
}
|
|
|
|
func (s *authService) CurrentProfile(ctx *gin.Context) (*response.LoginResponse, error) {
|
|
principal, err := s.Authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &response.LoginResponse{
|
|
User: &response.AuthUserResponse{
|
|
ID: principal.UserID,
|
|
Username: principal.Username,
|
|
Nickname: principal.Nickname,
|
|
Avatar: principal.Avatar,
|
|
Status: principal.Status,
|
|
Roles: principal.Roles,
|
|
},
|
|
Permissions: principal.Permissions,
|
|
Roles: principal.Roles,
|
|
}, nil
|
|
}
|
|
|
|
func (s *authService) GetUserRoles(userID int64) ([]models.Role, error) {
|
|
return s.loadUserRoles(sqls.DB(), userID)
|
|
}
|
|
|
|
func (s *authService) GetUserPermissions(userID int64) ([]string, error) {
|
|
return s.loadUserPermissionCodes(sqls.DB(), userID)
|
|
}
|
|
|
|
func (s *authService) issueTokens(ctx *sqls.TxContext, user *models.User, clientIP, userAgent string, authCfg config.AuthConfig) (*response.LoginResponse, error) {
|
|
roles, permissions, err := s.loadUserAuthScope(ctx.Tx, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tokenTTL := s.resolveTokenTTL(authCfg)
|
|
accessToken, err := randomToken(constants.AuthTokenPrefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
if err := repositories.LoginSessionRepository.Create(ctx.Tx, &models.LoginSession{
|
|
UserID: user.ID,
|
|
Token: accessToken,
|
|
ClientType: constants.ClientTypeAdminWeb,
|
|
ClientIP: clientIP,
|
|
UserAgent: userAgent,
|
|
ExpiredAt: now.Add(tokenTTL),
|
|
LastSeenAt: &now,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &response.LoginResponse{
|
|
AccessToken: accessToken,
|
|
ExpiresAt: now.Add(tokenTTL).Format(time.DateTime),
|
|
User: &response.AuthUserResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Nickname: user.Nickname,
|
|
Avatar: user.Avatar,
|
|
Status: user.Status,
|
|
Roles: roles,
|
|
},
|
|
Permissions: permissions,
|
|
Roles: roles,
|
|
}, nil
|
|
}
|
|
|
|
func (s *authService) resolveTokenTTL(authCfg config.AuthConfig) time.Duration {
|
|
tokenTTL := 12 * time.Hour
|
|
if authCfg.TokenTTLHours > 0 {
|
|
tokenTTL = time.Duration(authCfg.TokenTTLHours) * time.Hour
|
|
}
|
|
return tokenTTL
|
|
}
|
|
|
|
func (s *authService) validateSessionToken(token string) (*models.LoginSession, error) {
|
|
if strings.TrimSpace(token) == "" {
|
|
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
|
}
|
|
session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token", token))
|
|
if session == nil {
|
|
return nil, errorsx.InvalidTokenI18n("error.e0269")
|
|
}
|
|
if session.RevokedAt != nil {
|
|
return nil, errorsx.InvalidTokenI18n("error.e0267")
|
|
}
|
|
if time.Now().After(session.ExpiredAt) {
|
|
return nil, errorsx.InvalidTokenI18n("error.e0268")
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (s *authService) loadUserAuthScope(tx *gorm.DB, userID int64) ([]string, []string, error) {
|
|
roleCodes, err := s.loadUserRoleCodes(tx, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
permissionCodes, err := s.loadUserPermissionCodes(tx, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return roleCodes, permissionCodes, nil
|
|
}
|
|
|
|
func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, error) {
|
|
roles, err := s.loadUserRoles(tx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
roleCodes := make([]string, 0, len(roles))
|
|
for _, role := range roles {
|
|
roleCodes = append(roleCodes, role.Code)
|
|
}
|
|
return roleCodes, nil
|
|
}
|
|
|
|
func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) {
|
|
roles := make([]models.Role, 0)
|
|
if err := tx.
|
|
Table("t_role AS r").
|
|
Select("r.*").
|
|
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
|
|
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
|
|
Order("r.sort_no ASC, r.id ASC").
|
|
Scan(&roles).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return roles, nil
|
|
}
|
|
|
|
func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) {
|
|
permissionRows := make([]struct {
|
|
Code string
|
|
}, 0)
|
|
db := tx.Table("t_permission AS p").
|
|
Select("DISTINCT p.code").
|
|
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
|
|
Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id").
|
|
Where("ur.user_id = ?", userID).
|
|
Where("p.status = ?", enums.StatusOk)
|
|
if err := db.Order("p.sort_no ASC, p.id ASC").Scan(&permissionRows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
permissionCodes := make([]string, 0, len(permissionRows))
|
|
for _, permission := range permissionRows {
|
|
permissionCodes = append(permissionCodes, permission.Code)
|
|
}
|
|
|
|
overrideRows := make([]struct {
|
|
Code string
|
|
Effect int
|
|
}, 0)
|
|
if err := tx.
|
|
Table("t_user_permission AS up").
|
|
Select("p.code, up.effect").
|
|
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
|
|
Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()).
|
|
Scan(&overrideRows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
permissionSet := make(map[string]bool, len(permissionCodes))
|
|
for _, code := range permissionCodes {
|
|
permissionSet[code] = true
|
|
}
|
|
for _, override := range overrideRows {
|
|
if override.Effect < 0 {
|
|
delete(permissionSet, override.Code)
|
|
continue
|
|
}
|
|
permissionSet[override.Code] = true
|
|
}
|
|
|
|
permissionCodes = permissionCodes[:0]
|
|
for code := range permissionSet {
|
|
permissionCodes = append(permissionCodes, code)
|
|
}
|
|
sort.Strings(permissionCodes)
|
|
return permissionCodes, nil
|
|
}
|
|
|
|
func (s *authService) extractBearerToken(header string) string {
|
|
header = strings.TrimSpace(header)
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
parts := strings.SplitN(header, " ", 2)
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
func (s *authService) createLoginCredentialLog(principal string, userID int64, success bool, clientIP, userAgent, reason string) error {
|
|
return LoginCredentialLogService.Create(&models.LoginCredentialLog{
|
|
Principal: principal,
|
|
UserID: userID,
|
|
Success: success,
|
|
ClientIP: clientIP,
|
|
UserAgent: userAgent,
|
|
Reason: reason,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
func (s *authService) isCredentialLocked(principal string, authCfg config.AuthConfig) bool {
|
|
maxFailedAttempts := authCfg.MaxFailedAttempts
|
|
if maxFailedAttempts <= 0 {
|
|
return false
|
|
}
|
|
lockMinute := authCfg.CredentialLockMinute
|
|
if lockMinute <= 0 {
|
|
lockMinute = 15
|
|
}
|
|
since := time.Now().Add(-time.Duration(lockMinute) * time.Minute)
|
|
return LoginCredentialLogService.Count(sqls.NewCnd().
|
|
Eq("principal", normalizeLoginPrincipal(principal)).
|
|
Eq("success", false).
|
|
NotEq("reason", "credential locked").
|
|
Where("created_at >= ?", since)) >= int64(maxFailedAttempts)
|
|
}
|
|
|
|
func normalizeLoginPrincipal(principal string) string {
|
|
return strings.ToLower(strings.TrimSpace(principal))
|
|
}
|
|
|
|
func randomToken(prefix string) (string, error) {
|
|
buf := make([]byte, 24)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return prefix + hex.EncodeToString(buf), nil
|
|
}
|