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

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)
}