feat(log):新增用户登录日志
This commit is contained in:
1 parent
ce9a20366c
commit
bd0d852e0f
15 files changed
+769
-29
No files matched your search
@@ -189,6 +189,7 @@ type IAdminV1 interface {
|
||||
Admin2FAVerify(ctx context.Context, req *v1.Admin2FAVerifyReq) (res *v1.Admin2FAVerifyRes, err error)
|
||||
Admin2FAConfirm(ctx context.Context, req *v1.Admin2FAConfirmReq) (res *v1.Admin2FAConfirmRes, err error)
|
||||
AdminLoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (res *v1.AdminLoginHistoryRes, err error)
|
||||
AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error)
|
||||
AdminSettingsCategories(ctx context.Context, req *v1.AdminSettingsCategoriesReq) (res *v1.AdminSettingsCategoriesRes, err error)
|
||||
AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error)
|
||||
AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error)
|
||||
|
||||
@@ -120,3 +120,44 @@ type LoginHistoryItem struct {
|
||||
FailReason string `json:"fail_reason"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 租户用户登录历史(管理后台查看)
|
||||
// ============================================================
|
||||
|
||||
// AdminTenantLoginHistoryReq 租户用户登录历史(管理后台查看)
|
||||
type AdminTenantLoginHistoryReq struct {
|
||||
g.Meta `path:"/security/tenant-login-history" method:"get" tags:"管理后台-安全" summary:"租户用户登录历史"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID筛选"`
|
||||
Username string `json:"username" in:"query" dc:"用户名(模糊搜索)"`
|
||||
IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"`
|
||||
Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"`
|
||||
LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso/backup_code"`
|
||||
StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"`
|
||||
EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"`
|
||||
}
|
||||
|
||||
type AdminTenantLoginHistoryRes struct {
|
||||
List []AdminTenantLoginHistoryItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type AdminTenantLoginHistoryItem struct {
|
||||
ID int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LoginMethod string `json:"login_method"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Location string `json:"location"`
|
||||
IsNewDevice bool `json:"is_new_device"`
|
||||
Success bool `json:"success"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -90,11 +90,17 @@ type Tenant2FAConfirmRes struct {
|
||||
// 登录历史
|
||||
// ============================================================
|
||||
|
||||
// TenantLoginHistoryReq 登录历史列表
|
||||
// TenantLoginHistoryReq 登录历史列表(owner/admin 查看所有成员,member 仅查看自己)
|
||||
type TenantLoginHistoryReq struct {
|
||||
g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
Username string `json:"username" in:"query" dc:"用户名(模糊搜索,仅owner/admin有效)"`
|
||||
IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"`
|
||||
Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"`
|
||||
LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso"`
|
||||
StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"`
|
||||
EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"`
|
||||
}
|
||||
|
||||
type TenantLoginHistoryRes struct {
|
||||
@@ -106,6 +112,9 @@ type TenantLoginHistoryRes struct {
|
||||
|
||||
type TenantLoginHistoryItem struct {
|
||||
ID int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LoginMethod string `json:"login_method"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error) {
|
||||
return service.Admin().TenantLoginHistory(ctx, req)
|
||||
}
|
||||
@@ -314,6 +314,130 @@ func buildAdminUserMap(ctx context.Context, records []entity.AudLoginHistory) ma
|
||||
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 := dao.AudLoginHistory.Ctx(ctx).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 != 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 != 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
|
||||
_ = dao.TntUsers.Ctx(ctx).
|
||||
Fields("id, username, display_name").
|
||||
WhereIn("id", ids).
|
||||
Scan(&users)
|
||||
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)
|
||||
|
||||
@@ -183,6 +183,11 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
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)
|
||||
|
||||
var tenant entity.TntTenants
|
||||
var user entity.TntUsers
|
||||
|
||||
@@ -197,6 +202,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
return nil, err
|
||||
}
|
||||
if user.Id == 0 {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "用户不存在")
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
||||
}
|
||||
|
||||
@@ -207,12 +213,14 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
return nil, err
|
||||
}
|
||||
if tenant.Id == 0 {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, user.TenantId, "password", ipAddress, ua, deviceFP, false, "租户不存在")
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
||||
}
|
||||
} else {
|
||||
// RAM login: account is username@tenant_code
|
||||
parts := strings.SplitN(account, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "账号格式错误")
|
||||
return nil, common.NewBadRequestError("账号格式错误,应为 用户名@组织代码")
|
||||
}
|
||||
username := parts[0]
|
||||
@@ -225,6 +233,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
return nil, err
|
||||
}
|
||||
if tenant.Id == 0 {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "租户不存在")
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
||||
}
|
||||
|
||||
@@ -237,34 +246,40 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
return nil, err
|
||||
}
|
||||
if user.Id == 0 {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", 0, tenant.Id, "password", ipAddress, ua, deviceFP, false, "用户不存在")
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
if tenant.Status != "active" {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "租户已停用")
|
||||
return nil, common.NewBusinessError(consts.CodeTenantSuspended, consts.MsgTenantSuspended)
|
||||
}
|
||||
|
||||
// Check account lockout
|
||||
if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) {
|
||||
remaining := time.Until(user.LockedUntil.Time).Minutes()
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定")
|
||||
return nil, common.NewBusinessError(consts.CodeAccountLocked,
|
||||
fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining)))
|
||||
}
|
||||
|
||||
// Check status
|
||||
if user.Status != "active" {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已禁用")
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
||||
}
|
||||
|
||||
// Check IP whitelist
|
||||
ipAddr := g.RequestFromCtx(ctx).GetClientIp()
|
||||
if !CheckIPWhitelist(ctx, tenant.Id, ipAddr) {
|
||||
if !CheckIPWhitelist(ctx, tenant.Id, ipAddress) {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "IP不在白名单")
|
||||
return nil, common.NewBusinessError(consts.CodeIpRestricted, consts.MsgIpRestricted)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !crypto.VerifyPassword(req.Password, user.PasswordHash) {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误")
|
||||
|
||||
// Increment failed attempts
|
||||
failedAttempts := user.FailedAttempts + 1
|
||||
updateData := do.TntUsers{FailedAttempts: failedAttempts}
|
||||
@@ -295,10 +310,6 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
LockedUntil: nil,
|
||||
}).Update()
|
||||
}
|
||||
// Get client info
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
|
||||
deviceFP := common.DeviceFingerprint(ua, ipAddress)
|
||||
|
||||
// Check if 2FA is enabled → return provisional token
|
||||
if user.TotpEnabled {
|
||||
@@ -349,6 +360,8 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
LastLoginIp: ipAddress,
|
||||
}).Update()
|
||||
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "")
|
||||
|
||||
res := &v1.TenantLoginRes{
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
RefreshToken: refreshToken,
|
||||
|
||||
@@ -190,41 +190,71 @@ func (s *sTenant) ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmR
|
||||
return &v1.Tenant2FAConfirmRes{ConfirmToken: token}, nil
|
||||
}
|
||||
|
||||
// LoginHistory returns the login history for the current tenant user.
|
||||
// LoginHistory returns the login history for tenant users.
|
||||
// owner/admin see all members in the tenant; member sees only own records.
|
||||
func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error) {
|
||||
userID := ctxUserID(ctx)
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
tenantID := ctxTenantID(ctx)
|
||||
role := ctxUserRole(ctx)
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
q := dao.AudLoginHistory.Ctx(ctx).
|
||||
Where("user_type", "tenant").
|
||||
Where("tenant_id", tenantID)
|
||||
|
||||
// Role-based access: member only sees own records
|
||||
if role == "member" {
|
||||
q = q.Where("user_id", userID)
|
||||
} else if req.Username != "" {
|
||||
// owner/admin can filter by username
|
||||
userIds, err := tenantUserIdsByUsernameLocal(ctx, tenantID, req.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(userIds) == 0 {
|
||||
return &v1.TenantLoginHistoryRes{
|
||||
List: []v1.TenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
q = q.WhereIn("user_id", userIds)
|
||||
}
|
||||
|
||||
total, err := dao.AudLoginHistory.Ctx(ctx).
|
||||
Where("user_type", "tenant").
|
||||
Where("user_id", userID).
|
||||
Count()
|
||||
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 = dao.AudLoginHistory.Ctx(ctx).
|
||||
Where("user_type", "tenant").
|
||||
Where("user_id", userID).
|
||||
OrderDesc("created_at").
|
||||
Page(page, pageSize).
|
||||
Scan(&records)
|
||||
err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMap := buildTenantUserMapLocal(ctx, records)
|
||||
|
||||
items := make([]v1.TenantLoginHistoryItem, len(records))
|
||||
for i, r := range records {
|
||||
items[i] = v1.TenantLoginHistoryItem{
|
||||
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,
|
||||
@@ -246,6 +276,54 @@ func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
type tenantUserBriefLocal struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
func tenantUserIdsByUsernameLocal(ctx context.Context, tenantID int64, keyword string) ([]int64, error) {
|
||||
var users []entity.TntUsers
|
||||
err := dao.TntUsers.Ctx(ctx).
|
||||
Fields("id").
|
||||
Where("tenant_id", tenantID).
|
||||
Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
|
||||
Scan(&users)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, len(users))
|
||||
for i, u := range users {
|
||||
ids[i] = u.Id
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func buildTenantUserMapLocal(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBriefLocal {
|
||||
m := make(map[int64]tenantUserBriefLocal)
|
||||
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
|
||||
_ = dao.TntUsers.Ctx(ctx).
|
||||
Fields("id, username, display_name").
|
||||
WhereIn("id", ids).
|
||||
Scan(&users)
|
||||
for _, u := range users {
|
||||
m[u.Id] = tenantUserBriefLocal{Username: u.Username, DisplayName: u.DisplayName}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) {
|
||||
key := fmt.Sprintf("2fa:pending:tenant:%d", userID)
|
||||
val, err := g.Redis().Do(ctx, "GET", key)
|
||||
|
||||
@@ -329,6 +329,8 @@ type (
|
||||
ConfirmHighRisk(ctx context.Context, req *v1.Admin2FAConfirmReq) (*v1.Admin2FAConfirmRes, error)
|
||||
// LoginHistory returns the login history for all admin users with search filters.
|
||||
LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (*v1.AdminLoginHistoryRes, error)
|
||||
// TenantLoginHistory returns login history for tenant users (admin view).
|
||||
TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error)
|
||||
// GetSettingsCategories returns all available setting categories.
|
||||
GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error)
|
||||
// GetSettings retrieves settings with schema for a given category.
|
||||
|
||||
@@ -277,7 +277,8 @@ type (
|
||||
RegenerateBackupCodes(ctx context.Context, req *v1.Tenant2FARegenerateBackupCodesReq) (*v1.Tenant2FARegenerateBackupCodesRes, error)
|
||||
// ConfirmHighRisk generates a confirm token for high-risk operations.
|
||||
ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmReq) (*v1.Tenant2FAConfirmRes, error)
|
||||
// LoginHistory returns the login history for the current tenant user.
|
||||
// LoginHistory returns the login history for tenant users.
|
||||
// owner/admin see all members in the tenant; member sees only own records.
|
||||
LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error)
|
||||
// GetIPWhitelist returns the tenant's IP whitelist configuration.
|
||||
GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error)
|
||||
|
||||
@@ -120,6 +120,12 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/security/LoginHistoryPage.vue'),
|
||||
meta: { title: '登录历史' },
|
||||
},
|
||||
{
|
||||
path: 'tenant-login-history',
|
||||
name: 'AdminTenantLoginHistory',
|
||||
component: () => import('@/views/security/TenantLoginHistoryPage.vue'),
|
||||
meta: { title: '租户登录历史' },
|
||||
},
|
||||
{
|
||||
path: 'task-logs',
|
||||
name: 'AdminTaskLogs',
|
||||
|
||||
@@ -186,6 +186,7 @@ const menuGroups = [
|
||||
icon: IconSafe,
|
||||
items: [
|
||||
{ name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle },
|
||||
{ name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle },
|
||||
{ name: 'AdminSessions', label: '会话管理', icon: IconClockCircle },
|
||||
{ name: 'AdminPermissions', label: '权限管理', icon: IconSafe },
|
||||
{ name: 'AdminAudit', label: '操作日志', icon: IconFile },
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showTotal: true,
|
||||
})
|
||||
|
||||
const searchForm = reactive({
|
||||
tenant_id: undefined as number | undefined,
|
||||
username: '',
|
||||
ip_address: '',
|
||||
success: undefined as boolean | undefined,
|
||||
login_method: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
})
|
||||
|
||||
const successOptions = [
|
||||
{ label: '全部', value: undefined },
|
||||
{ label: '成功', value: true },
|
||||
{ label: '失败', value: false },
|
||||
]
|
||||
|
||||
const methodOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '密码', value: 'password' },
|
||||
{ label: '双因素', value: 'totp' },
|
||||
{ label: '单点登录', value: 'sso' },
|
||||
{ label: '恢复码', value: 'backup_code' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (searchForm.tenant_id) params.tenant_id = searchForm.tenant_id
|
||||
if (searchForm.username) params.username = searchForm.username
|
||||
if (searchForm.ip_address) params.ip_address = searchForm.ip_address
|
||||
if (searchForm.success !== undefined) params.success = searchForm.success
|
||||
if (searchForm.login_method) params.login_method = searchForm.login_method
|
||||
if (searchForm.start_time) params.start_time = searchForm.start_time
|
||||
if (searchForm.end_time) params.end_time = searchForm.end_time
|
||||
|
||||
const res = await request.get('/admin/security/tenant-login-history', { params })
|
||||
if (res.data?.code === 0) {
|
||||
list.value = res.data.data.list || []
|
||||
pagination.total = res.data.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
pagination.current = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
searchForm.tenant_id = undefined
|
||||
searchForm.username = ''
|
||||
searchForm.ip_address = ''
|
||||
searchForm.success = undefined
|
||||
searchForm.login_method = ''
|
||||
searchForm.start_time = ''
|
||||
searchForm.end_time = ''
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function methodLabel(method: string) {
|
||||
const map: Record<string, string> = {
|
||||
password: '密码',
|
||||
totp: '双因素',
|
||||
sso: '单点登录',
|
||||
backup_code: '恢复码',
|
||||
}
|
||||
return map[method] || method
|
||||
}
|
||||
|
||||
function methodColor(method: string) {
|
||||
const map: Record<string, string> = {
|
||||
password: 'blue',
|
||||
totp: 'green',
|
||||
sso: 'purple',
|
||||
backup_code: 'orange',
|
||||
}
|
||||
return map[method] || 'gray'
|
||||
}
|
||||
|
||||
function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
|
||||
if (type === 'start') {
|
||||
searchForm.start_time = dateString || ''
|
||||
} else {
|
||||
searchForm.end_time = dateString || ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-table">
|
||||
<PageHeader title="租户登录历史" description="查看所有租户用户的登录记录" />
|
||||
|
||||
<ACard :bordered="false" style="margin-bottom: 16px">
|
||||
<AForm :model="searchForm" layout="inline" @submit="onSearch">
|
||||
<AFormItem label="租户ID">
|
||||
<AInputNumber v-model="searchForm.tenant_id" placeholder="租户ID" allow-clear style="width: 120px" />
|
||||
</AFormItem>
|
||||
<AFormItem label="用户名">
|
||||
<AInput v-model="searchForm.username" placeholder="搜索用户名" allow-clear style="width: 140px" />
|
||||
</AFormItem>
|
||||
<AFormItem label="IP 地址">
|
||||
<AInput v-model="searchForm.ip_address" placeholder="搜索 IP" allow-clear style="width: 140px" />
|
||||
</AFormItem>
|
||||
<AFormItem label="状态">
|
||||
<ASelect v-model="searchForm.success" :options="successOptions" placeholder="全部" allow-clear style="width: 100px" />
|
||||
</AFormItem>
|
||||
<AFormItem label="登录方式">
|
||||
<ASelect v-model="searchForm.login_method" :options="methodOptions" placeholder="全部" allow-clear style="width: 120px" />
|
||||
</AFormItem>
|
||||
<AFormItem label="开始日期">
|
||||
<ADatePicker v-model="searchForm.start_time" style="width: 150px" @change="(v: any) => onDateChange(v, 'start')" />
|
||||
</AFormItem>
|
||||
<AFormItem label="结束日期">
|
||||
<ADatePicker v-model="searchForm.end_time" style="width: 150px" @change="(v: any) => onDateChange(v, 'end')" />
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<ASpace>
|
||||
<AButton type="primary" html-type="submit">搜索</AButton>
|
||||
<AButton @click="onReset">重置</AButton>
|
||||
</ASpace>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<ATable
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showTotal: true,
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
>
|
||||
<template #columns>
|
||||
<ATableColumn title="时间" data-index="created_at" :width="180" />
|
||||
<ATableColumn title="租户ID" data-index="tenant_id" :width="90" />
|
||||
<ATableColumn title="用户" :width="160">
|
||||
<template #cell="{ record }">
|
||||
<span>{{ record.display_name || record.username }}</span>
|
||||
<span v-if="record.display_name" style="color: var(--ta-text-tertiary); margin-left: 4px">({{ record.username }})</span>
|
||||
</template>
|
||||
</ATableColumn>
|
||||
<ATableColumn title="登录方式" data-index="login_method" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<ATag :color="methodColor(record.login_method)" size="small">{{ methodLabel(record.login_method) }}</ATag>
|
||||
</template>
|
||||
</ATableColumn>
|
||||
<ATableColumn title="IP 地址" data-index="ip_address" :width="150" />
|
||||
<ATableColumn title="设备" data-index="user_agent" :width="200" ellipsis />
|
||||
<ATableColumn title="状态" :width="80">
|
||||
<template #cell="{ record }">
|
||||
<ATag :color="record.success ? 'green' : 'red'" size="small">{{ record.success ? '成功' : '失败' }}</ATag>
|
||||
</template>
|
||||
</ATableColumn>
|
||||
<ATableColumn title="新设备" :width="80">
|
||||
<template #cell="{ record }">
|
||||
<ATag v-if="record.is_new_device" color="orangered" size="small">新设备</ATag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ATableColumn>
|
||||
<ATableColumn title="失败原因" data-index="fail_reason" :width="180" ellipsis>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.fail_reason">{{ record.fail_reason }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ATableColumn>
|
||||
</template>
|
||||
</ATable>
|
||||
</ACard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -195,6 +195,12 @@ const tenantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/settings/AuditConfigPage.vue'),
|
||||
meta: { title: '审计设置', roles: ['owner', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: 'login-history',
|
||||
name: 'TenantLoginHistory',
|
||||
component: () => import('@/views/settings/LoginHistoryPage.vue'),
|
||||
meta: { title: '登录历史', roles: ['owner', 'admin', 'member'] },
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'TenantProfile',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
const filterUsername = ref('')
|
||||
const filterIpAddress = ref('')
|
||||
const filterSuccess = ref('')
|
||||
const filterMethod = ref('')
|
||||
const filterStartDate = ref('')
|
||||
const filterEndDate = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (filterUsername.value) params.username = filterUsername.value
|
||||
if (filterIpAddress.value) params.ip_address = filterIpAddress.value
|
||||
if (filterSuccess.value !== '') params.success = filterSuccess.value === 'true'
|
||||
if (filterMethod.value) params.login_method = filterMethod.value
|
||||
if (filterStartDate.value) params.start_time = filterStartDate.value
|
||||
if (filterEndDate.value) params.end_time = filterEndDate.value
|
||||
|
||||
const res = await request.get('/tenant/security/login-history', { params })
|
||||
const raw = res.data?.data
|
||||
list.value = raw?.list || []
|
||||
total.value = raw?.total || 0
|
||||
} catch {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterUsername.value = ''
|
||||
filterIpAddress.value = ''
|
||||
filterSuccess.value = ''
|
||||
filterMethod.value = ''
|
||||
filterStartDate.value = ''
|
||||
filterEndDate.value = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const methodBadge: Record<string, string> = {
|
||||
password: 'badge-primary',
|
||||
totp: 'badge-success',
|
||||
sso: 'badge-purple',
|
||||
}
|
||||
|
||||
const methodLabel: Record<string, string> = {
|
||||
password: '密码',
|
||||
totp: '双因素',
|
||||
sso: '单点登录',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">登录历史</h1>
|
||||
<p class="page-description">查看组织成员的登录记录,包括成功和失败的登录尝试</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="flex flex-1 flex-wrap items-end gap-4">
|
||||
<div class="w-full sm:w-auto sm:min-w-[120px]">
|
||||
<label class="input-label">用户名</label>
|
||||
<input
|
||||
v-model="filterUsername"
|
||||
type="text"
|
||||
placeholder="搜索用户名"
|
||||
class="input"
|
||||
@keyup.enter="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">IP 地址</label>
|
||||
<input
|
||||
v-model="filterIpAddress"
|
||||
type="text"
|
||||
placeholder="搜索 IP"
|
||||
class="input"
|
||||
@keyup.enter="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[100px]">
|
||||
<label class="input-label">状态</label>
|
||||
<select v-model="filterSuccess" class="input bg-white">
|
||||
<option value="">全部</option>
|
||||
<option value="true">成功</option>
|
||||
<option value="false">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[100px]">
|
||||
<label class="input-label">登录方式</label>
|
||||
<select v-model="filterMethod" class="input bg-white">
|
||||
<option value="">全部</option>
|
||||
<option value="password">密码</option>
|
||||
<option value="totp">双因素</option>
|
||||
<option value="sso">单点登录</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">开始日期</label>
|
||||
<input v-model="filterStartDate" type="date" class="input" />
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">结束日期</label>
|
||||
<input v-model="filterEndDate" type="date" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-secondary" @click="applyFilters">
|
||||
<Icon name="search" size="sm" />
|
||||
搜索
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<div v-if="loading" class="p-8 flex justify-center">
|
||||
<div class="spinner h-6 w-6 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="list.length > 0">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>登录方式</th>
|
||||
<th>IP 地址</th>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>失败原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in list" :key="item.id">
|
||||
<td class="whitespace-nowrap">{{ item.created_at }}</td>
|
||||
<td>
|
||||
<div>{{ item.display_name || item.username || '-' }}</div>
|
||||
<div v-if="item.display_name && item.username" class="text-xs text-gray-400">{{ item.username }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="methodBadge[item.login_method] || 'badge-gray'">
|
||||
{{ methodLabel[item.login_method] || item.login_method }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono text-sm">{{ item.ip_address }}</td>
|
||||
<td class="max-w-[200px] truncate text-gray-500" :title="item.user_agent">{{ item.user_agent }}</td>
|
||||
<td>
|
||||
<span v-if="item.success" class="badge badge-success">成功</span>
|
||||
<span v-else class="badge badge-danger">失败</span>
|
||||
</td>
|
||||
<td class="text-gray-500">{{ item.fail_reason || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="card-footer flex justify-between items-center">
|
||||
<span class="text-sm text-gray-500">共 {{ total }} 条记录</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="page <= 1"
|
||||
@click="page--; fetchData()"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ page }} / {{ Math.ceil(total / pageSize) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="page * pageSize >= total"
|
||||
@click="page++; fetchData()"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="empty-state">
|
||||
<Icon name="shield" size="xl" class="empty-state-icon" />
|
||||
<p class="empty-state-title">暂无登录记录</p>
|
||||
<p class="empty-state-description">当前没有任何登录历史记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -334,6 +334,22 @@ onMounted(() => {
|
||||
暂无会话
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login History Link Card -->
|
||||
<router-link to="/tenant/login-history" class="card card-hover block">
|
||||
<div class="card-body flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-10 w-10 rounded-xl bg-primary-100 flex items-center justify-center">
|
||||
<Icon name="shield" size="md" class="text-primary-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">登录历史</p>
|
||||
<p class="text-sm text-gray-500">查看登录记录,包括成功和失败的登录尝试</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="chevronRight" size="md" class="text-gray-400" />
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user