450 lines
13 KiB
Go
450 lines
13 KiB
Go
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, "密码错误")
|
|
|
|
// 原子递增失败次数
|
|
updateData := do.SysAdminUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")}
|
|
failedAttempts := user.FailedAttempts + 1
|
|
|
|
if failedAttempts >= 5 {
|
|
lockedUntil := time.Now().Add(30 * 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,
|
|
"连续 5 次密码错误,账号已锁定 30 分钟")
|
|
}
|
|
|
|
_, 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")
|
|
}
|
|
|
|
// 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
|
|
|
|
// 检查待接受协议
|
|
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) {
|
|
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
|
|
|
|
// Find session by refresh token hash
|
|
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
// Generate new refresh token
|
|
newRefreshToken, err := common.GenerateRefreshToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
|
|
|
|
// Rotate session
|
|
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
|
deviceInfo := common.ExtractDeviceInfo(ctx)
|
|
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, 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").WhereIn("id", ids).Scan(&users)
|
|
for _, u := range users {
|
|
userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName}
|
|
}
|
|
}
|
|
|
|
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,
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
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) {
|
|
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
|
|
}
|