2026-07-12 00:47:20 +08:00

196 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package auth 实现 user-service 的认证、三方登录、密码登录和 refresh session 用例。
package auth
import (
"context"
"log/slog"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
// Meta 是 gateway 透传到 user-service 的审计元信息。
type Meta struct {
// AppCode 是 gateway 已解析的 App 租户键。
AppCode string
// RequestID 是 gateway 透传的请求 ID用于登录审计。
RequestID string
// ClientIP 是客户端 IP。
ClientIP string
// UserAgent 是客户端 UA。
UserAgent string
// CountryByIP 是 gateway 或可信边缘层根据入口 IP 得到的国家快照。
CountryByIP string
// Platform 是客户端平台快照,认证链路只做审计和风险任务透传。
Platform string
// AppVersion 是本次登录客户端版本;只记录本次请求,不能沿用用户注册时的历史版本。
AppVersion string
// BuildNumber 是本次登录客户端构建号;与 AppVersion 一起用于精确定位客户端包。
BuildNumber string
// Language 是客户端语言偏好gateway 只用作弱信号快拦。
Language string
// Timezone 是客户端 IANA 时区gateway 只用作弱信号快拦。
Timezone string
}
func (s *Service) audit(ctx context.Context, meta Meta, userID int64, loginType string, provider string, result string, failureCode string) {
s.recordAudit(ctx, authdomain.LoginAudit{
AppCode: appcode.Normalize(meta.AppCode),
RequestID: meta.RequestID,
UserID: userID,
LoginType: loginType,
Provider: provider,
Channel: auditChannel(loginType, provider),
Platform: normalizePlatform(meta.Platform),
AppVersion: normalizeAuditClientVersion(meta.AppVersion),
BuildNumber: normalizeAuditClientVersion(meta.BuildNumber),
Result: result,
FailureCode: failureCode,
ClientIP: meta.ClientIP,
IPCountryCode: normalizeCountryCode(meta.CountryByIP),
UserAgent: meta.UserAgent,
CreatedAtMs: s.now().UnixMilli(),
})
}
func (s *Service) recordAudit(ctx context.Context, audit authdomain.LoginAudit) {
if s.authRepository == nil {
// 没有 repository 时无法写审计,但不应让辅助审计路径触发 panic。
return
}
// 审计失败不影响登录主链路,但必须暴露日志;否则漏执行迁移或数据库瞬断会让版本画像静默停留在旧值。
if err := s.authRepository.RecordLoginAudit(ctx, audit); err != nil {
logx.Error(
logx.With(ctx, slog.String("request_id", audit.RequestID), slog.String("app_code", audit.AppCode)),
"login_audit_write_failed",
err,
slog.String("component", "user_auth"),
slog.String("login_type", audit.LoginType),
slog.Int64("user_id", audit.UserID),
)
}
}
// RecordLoginBlocked 写入 gateway fast guard 拒绝的登录审计;失败不应影响 gateway 返回阻断响应。
func (s *Service) RecordLoginBlocked(ctx context.Context, meta Meta, channel string, platform string, language string, timezone string, blockReason string) bool {
if s.authRepository == nil {
return false
}
nowMs := s.now().UnixMilli()
audit := authdomain.LoginAudit{
AppCode: appcode.Normalize(meta.AppCode),
RequestID: meta.RequestID,
LoginType: blockedLoginType(channel),
Provider: blockedProvider(channel),
Channel: normalizeChannel(channel),
Platform: normalizePlatform(firstNonEmpty(platform, meta.Platform)),
AppVersion: normalizeAuditClientVersion(meta.AppVersion),
BuildNumber: normalizeAuditClientVersion(meta.BuildNumber),
Result: "blocked",
FailureCode: string(xerr.AuthLoginBlocked),
ClientIP: meta.ClientIP,
IPCountryCode: normalizeCountryCode(meta.CountryByIP),
Blocked: true,
BlockReason: strings.TrimSpace(blockReason),
UserAgent: meta.UserAgent,
CreatedAtMs: nowMs,
}
if audit.Channel == "" {
audit.Channel = "account"
}
return s.authRepository.RecordLoginAudit(ctx, audit) == nil
}
func normalizeAuditClientVersion(value string) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) <= 64 {
return value
}
// 客户端 header 不受 proto 长度约束。审计元数据超长时截断而不是让 INSERT 失败,
// 否则登录虽成功却丢失整条审计Dashboard 也无法确定该用户最近一次成功登录。
return string(runes[:64])
}
func auditChannel(loginType string, provider string) string {
if loginType == loginPassword {
return "account"
}
if loginType == loginThird {
return normalizeThirdPartyChannel(provider)
}
return strings.TrimSpace(loginType)
}
func blockedLoginType(channel string) string {
if normalizeChannel(channel) == "account" {
return loginPassword
}
return loginThird
}
func blockedProvider(channel string) string {
channel = normalizeChannel(channel)
if channel == "account" {
return ""
}
return channel
}
func normalizeThirdPartyChannel(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case "google", "google.com", "firebase":
return "google"
case "facebook", "facebook.com":
return "facebook"
case "twitter", "x":
return "twitter"
case "tiktok":
return "tiktok"
default:
return provider
}
}
func normalizeChannel(channel string) string {
channel = strings.ToLower(strings.TrimSpace(channel))
switch channel {
case "", "password":
return "account"
case "third_party":
return ""
default:
return normalizeThirdPartyChannel(channel)
}
}
func normalizePlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
if platform == "android" || platform == "ios" {
return platform
}
return platform
}
func normalizeCountryCode(country string) string {
country = strings.ToUpper(strings.TrimSpace(country))
if len(country) < 2 || len(country) > 3 {
return ""
}
return country
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}