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

550 lines
17 KiB
Go

package tenant
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
do "github.com/qianfree/team-api/internal/model/do"
"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/tenant/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/middleware"
"github.com/qianfree/team-api/internal/model/entity"
"github.com/qianfree/team-api/internal/utility/crypto"
)
// Register handles tenant registration.
func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.TenantRegisterRes, error) {
// Check if registration is enabled
if !common.Config().GetBool(ctx, "register_enabled") {
return nil, common.NewBusinessError(consts.CodeRegistrationDisabled, consts.MsgRegistrationDisabled)
}
email := strings.TrimSpace(strings.ToLower(req.Email))
tenantCode := strings.TrimSpace(strings.ToLower(req.TenantCode))
username := strings.TrimSpace(req.Username)
// 根据配置选择验证方式
emailVerificationEnabled := common.Config().GetBool(ctx, "register_email_verification")
if emailVerificationEnabled {
// 邮箱验证模式:要求邮箱验证码
if req.Code == "" {
return nil, common.NewBusinessError(consts.CodeVerifyCodeInvalid, consts.MsgVerifyCodeInvalid)
}
err := common.VerifyCode(ctx, email, req.Code, "register")
if err != nil {
return nil, err
}
} else {
// 滑块验证模式
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
}
// Check tenant code uniqueness
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
// Validate password policy
if err := common.ValidatePassword(req.Password); err != nil {
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
}
passwordHash, err := crypto.HashPassword(req.Password)
if err != nil {
return nil, err
}
var tenantID, ownerUserID int64
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Create tenant
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
Name: strings.TrimSpace(req.TenantName),
Code: tenantCode,
MaxMembers: 10,
MaxConcurrency: 10,
Level: 1,
Settings: "{}",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create tenant")
}
tenantID, err = tenantResult.LastInsertId()
if err != nil {
return gerror.Wrapf(err, "get tenant id")
}
// Create owner user
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
PasswordHash: passwordHash,
DisplayName: username,
Role: "owner",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create user")
}
ownerUserID, err = userResult.LastInsertId()
if err != nil {
return gerror.Wrapf(err, "get user id")
}
// Update tenant owner
_, err = tx.Model("tnt_tenants").Ctx(ctx).
Where("id", tenantID).
Data(do.TntTenants{
OwnerUserId: ownerUserID,
}).
Update()
if err != nil {
return gerror.Wrapf(err, "set owner")
}
// Create wallet for tenant
_, err = tx.Model("bil_wallets").Ctx(ctx).Data(do.BilWallets{
TenantId: tenantID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create wallet")
}
return nil
})
if err != nil {
return nil, err
}
// Generate tokens
refreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
refreshTokenHash := common.HashRefreshToken(refreshToken)
deviceInfo := extractTenantDeviceInfo(ctx)
sessionID, err := common.CreateSession(ctx, "tenant", ownerUserID, tenantID, refreshTokenHash, ipAddress, deviceInfo)
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
tokenPair, err := common.GenerateTokenPair(ctx, ownerUserID, "tenant", "owner", tenantID, sessionID)
if err != nil {
return nil, err
}
res := &v1.TenantRegisterRes{
AccessToken: tokenPair.AccessToken,
RefreshToken: refreshToken,
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}
res.Tenant.ID = tenantID
res.Tenant.Name = strings.TrimSpace(req.TenantName)
res.Tenant.Code = tenantCode
res.User.ID = ownerUserID
res.User.Username = username
res.User.Role = "owner"
return res, nil
}
// Login handles tenant user login.
func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) {
account := strings.TrimSpace(req.Account)
// Check captcha if required
if err := common.CheckCaptchaRequired(ctx, "tenant_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)
var tenant entity.TntTenants
var user entity.TntUsers
if req.Type == "admin" {
// Admin login: account is email, find owner user by email
email := strings.TrimSpace(strings.ToLower(account))
err := dao.TntUsers.Ctx(ctx).
Where("email", email).
Where("role", "owner").
Scan(&user)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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)
}
// Find tenant
err = dao.TntTenants.Ctx(ctx).
Where("id", user.TenantId).Scan(&tenant)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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]
tenantCode := strings.ToLower(parts[1])
// Find tenant
err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Scan(&tenant)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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)
}
// Find user
err = dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenant.Id).
Where("username", username).
Scan(&user)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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
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}
if failedAttempts >= 5 {
lockedUntil := time.Now().Add(30 * time.Minute)
updateData.LockedUntil = gtime.NewFromTime(lockedUntil)
dao.TntUsers.Ctx(ctx).
Where("id", user.Id).
Data(updateData).Update()
return nil, common.NewBusinessError(consts.CodeAccountLocked,
"连续 5 次密码错误,账号已锁定 30 分钟")
}
dao.TntUsers.Ctx(ctx).
Where("id", user.Id).
Data(updateData).Update()
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
// Reset failed attempts on successful login
if user.FailedAttempts > 0 {
dao.TntUsers.Ctx(ctx).
Where("id", user.Id).
Data(do.TntUsers{
FailedAttempts: 0,
LockedUntil: nil,
}).Update()
}
// Check if 2FA is enabled → return provisional token
if user.TotpEnabled {
provisionalToken, err := common.GenerateProvisionalToken(ctx, user.Id, "tenant", user.Role, tenant.Id)
if err != nil {
return nil, err
}
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "")
res := &v1.TenantLoginRes{
TotpRequired: true,
ProvisionalToken: provisionalToken,
}
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
return res, nil
}
// Generate tokens
refreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
refreshTokenHash := common.HashRefreshToken(refreshToken)
// ipAddress already declared above for 2FA check
deviceInfo := extractTenantDeviceInfo(ctx)
sessionID, err := common.CreateSession(ctx, "tenant", user.Id, tenant.Id, refreshTokenHash, ipAddress, deviceInfo)
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, tenant.Id, sessionID)
if err != nil {
return nil, err
}
// Update last login
dao.TntUsers.Ctx(ctx).
Where("id", user.Id).
Data(do.TntUsers{
LastLoginAt: gtime.Now(),
LastLoginIp: ipAddress,
}).Update()
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "")
res := &v1.TenantLoginRes{
AccessToken: tokenPair.AccessToken,
RefreshToken: refreshToken,
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
// Check maintenance mode
maintenanceMode := common.Config().GetBool(ctx, "maintenance_mode")
if maintenanceMode {
res.MaintenanceInfo = &v1.LoginMaintenanceInfo{
Enabled: true,
Message: common.Config().GetString(ctx, "maintenance_message"),
Duration: common.Config().GetString(ctx, "maintenance_duration"),
}
}
return res, nil
}
// Logout handles tenant user logout.
func (s *sTenant) Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.TenantLogoutRes, error) {
sessionID := middleware.GetSessionID(ctx)
common.MarkSessionRevoked(ctx, sessionID)
err := common.RevokeSession(ctx, sessionID)
if err != nil {
return nil, err
}
return nil, nil
}
// Refresh handles token refresh for tenant users.
func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) {
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
if err != nil {
return nil, common.NewUnauthorizedError("会话不存在")
}
if session == nil {
return nil, common.NewUnauthorizedError("会话已过期或不存在")
}
if session.UserType != "tenant" {
return nil, common.NewUnauthorizedError("令牌类型不匹配")
}
if common.IsSessionRevoked(ctx, session.Id) {
return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked)
}
newRefreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
deviceInfo := extractTenantDeviceInfo(ctx)
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
if err != nil {
return nil, err
}
// Fetch current role from user table
var tntUser *entity.TntUsers
err = dao.TntUsers.Ctx(ctx).Where("id", session.UserId).Fields("role").Scan(&tntUser)
if err != nil {
return nil, err
}
if tntUser == nil {
return nil, common.NewUnauthorizedError("用户不存在")
}
tokenPair, err := common.GenerateTokenPair(ctx, session.UserId, "tenant", tntUser.Role, session.TenantId, session.Id)
if err != nil {
return nil, err
}
return &v1.TenantRefreshRes{
AccessToken: tokenPair.AccessToken,
RefreshToken: newRefreshToken,
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}, nil
}
// ChangePassword handles tenant user password change.
func (s *sTenant) ChangePassword(ctx context.Context, req *v1.TenantChangePasswordReq) (*v1.TenantChangePasswordRes, error) {
userID := middleware.GetUserID(ctx)
var user *entity.TntUsers
err := dao.TntUsers.Ctx(ctx).
Where("id", userID).Scan(&user)
if err != nil {
return nil, err
}
if user == nil {
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
if !crypto.VerifyPassword(req.OldPassword, user.PasswordHash) {
return nil, common.NewBusinessError(consts.CodeOldPasswordWrong, consts.MsgOldPasswordWrong)
}
if err := common.ValidatePassword(req.NewPassword); err != nil {
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
}
newHash, err := crypto.HashPassword(req.NewPassword)
if err != nil {
return nil, err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", userID).
Data(do.TntUsers{
PasswordHash: newHash,
}).Update()
if err != nil {
return nil, err
}
common.RevokeAllSessions(ctx, "tenant", userID)
return nil, nil
}
// ListSessions returns active sessions for the current tenant user.
func (s *sTenant) ListSessions(ctx context.Context, req *v1.TenantSessionListReq) (*v1.TenantSessionListRes, error) {
userID := middleware.GetUserID(ctx)
currentSessionID := middleware.GetSessionID(ctx)
sessions, err := common.ListSessions(ctx, "tenant", userID)
if err != nil {
return nil, err
}
items := make([]v1.TenantSessionItem, len(sessions))
for i, sess := range sessions {
items[i] = v1.TenantSessionItem{
ID: sess.ID,
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.TenantSessionListRes{List: items}, nil
}
// RevokeSession revokes a specific session.
func (s *sTenant) RevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (*v1.TenantRevokeSessionRes, error) {
common.MarkSessionRevoked(ctx, req.Id)
err := common.RevokeSession(ctx, req.Id)
if err != nil {
return nil, err
}
return nil, nil
}
// extractTenantDeviceInfo 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 extractTenantDeviceInfo(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)
}