390 lines
11 KiB
Go
390 lines
11 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
"strings"
|
|
"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"
|
|
"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) {
|
|
// 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", strings.TrimSpace(req.Username)).
|
|
Scan(&user)
|
|
if 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)
|
|
}
|
|
|
|
// Verify password
|
|
if !crypto.VerifyPassword(req.Password, user.PasswordHash) {
|
|
_ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "密码错误")
|
|
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
|
|
}
|
|
|
|
// 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 := extractDeviceInfo(ctx)
|
|
|
|
// Create session
|
|
sessionID, err := common.CreateSession(ctx, "admin", user.Id, 0, refreshTokenHash, ipAddress, deviceInfo)
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update last login
|
|
dao.SysAdminUsers.Ctx(ctx).
|
|
Where("id", user.Id).
|
|
Data(do.SysAdminUsers{
|
|
LastLoginAt: gtime.Now(),
|
|
LastLoginIp: ipAddress,
|
|
}).Update()
|
|
|
|
// 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
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// Logout handles admin logout.
|
|
func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLogoutRes, error) {
|
|
sessionID := common.GetCtxSessionID(ctx)
|
|
|
|
// Mark session as revoked in Redis for instant effect
|
|
common.MarkSessionRevoked(ctx, sessionID)
|
|
|
|
// Delete session from database
|
|
err := common.RevokeSession(ctx, 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.Id) {
|
|
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 := 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").Scan(&adminUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if adminUser == nil {
|
|
return nil, common.NewUnauthorizedError("用户不存在")
|
|
}
|
|
|
|
// Generate new token pair with same session ID
|
|
tokenPair, err := common.GenerateTokenPair(ctx, session.UserId, session.UserType, adminUser.Role, 0, session.Id)
|
|
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 := req.Page
|
|
pageSize := req.PageSize
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 || pageSize > 100 {
|
|
pageSize = 20
|
|
}
|
|
|
|
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", "%"+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 != 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) {
|
|
common.MarkSessionRevoked(ctx, req.Id)
|
|
err := common.RevokeSession(ctx, 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) {
|
|
sessions, err := common.ListSessions(ctx, "admin", req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, sess := range sessions {
|
|
common.MarkSessionRevoked(ctx, sess.ID)
|
|
}
|
|
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 != 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
|
|
}
|
|
|
|
// extractDeviceInfo extracts device information from the request and returns it as a JSON string.
|
|
// The sys_sessions.device_info column is JSONB, so this must be valid JSON.
|
|
func extractDeviceInfo(ctx context.Context) string {
|
|
r := g.RequestFromCtx(ctx)
|
|
if r == nil {
|
|
return `{"user_agent":"unknown"}`
|
|
}
|
|
ua := r.Header.Get("User-Agent")
|
|
if len(ua) > 500 {
|
|
ua = ua[:500]
|
|
}
|
|
if ua == "" {
|
|
ua = "unknown"
|
|
}
|
|
b, _ := json.Marshal(map[string]string{"user_agent": ua})
|
|
return string(b)
|
|
}
|