469 lines
14 KiB
Go
469 lines
14 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Verify2FA handles the 2FA verification step during admin login.
|
|
func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.Admin2FAVerifyRes, error) {
|
|
// Parse provisional token
|
|
claims, err := common.ParseProvisionalToken(req.Provisional)
|
|
if err != nil {
|
|
return nil, common.NewBusinessError(consts.CodeUnauthorized, "临时令牌无效或已过期")
|
|
}
|
|
|
|
if claims.UserType != "admin" {
|
|
return nil, common.NewBusinessError(consts.CodeUnauthorized, "令牌类型不匹配")
|
|
}
|
|
|
|
// Verify 2FA code
|
|
valid, err := common.Verify2FACode(ctx, "admin", claims.UserID, req.Code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !valid {
|
|
return nil, common.NewBusinessError(consts.CodeTotpInvalid, consts.MsgTotpInvalid)
|
|
}
|
|
|
|
// Get user info for response
|
|
var user *entity.SysAdminUsers
|
|
err = dao.SysAdminUsers.Ctx(ctx).Where("id", claims.UserID).Scan(&user)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
|
}
|
|
if user.Status != "active" {
|
|
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
|
}
|
|
|
|
// Generate refresh token
|
|
refreshToken, err := common.GenerateRefreshToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refreshTokenHash := common.HashRefreshToken(refreshToken)
|
|
|
|
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
|
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
|
|
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
|
|
deviceFP := common.DeviceFingerprint(ua, ipAddress)
|
|
_ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "totp", ipAddress, ua, deviceFP, true, "")
|
|
|
|
res := &v1.Admin2FAVerifyRes{
|
|
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)
|
|
return res, nil
|
|
}
|
|
|
|
// Setup2FA starts the 2FA setup process for the current admin user.
|
|
func (s *sAdmin) Setup2FA(ctx context.Context, _ *v1.Admin2FASetupReq) (*v1.Admin2FASetupRes, error) {
|
|
userID := common.GetCtxUserID(ctx)
|
|
secret, uri, err := common.Setup2FA(ctx, "admin", userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Store secret in Redis temporarily (5 min TTL) for the enable step
|
|
encKey, err := getEncKey(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encrypted, err := crypto.EncryptString(encKey, secret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key := fmt.Sprintf("2fa:pending:admin:%d", userID)
|
|
_, err = g.Redis().Do(ctx, "SETEX", key, 300, encrypted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.Admin2FASetupRes{Secret: secret, URI: uri}, nil
|
|
}
|
|
|
|
// Enable2FA confirms and enables 2FA after verifying the code.
|
|
func (s *sAdmin) Enable2FA(ctx context.Context, req *v1.Admin2FAEnableReq) (*v1.Admin2FAEnableRes, error) {
|
|
userID := common.GetCtxUserID(ctx)
|
|
|
|
// Get pending secret from session/cache (stored temporarily during setup)
|
|
secret, err := getPendingTOTPSecret(ctx, userID)
|
|
if err != nil || secret == "" {
|
|
return nil, common.NewBusinessError(consts.CodeBadRequest, "请先执行2FA设置")
|
|
}
|
|
|
|
backupCodes, err := common.Enable2FA(ctx, "admin", userID, secret, req.Code, req.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Clear pending secret
|
|
clearPendingTOTPSecret(ctx, userID)
|
|
|
|
return &v1.Admin2FAEnableRes{BackupCodes: backupCodes}, nil
|
|
}
|
|
|
|
// Disable2FA disables 2FA for the current admin user.
|
|
func (s *sAdmin) Disable2FA(ctx context.Context, req *v1.Admin2FADisableReq) (*v1.Admin2FADisableRes, error) {
|
|
userID := common.GetCtxUserID(ctx)
|
|
err := common.Disable2FA(ctx, "admin", userID, req.Code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// RegenerateBackupCodes generates new backup codes.
|
|
func (s *sAdmin) RegenerateBackupCodes(ctx context.Context, req *v1.Admin2FARegenerateBackupCodesReq) (*v1.Admin2FARegenerateBackupCodesRes, error) {
|
|
userID := common.GetCtxUserID(ctx)
|
|
codes, err := common.RegenerateBackupCodes(ctx, "admin", userID, req.Code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &v1.Admin2FARegenerateBackupCodesRes{BackupCodes: codes}, nil
|
|
}
|
|
|
|
// ConfirmHighRisk generates a confirm token for high-risk operations.
|
|
func (s *sAdmin) ConfirmHighRisk(ctx context.Context, req *v1.Admin2FAConfirmReq) (*v1.Admin2FAConfirmRes, error) {
|
|
userID := common.GetCtxUserID(ctx)
|
|
token, err := common.ConfirmHighRisk(ctx, "admin", userID, req.Code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.Admin2FAConfirmRes{ConfirmToken: token}, nil
|
|
}
|
|
|
|
// LoginHistory returns the login history for all admin users with search filters.
|
|
func (s *sAdmin) LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (*v1.AdminLoginHistoryRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
q := common.AuditModelCtx(ctx, "aud_login_history").Where("user_type", "admin")
|
|
|
|
if req.Username != "" {
|
|
userIds, err := adminUserIdsByUsername(ctx, req.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(userIds) == 0 {
|
|
return &v1.AdminLoginHistoryRes{List: []v1.LoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
q = q.WhereIn("user_id", userIds)
|
|
}
|
|
if req.IpAddress != "" {
|
|
q = q.WhereLike("ip_address", "%"+req.IpAddress+"%")
|
|
}
|
|
if req.Success != nil {
|
|
q = q.Where("success", *req.Success)
|
|
}
|
|
if req.LoginMethod != "" {
|
|
q = q.Where("login_method", req.LoginMethod)
|
|
}
|
|
if req.StartTime != "" {
|
|
q = q.WhereGTE("created_at", req.StartTime+" 00:00:00")
|
|
}
|
|
if req.EndTime != "" {
|
|
q = q.WhereLTE("created_at", req.EndTime+" 23:59:59")
|
|
}
|
|
|
|
total, err := q.Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var records []entity.AudLoginHistory
|
|
err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
userMap := buildAdminUserMap(ctx, records)
|
|
items := make([]v1.LoginHistoryItem, len(records))
|
|
for i, r := range records {
|
|
items[i] = v1.LoginHistoryItem{
|
|
ID: r.Id,
|
|
UserId: r.UserId,
|
|
Username: userMap[r.UserId].Username,
|
|
DisplayName: userMap[r.UserId].DisplayName,
|
|
LoginMethod: r.LoginMethod,
|
|
IpAddress: r.IpAddress,
|
|
UserAgent: r.UserAgent,
|
|
Location: r.Location,
|
|
IsNewDevice: r.IsNewDevice,
|
|
Success: r.Success,
|
|
FailReason: r.FailReason,
|
|
}
|
|
if r.CreatedAt != nil {
|
|
items[i].CreatedAt = r.CreatedAt.Format("Y-m-d H:i:s")
|
|
}
|
|
}
|
|
|
|
return &v1.AdminLoginHistoryRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
type adminUserBrief struct {
|
|
Username string
|
|
DisplayName string
|
|
// Role 用于会话列表标记超管会话(前端隐藏撤销入口),其他场景可为空
|
|
Role string
|
|
}
|
|
|
|
func adminUserIdsByUsername(ctx context.Context, keyword string) ([]int64, error) {
|
|
var users []entity.SysAdminUsers
|
|
err := dao.SysAdminUsers.Ctx(ctx).
|
|
Fields("id").
|
|
WhereLike("username", "%"+common.EscapeLikePattern(keyword)+"%").
|
|
WhereOrLike("display_name", "%"+common.EscapeLikePattern(keyword)+"%").
|
|
Scan(&users)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, len(users))
|
|
for i, u := range users {
|
|
ids[i] = u.Id
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func buildAdminUserMap(ctx context.Context, records []entity.AudLoginHistory) map[int64]adminUserBrief {
|
|
m := make(map[int64]adminUserBrief)
|
|
idSet := make(map[int64]struct{})
|
|
for _, r := range records {
|
|
idSet[r.UserId] = struct{}{}
|
|
}
|
|
if len(idSet) == 0 {
|
|
return m
|
|
}
|
|
ids := make([]int64, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
var users []entity.SysAdminUsers
|
|
if err := dao.SysAdminUsers.Ctx(ctx).
|
|
Fields("id, username, display_name").
|
|
WhereIn("id", ids).
|
|
Scan(&users); err != nil {
|
|
g.Log().Warningf(ctx, "load admin users for session list: %v", err)
|
|
}
|
|
for _, u := range users {
|
|
m[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// TenantLoginHistory returns login history for tenant users (admin view).
|
|
func (s *sAdmin) TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
q := common.AuditModelCtx(ctx, "aud_login_history").Where("user_type", "tenant")
|
|
|
|
if req.TenantID > 0 {
|
|
q = q.Where("tenant_id", req.TenantID)
|
|
}
|
|
if req.Username != "" {
|
|
userIds, err := tenantUserIdsByKeyword(ctx, req.TenantID, req.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(userIds) == 0 {
|
|
return &v1.AdminTenantLoginHistoryRes{
|
|
List: []v1.AdminTenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
q = q.WhereIn("user_id", userIds)
|
|
}
|
|
if req.IpAddress != "" {
|
|
q = q.WhereLike("ip_address", "%"+req.IpAddress+"%")
|
|
}
|
|
if req.Success != nil {
|
|
q = q.Where("success", *req.Success)
|
|
}
|
|
if req.LoginMethod != "" {
|
|
q = q.Where("login_method", req.LoginMethod)
|
|
}
|
|
if req.StartTime != "" {
|
|
q = q.WhereGTE("created_at", req.StartTime+" 00:00:00")
|
|
}
|
|
if req.EndTime != "" {
|
|
q = q.WhereLTE("created_at", req.EndTime+" 23:59:59")
|
|
}
|
|
|
|
total, err := q.Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var records []entity.AudLoginHistory
|
|
err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
userMap := buildTenantUserMap(ctx, records)
|
|
items := make([]v1.AdminTenantLoginHistoryItem, len(records))
|
|
for i, r := range records {
|
|
items[i] = v1.AdminTenantLoginHistoryItem{
|
|
ID: r.Id,
|
|
UserId: r.UserId,
|
|
TenantId: r.TenantId,
|
|
Username: userMap[r.UserId].Username,
|
|
DisplayName: userMap[r.UserId].DisplayName,
|
|
LoginMethod: r.LoginMethod,
|
|
IpAddress: r.IpAddress,
|
|
UserAgent: r.UserAgent,
|
|
Location: r.Location,
|
|
IsNewDevice: r.IsNewDevice,
|
|
Success: r.Success,
|
|
FailReason: r.FailReason,
|
|
}
|
|
if r.CreatedAt != nil {
|
|
items[i].CreatedAt = r.CreatedAt.Format("Y-m-d H:i:s")
|
|
}
|
|
}
|
|
|
|
return &v1.AdminTenantLoginHistoryRes{
|
|
List: items, Total: total, Page: page, PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
type tenantUserBrief struct {
|
|
Username string
|
|
DisplayName string
|
|
}
|
|
|
|
func tenantUserIdsByKeyword(ctx context.Context, tenantID int64, keyword string) ([]int64, error) {
|
|
q := dao.TntUsers.Ctx(ctx).Fields("id")
|
|
q = q.Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
|
if tenantID > 0 {
|
|
q = q.Where("tenant_id", tenantID)
|
|
}
|
|
var users []entity.TntUsers
|
|
err := q.Scan(&users)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, len(users))
|
|
for i, u := range users {
|
|
ids[i] = u.Id
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func buildTenantUserMap(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBrief {
|
|
m := make(map[int64]tenantUserBrief)
|
|
idSet := make(map[int64]struct{})
|
|
for _, r := range records {
|
|
if r.UserId > 0 {
|
|
idSet[r.UserId] = struct{}{}
|
|
}
|
|
}
|
|
if len(idSet) == 0 {
|
|
return m
|
|
}
|
|
ids := make([]int64, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
var users []entity.TntUsers
|
|
if err := dao.TntUsers.Ctx(ctx).
|
|
Fields("id, username, display_name").
|
|
WhereIn("id", ids).
|
|
Scan(&users); err != nil {
|
|
g.Log().Warningf(ctx, "load tenant users for session list: %v", err)
|
|
}
|
|
for _, u := range users {
|
|
m[u.Id] = tenantUserBrief{Username: u.Username, DisplayName: u.DisplayName}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Pending TOTP secret is stored in Redis temporarily during setup flow.
|
|
func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) {
|
|
key := fmt.Sprintf("2fa:pending:admin:%d", userID)
|
|
val, err := g.Redis().Do(ctx, "GET", key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if val.IsNil() || val.IsEmpty() {
|
|
return "", nil
|
|
}
|
|
// The secret is stored encrypted, decrypt it
|
|
encKey, err := getEncKey(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return crypto.DecryptString(encKey, val.String())
|
|
}
|
|
|
|
func clearPendingTOTPSecret(ctx context.Context, userID int64) {
|
|
key := fmt.Sprintf("2fa:pending:admin:%d", userID)
|
|
_, _ = g.Redis().Do(ctx, "DEL", key)
|
|
}
|
|
|
|
func getEncKey(ctx context.Context) ([]byte, error) {
|
|
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
|
|
if hexKey == "" {
|
|
return nil, gerror.New("crypto.encryptionKey is not configured")
|
|
}
|
|
key, err := hex.DecodeString(hexKey)
|
|
if err != nil {
|
|
return nil, gerror.Wrapf(err, "invalid crypto.encryptionKey")
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, gerror.Newf("crypto.encryptionKey must be 32 bytes, got %d", len(key))
|
|
}
|
|
return key, nil
|
|
}
|