package admin import ( "context" "fmt" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" "github.com/qianfree/team-api/internal/model/entity" "github.com/qianfree/team-api/internal/utility/crypto" ) // NOTE: ctxUserID and ctxSessionID have been removed. // Use common.GetCtxUserID(ctx) and common.GetCtxSessionID(ctx) instead. // Login handles admin login. func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error) { req.Username = strings.TrimSpace(req.Username) // Check captcha if required if err := common.CheckCaptchaRequired(ctx, "admin_login", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err } // Get client info early for login history recording ipAddress := g.RequestFromCtx(ctx).GetClientIp() ua := g.RequestFromCtx(ctx).Header.Get("User-Agent") deviceFP := common.DeviceFingerprint(ua, ipAddress) // Find admin user by username var user *entity.SysAdminUsers err := dao.SysAdminUsers.Ctx(ctx). Where("username", req.Username). Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if user == nil { _ = common.RecordLoginHistory(ctx, "admin", 0, 0, "password", ipAddress, ua, deviceFP, false, "用户不存在") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } // Check status if user.Status != "active" { _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "账号已禁用") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } // Check account lockout if user.LockedUntil != nil { if time.Now().Before(user.LockedUntil.Time) { // 仍在锁定期内 remaining := time.Until(user.LockedUntil.Time).Minutes() _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "账号已锁定") return nil, common.NewBusinessError(consts.CodeAccountLocked, fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) } // 锁定已过期:重置失败计数,给用户重新 5 次机会(方案A) _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). Data(map[string]interface{}{ "failed_attempts": 0, "locked_until": nil, }).Update() if err != nil { g.Log().Errorf(ctx, "重置管理员锁定状态失败: %v", err) } // 同步更新内存对象,避免后续密码校验仍读到旧的失败计数 user.FailedAttempts = 0 user.LockedUntil = nil } // Verify password if !crypto.VerifyPassword(req.Password, user.PasswordHash) { _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "密码错误") // 原子递增失败次数;锁定阈值与时长取自安全配置(sys_options) updateData := do.SysAdminUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")} failedAttempts := user.FailedAttempts + 1 maxAttempts, lockoutMinutes := common.LoginLockoutPolicy(ctx) if failedAttempts >= maxAttempts { lockedUntil := time.Now().Add(time.Duration(lockoutMinutes) * time.Minute) updateData.LockedUntil = gtime.NewFromTime(lockedUntil) _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). Data(updateData).Update() if err != nil { g.Log().Errorf(ctx, "更新管理员账号锁定状态失败: %v", err) } return nil, common.NewBusinessError(consts.CodeAccountLocked, fmt.Sprintf("连续 %d 次密码错误,账号已锁定 %d 分钟", maxAttempts, lockoutMinutes)) } _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). Data(updateData).Update() if err != nil { g.Log().Errorf(ctx, "更新管理员登录失败次数失败: %v", err) } return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } // Reset failed attempts on successful login if user.FailedAttempts > 0 { _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). Data(map[string]interface{}{ "failed_attempts": 0, "locked_until": nil, }).Update() if err != nil { g.Log().Errorf(ctx, "重置管理员登录失败次数失败: %v", err) } } // Check if 2FA is enabled if user.TotpEnabled { provisionalToken, err := common.GenerateProvisionalToken(ctx, user.Id, "admin", user.Role, 0) if err != nil { return nil, err } // Record login attempt (password passed, awaiting 2FA) _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, true, "") res := &v1.AdminLoginRes{ TotpRequired: true, ProvisionalToken: provisionalToken, } res.User.ID = user.Id res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role return res, nil } // Generate refresh token refreshToken, err := common.GenerateRefreshToken() if err != nil { return nil, err } refreshTokenHash := common.HashRefreshToken(refreshToken) deviceInfo := common.ExtractDeviceInfo(ctx) // Create session with jti jti := common.GenerateJti() sessionID, err := common.CreateSession(ctx, "admin", user.Id, 0, refreshTokenHash, ipAddress, deviceInfo, jti) if err != nil { return nil, gerror.Wrapf(err, "create session") } refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) // Generate token pair tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti) if err != nil { return nil, err } // Update last login (non-critical, log errors only) if _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). Data(do.SysAdminUsers{ LastLoginAt: gtime.Now(), LastLoginIp: ipAddress, }).Update(); err != nil { g.Log().Warningf(ctx, "update last_login for user %d: %v", user.Id, err) } // Record login history _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, true, "") // Publish login event common.Publish(ctx, &common.Event{ Type: "admin.login", TenantID: 0, UserID: user.Id, Payload: map[string]any{ "username": user.Username, "ip_address": ipAddress, }, }) res := &v1.AdminLoginRes{ AccessToken: tokenPair.AccessToken, RefreshToken: refreshToken, ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339), } res.User.ID = user.Id res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role) // 检查待接受协议 if common.Config().GetBool(ctx, "agreement_enabled") { if pending, err := common.GetPendingAgreements(ctx, "admin", user.Id); err == nil && len(pending) > 0 { items := make([]*v1.LoginPendingAgreement, 0, len(pending)) for _, a := range pending { items = append(items, &v1.LoginPendingAgreement{ Id: a.Id, Code: a.Code, Title: a.Title, Version: a.Version, }) } res.PendingAgreements = items } } return res, nil } // Logout handles admin logout. func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLogoutRes, error) { jti := common.GetCtxJti(ctx) sessionID := common.GetCtxSessionID(ctx) // Mark session as revoked in Redis for instant effect common.MarkSessionRevoked(ctx, jti) // Delete session from database err := common.RevokeSession(ctx, "admin", sessionID) if err != nil { return nil, err } return nil, nil } // Refresh handles token refresh. func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) { // 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销 session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken) if err != nil { return nil, common.NewUnauthorizedError("会话不存在") } if replayed { return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录") } if session == nil { return nil, common.NewUnauthorizedError("会话已过期或不存在") } if session.UserType != "admin" { return nil, common.NewUnauthorizedError("令牌类型不匹配") } // Check if session is revoked if common.IsSessionRevoked(ctx, session.Jti) { return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked) } // Rotate session(新令牌内嵌会话 ID,供下次重放检测定位) ipAddress := g.RequestFromCtx(ctx).GetClientIp() deviceInfo := common.ExtractDeviceInfo(ctx) newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo) if err != nil { return nil, err } // Fetch current role from user table var adminUser *entity.SysAdminUsers err = dao.SysAdminUsers.Ctx(ctx).Where("id", session.UserId).Fields("role, status").Scan(&adminUser) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if adminUser == nil || adminUser.Status != "active" { return nil, common.NewUnauthorizedError("账号已被禁用或不存在") } // Generate new token pair with same session ID and jti tokenPair, err := common.GenerateTokenPair(ctx, session.UserId, session.UserType, adminUser.Role, 0, session.Id, session.Jti) if err != nil { return nil, err } return &v1.AdminRefreshRes{ AccessToken: tokenPair.AccessToken, RefreshToken: newRefreshToken, ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339), }, nil } // ListSessions returns active sessions for the current admin user. func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) (*v1.AdminSessionListRes, error) { currentSessionID := common.GetCtxSessionID(ctx) page, pageSize := common.NormalizePagination(req.Page, req.PageSize) q := dao.SysSessions.Ctx(ctx). Where("user_type", "admin"). Where("expires_at > NOW()") if req.Username != "" { userIds, err := adminUserIdsByUsername(ctx, req.Username) if err != nil { return nil, err } if len(userIds) == 0 { return &v1.AdminSessionListRes{List: []v1.AdminSessionItem{}, Total: 0, Page: page, PageSize: pageSize}, nil } q = q.WhereIn("user_id", userIds) } if req.IpAddress != "" { q = q.WhereLike("ip_address", "%"+common.EscapeLikePattern(req.IpAddress)+"%") } total, err := q.Count() if err != nil { return nil, err } var sessions []entity.SysSessions err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&sessions) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } idSet := make(map[int64]struct{}) for _, s := range sessions { idSet[s.UserId] = struct{}{} } userMap := make(map[int64]adminUserBrief) if len(idSet) > 0 { ids := make([]int64, 0, len(idSet)) for id := range idSet { ids = append(ids, id) } var users []entity.SysAdminUsers _ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name, role").WhereIn("id", ids).Scan(&users) for _, u := range users { userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName, Role: u.Role} } } items := make([]v1.AdminSessionItem, len(sessions)) for i, sess := range sessions { items[i] = v1.AdminSessionItem{ ID: sess.Id, UserId: sess.UserId, Username: userMap[sess.UserId].Username, DisplayName: userMap[sess.UserId].DisplayName, Role: userMap[sess.UserId].Role, IpAddress: sess.IpAddress, DeviceInfo: sess.DeviceInfo, IsCurrent: sess.Id == currentSessionID, } if sess.ExpiresAt != nil { items[i].ExpiresAt = sess.ExpiresAt.Format("Y-m-d H:i:s") } if sess.CreatedAt != nil { items[i].CreatedAt = sess.CreatedAt.Format("Y-m-d H:i:s") } } return &v1.AdminSessionListRes{ List: items, Total: total, Page: page, PageSize: pageSize, }, nil } // assertCanOperateSessions 校验当前调用者能否处置目标管理员账号的会话。 // // 超级管理员的登录态任何人都不能代为操作(包括其他超管):踢掉超管会话等同于 // 掐断平台最高权限入口,一旦下放就存在「借会话管理打击超管」的滥用路径。 // 唯一例外是账号本人管理自己的登录设备(等同在其他设备登出)。 // 查看不受限:会话列表对超管账号仍然展示,只是不可操作。 func assertCanOperateSessions(ctx context.Context, targetUserID int64) error { if targetUserID == 0 { return nil } var user *entity.SysAdminUsers err := dao.SysAdminUsers.Ctx(ctx).Where("id", targetUserID).Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return err } // 目标账号不存在或不是超管:无可保护对象,交由路由层 user:edit 把关 if user == nil || user.Role != "super_admin" { return nil } if common.GetCtxUserID(ctx) == targetUserID { return nil } return common.NewBusinessError(consts.CodeForbidden, "超级管理员的会话仅限本人管理,其他人只能查看") } // RevokeSession revokes a specific session. func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionReq) (*v1.AdminRevokeSessionRes, error) { // Look up session to get jti for Redis revocation sess, err := common.GetSessionByID(ctx, "admin", req.Id) if err != nil { return nil, err } if sess != nil { if err := assertCanOperateSessions(ctx, sess.UserId); err != nil { return nil, err } common.MarkSessionRevoked(ctx, sess.Jti) } err = common.RevokeSession(ctx, "admin", req.Id) if err != nil { return nil, err } return nil, nil } // ForceLogout revokes all sessions for a specific user. func (s *sAdmin) ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) { if err := assertCanOperateSessions(ctx, req.Id); err != nil { return nil, err } err := common.RevokeAllSessions(ctx, "admin", req.Id) if err != nil { return nil, err } return nil, nil } // ChangePassword handles admin password change. func (s *sAdmin) ChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (*v1.AdminChangePasswordRes, error) { userID := common.GetCtxUserID(ctx) var user *entity.SysAdminUsers err := dao.SysAdminUsers.Ctx(ctx). Where("id", userID). Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if user == nil { return nil, common.NewUnauthorizedError("账号已被禁用或不存在") } // Verify old password if !crypto.VerifyPassword(req.OldPassword, user.PasswordHash) { return nil, common.NewBusinessError(consts.CodeOldPasswordWrong, consts.MsgOldPasswordWrong) } // Validate new password policy if err := common.ValidatePassword(req.NewPassword); err != nil { return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak) } // Hash new password newHash, err := crypto.HashPassword(req.NewPassword) if err != nil { return nil, err } // Update password _, err = dao.SysAdminUsers.Ctx(ctx). Where("id", userID). Data(do.SysAdminUsers{ PasswordHash: newHash, }).Update() if err != nil { return nil, err } // Revoke all sessions (force re-login on all devices) common.RevokeAllSessions(ctx, "admin", userID) return nil, nil } // loadSessionPermissions 装载登录响应所需的有效权限与角色。 // // 权限查询失败不阻断登录:鉴权以服务端为准,前端权限集只影响菜单与按钮渲染。 // 此时返回空集合,用户会看到一个空菜单,刷新页面(走 /auth/me)即可恢复, // 好过因为一次查询抖动把人挡在登录页外。 func loadSessionPermissions(ctx context.Context, userID int64, role string) ([]string, []v1.AdminRoleBrief) { perms, err := GetEffectivePermissions(ctx, userID, role) if err != nil { g.Log().Warningf(ctx, "加载管理员 %d 有效权限失败: %v", userID, err) perms = []string{} } if perms == nil { perms = []string{} } roles := []v1.AdminRoleBrief{} // 超级管理员不通过角色获得权限,不查关联表 if role != "super_admin" { if briefs, err := GetUserRoleBriefs(ctx, userID); err != nil { g.Log().Warningf(ctx, "加载管理员 %d 角色失败: %v", userID, err) } else if briefs != nil { roles = briefs } } return perms, roles } // GetMe 返回当前登录用户的信息与有效权限。 // // 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限, // 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。 func (s *sAdmin) GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) { userID := common.GetCtxUserID(ctx) if userID == 0 { return nil, common.NewBusinessError(consts.CodeUnauthorized, consts.MsgUnauthorized) } var user *struct { Id int64 `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"` } err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if user == nil { return nil, common.NewNotFoundError("用户") } perms, roles := loadSessionPermissions(ctx, user.Id, user.Role) return &v1.AdminMeRes{ ID: user.Id, Username: user.Username, DisplayName: user.DisplayName, Email: user.Email, Role: user.Role, Permissions: perms, Roles: roles, }, nil }