Files
team-api/internal/logic/admin/auth.go
T

543 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}