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

476 lines
13 KiB
Go

package tenant
import (
"context"
"encoding/json"
"fmt"
"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/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"
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 tenant login.
func (s *sTenant) Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v1.Tenant2FAVerifyRes, error) {
claims, err := common.ParseProvisionalToken(req.Provisional)
if err != nil {
return nil, common.NewBusinessError(consts.CodeUnauthorized, "临时令牌无效或已过期")
}
if claims.UserType != "tenant" {
return nil, common.NewBusinessError(consts.CodeUnauthorized, "令牌类型不匹配")
}
valid, err := common.Verify2FACode(ctx, "tenant", claims.UserID, req.Code)
if err != nil {
return nil, err
}
if !valid {
return nil, common.NewBusinessError(consts.CodeTotpInvalid, consts.MsgTotpInvalid)
}
var user *entity.TntUsers
err = dao.TntUsers.Ctx(ctx).Where("id", claims.UserID).Scan(&user)
if 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)
}
refreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
refreshTokenHash := common.HashRefreshToken(refreshToken)
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
deviceInfo := extractTenantDeviceInfo(ctx)
sessionID, err := common.CreateSession(ctx, "tenant", user.Id, user.TenantId, refreshTokenHash, ipAddress, deviceInfo)
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, user.TenantId, sessionID)
if err != nil {
return nil, err
}
dao.TntUsers.Ctx(ctx).Where("id", user.Id).Data(do.TntUsers{
LastLoginAt: gtime.Now(),
LastLoginIp: ipAddress,
}).Update()
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
deviceFP := common.DeviceFingerprint(ua, ipAddress)
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, user.TenantId, "totp", ipAddress, ua, deviceFP, true, "")
// Get tenant info
var tenant *entity.TntTenants
err = dao.TntTenants.Ctx(ctx).Where("id", user.TenantId).Scan(&tenant)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
res := &v1.Tenant2FAVerifyRes{
AccessToken: tokenPair.AccessToken,
RefreshToken: refreshToken,
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
return res, nil
}
// Setup2FA starts the 2FA setup process for the current tenant user.
func (s *sTenant) Setup2FA(ctx context.Context, _ *v1.Tenant2FASetupReq) (*v1.Tenant2FASetupRes, error) {
userID := middleware.GetUserID(ctx)
secret, uri, err := common.Setup2FA(ctx, "tenant", userID)
if err != nil {
return nil, err
}
encKey := getEncKey(ctx)
encrypted, err := crypto.EncryptString(encKey, secret)
if err != nil {
return nil, err
}
key := fmt.Sprintf("2fa:pending:tenant:%d", userID)
_, err = g.Redis().Do(ctx, "SETEX", key, 300, encrypted)
if err != nil {
return nil, err
}
return &v1.Tenant2FASetupRes{Secret: secret, URI: uri}, nil
}
// Enable2FA confirms and enables 2FA.
func (s *sTenant) Enable2FA(ctx context.Context, req *v1.Tenant2FAEnableReq) (*v1.Tenant2FAEnableRes, error) {
userID := middleware.GetUserID(ctx)
secret, err := getPendingTOTPSecret(ctx, userID)
if err != nil || secret == "" {
return nil, common.NewBusinessError(consts.CodeBadRequest, "请先执行2FA设置")
}
backupCodes, err := common.Enable2FA(ctx, "tenant", userID, secret, req.Code, req.Password)
if err != nil {
return nil, err
}
clearPendingTOTPSecret(ctx, userID)
return &v1.Tenant2FAEnableRes{BackupCodes: backupCodes}, nil
}
// Disable2FA disables 2FA for the current tenant user.
func (s *sTenant) Disable2FA(ctx context.Context, req *v1.Tenant2FADisableReq) (*v1.Tenant2FADisableRes, error) {
userID := middleware.GetUserID(ctx)
err := common.Disable2FA(ctx, "tenant", userID, req.Code)
if err != nil {
return nil, err
}
return nil, nil
}
// RegenerateBackupCodes generates new backup codes.
func (s *sTenant) RegenerateBackupCodes(ctx context.Context, req *v1.Tenant2FARegenerateBackupCodesReq) (*v1.Tenant2FARegenerateBackupCodesRes, error) {
userID := middleware.GetUserID(ctx)
codes, err := common.RegenerateBackupCodes(ctx, "tenant", userID, req.Code)
if err != nil {
return nil, err
}
return &v1.Tenant2FARegenerateBackupCodesRes{BackupCodes: codes}, nil
}
// ConfirmHighRisk generates a confirm token for high-risk operations.
func (s *sTenant) ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmReq) (*v1.Tenant2FAConfirmRes, error) {
userID := middleware.GetUserID(ctx)
enabled, err := common.Is2FAEnabled(ctx, "tenant", userID)
if err != nil {
return nil, err
}
if !enabled {
return nil, common.NewBusinessError(consts.CodeTotpNotEnabled, consts.MsgTotpNotEnabled)
}
valid, err := common.Verify2FACode(ctx, "tenant", userID, req.Code)
if err != nil {
return nil, err
}
if !valid {
return nil, common.NewBusinessError(consts.CodeTotpInvalid, consts.MsgTotpInvalid)
}
token, err := common.GenerateConfirmToken(ctx, userID, "tenant")
if err != nil {
return nil, err
}
return &v1.Tenant2FAConfirmRes{ConfirmToken: token}, nil
}
// 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 := middleware.GetUserID(ctx)
tenantID := middleware.GetTenantID(ctx)
role := middleware.GetUserRole(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)
}
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 := 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,
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.TenantLoginHistoryRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, 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)
if err != nil {
return "", err
}
if val.IsNil() || val.IsEmpty() {
return "", nil
}
encKey := getEncKey(ctx)
return crypto.DecryptString(encKey, val.String())
}
func clearPendingTOTPSecret(ctx context.Context, userID int64) {
key := fmt.Sprintf("2fa:pending:tenant:%d", userID)
_, _ = g.Redis().Do(ctx, "DEL", key)
}
func getEncKey(ctx context.Context) []byte {
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
if hexKey == "" {
hexKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
return crypto.MustGetEncryptionKey(hexKey)
}
// GetIPWhitelist returns the tenant's IP whitelist configuration.
func (s *sTenant) GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error) {
tenantID := middleware.GetTenantID(ctx)
var tenant *entity.TntTenants
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, common.NewNotFoundError("租户")
}
settings := make(map[string]any)
if tenant.Settings != "" {
_ = json.Unmarshal([]byte(tenant.Settings), &settings)
}
enabled := false
whitelist := []string{}
if v, ok := settings["ip_whitelist_enabled"]; ok {
enabled = v.(bool)
}
if v, ok := settings["ip_whitelist"]; ok {
if arr, ok := v.([]any); ok {
for _, item := range arr {
whitelist = append(whitelist, fmt.Sprintf("%v", item))
}
}
}
return &v1.TenantIPWhitelistGetRes{Enabled: enabled, Whitelist: whitelist}, nil
}
// UpdateIPWhitelist updates the tenant's IP whitelist configuration.
func (s *sTenant) UpdateIPWhitelist(ctx context.Context, req *v1.TenantIPWhitelistUpdateReq) (*v1.TenantIPWhitelistUpdateRes, error) {
tenantID := middleware.GetTenantID(ctx)
var tenant *entity.TntTenants
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, common.NewNotFoundError("租户")
}
settings := make(map[string]any)
if tenant.Settings != "" {
_ = json.Unmarshal([]byte(tenant.Settings), &settings)
}
if req.Enabled != nil {
settings["ip_whitelist_enabled"] = *req.Enabled
}
if req.Whitelist != nil {
settings["ip_whitelist"] = req.Whitelist
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return nil, err
}
_, err = dao.TntTenants.Ctx(ctx).Where("id", tenantID).Data(g.Map{
"settings": string(settingsJSON),
}).Update()
if err != nil {
return nil, err
}
return nil, nil
}
// CheckIPWhitelist checks if the given IP is allowed for the tenant.
// Returns true if allowed (or whitelist is disabled).
func CheckIPWhitelist(ctx context.Context, tenantID int64, ip string) bool {
var tenant *entity.TntTenants
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
if err != nil {
return true
}
if tenant == nil {
return true
}
if tenant.Settings == "" {
return true
}
settings := make(map[string]any)
if err := json.Unmarshal([]byte(tenant.Settings), &settings); err != nil {
return true
}
enabled, _ := settings["ip_whitelist_enabled"].(bool)
if !enabled {
return true
}
whitelist, ok := settings["ip_whitelist"]
if !ok {
return true
}
arr, ok := whitelist.([]any)
if !ok || len(arr) == 0 {
return true
}
for _, item := range arr {
cidr := fmt.Sprintf("%v", item)
if ip == cidr {
return true
}
// Simple CIDR / prefix match for common cases
if strings.HasSuffix(cidr, ".*") {
prefix := strings.TrimSuffix(cidr, ".*")
if strings.HasPrefix(ip, prefix) {
return true
}
}
}
return false
}