3d47227fbd
Remove embedded frontend and workflow editor assets, add standalone API deployment configuration, and retain the current backend service updates.
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.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
|
|
}
|