364 lines
11 KiB
Go
364 lines
11 KiB
Go
package http
|
||
|
||
import (
|
||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||
"net"
|
||
"net/http"
|
||
"net/netip"
|
||
"strings"
|
||
"time"
|
||
|
||
userv1 "hyapp.local/api/proto/user/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/services/gateway-service/internal/auth"
|
||
)
|
||
|
||
// authTokenData 是 gateway 对外 auth API 的扁平响应格式。
|
||
type authTokenData struct {
|
||
AppCode string `json:"app_code"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||
DefaultDisplayUserID string `json:"default_display_user_id,omitempty"`
|
||
DisplayUserIDKind string `json:"display_user_id_kind,omitempty"`
|
||
DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"`
|
||
SessionID string `json:"session_id"`
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token,omitempty"`
|
||
ExpiresInSec int64 `json:"expires_in_sec"`
|
||
TokenType string `json:"token_type"`
|
||
IsNewUser *bool `json:"is_new_user,omitempty"`
|
||
ProfileCompleted bool `json:"profile_completed"`
|
||
OnboardingStatus string `json:"onboarding_status"`
|
||
}
|
||
|
||
// loginPassword 处理账号密码登录;账号就是当前有效短号。
|
||
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
Account string `json:"account"`
|
||
Password string `json:"password"`
|
||
DeviceID string `json:"device_id"`
|
||
Platform string `json:"platform"`
|
||
Language string `json:"language"`
|
||
Timezone string `json:"timezone"`
|
||
}
|
||
if !decode(writer, request, &body) {
|
||
return
|
||
}
|
||
account := strings.TrimSpace(body.Account)
|
||
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
||
operation: authOperationPassword,
|
||
deviceID: body.DeviceID,
|
||
displayUserID: account,
|
||
}) {
|
||
return
|
||
}
|
||
if !h.allowLoginRisk(writer, request, loginRiskInput{
|
||
Channel: "account",
|
||
Platform: body.Platform,
|
||
Language: body.Language,
|
||
Timezone: body.Timezone,
|
||
}) {
|
||
return
|
||
}
|
||
if h.userClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
|
||
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
|
||
DisplayUserId: account,
|
||
Password: body.Password,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
|
||
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
|
||
}
|
||
|
||
// loginThirdParty 处理三方登录即注册。
|
||
func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
Provider string `json:"provider"`
|
||
Credential string `json:"credential"`
|
||
Username string `json:"username"`
|
||
Gender string `json:"gender"`
|
||
Country string `json:"country"`
|
||
InviteCode string `json:"invite_code"`
|
||
DeviceID string `json:"device_id"`
|
||
Device string `json:"device"`
|
||
OSVersion string `json:"os_version"`
|
||
Avatar string `json:"avatar"`
|
||
Birth string `json:"birth"`
|
||
AppVersion string `json:"app_version"`
|
||
BuildNumber string `json:"build_number"`
|
||
Source string `json:"source"`
|
||
InstallChannel string `json:"install_channel"`
|
||
Campaign string `json:"campaign"`
|
||
Platform string `json:"platform"`
|
||
Language string `json:"language"`
|
||
Timezone string `json:"timezone"`
|
||
}
|
||
if !decode(writer, request, &body) {
|
||
return
|
||
}
|
||
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
||
operation: authOperationThirdParty,
|
||
deviceID: body.DeviceID,
|
||
provider: body.Provider,
|
||
}) {
|
||
return
|
||
}
|
||
if !h.allowLoginRisk(writer, request, loginRiskInput{
|
||
Channel: body.Provider,
|
||
Platform: body.Platform,
|
||
Language: body.Language,
|
||
Timezone: body.Timezone,
|
||
}) {
|
||
return
|
||
}
|
||
if h.userClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
|
||
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
|
||
Provider: body.Provider,
|
||
Credential: body.Credential,
|
||
Username: body.Username,
|
||
Gender: body.Gender,
|
||
Country: body.Country,
|
||
InviteCode: body.InviteCode,
|
||
DeviceId: body.DeviceID,
|
||
Device: body.Device,
|
||
OsVersion: body.OSVersion,
|
||
Avatar: body.Avatar,
|
||
Birth: body.Birth,
|
||
AppVersion: body.AppVersion,
|
||
BuildNumber: body.BuildNumber,
|
||
Source: body.Source,
|
||
InstallChannel: body.InstallChannel,
|
||
Campaign: body.Campaign,
|
||
Platform: body.Platform,
|
||
Language: body.Language,
|
||
Timezone: body.Timezone,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
|
||
isNewUser := resp.GetIsNewUser()
|
||
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
|
||
}
|
||
|
||
// setPassword 处理已登录用户首次设置密码。
|
||
func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if !decode(writer, request, &body) {
|
||
return
|
||
}
|
||
if h.userClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.userClient.SetPassword(request.Context(), &userv1.SetPasswordRequest{
|
||
Meta: authRequestMeta(request, ""),
|
||
UserId: auth.UserIDFromContext(request.Context()),
|
||
Password: body.Password,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
|
||
httpkit.WriteOK(writer, request, map[string]bool{"password_set": resp.GetPasswordSet()})
|
||
}
|
||
|
||
// refreshToken 处理 refresh token 轮换。
|
||
func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
RefreshToken string `json:"refresh_token"`
|
||
DeviceID string `json:"device_id"`
|
||
}
|
||
if !decode(writer, request, &body) {
|
||
return
|
||
}
|
||
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
||
operation: authOperationRefresh,
|
||
deviceID: body.DeviceID,
|
||
}) {
|
||
return
|
||
}
|
||
if h.userClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.userClient.RefreshToken(request.Context(), &userv1.RefreshTokenRequest{
|
||
Meta: authRequestMeta(request, body.DeviceID),
|
||
RefreshToken: body.RefreshToken,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
|
||
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
|
||
}
|
||
|
||
// logout 处理 refresh session 失效。
|
||
func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
RefreshToken string `json:"refresh_token"`
|
||
SessionID string `json:"session_id"`
|
||
}
|
||
if !decode(writer, request, &body) {
|
||
return
|
||
}
|
||
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
||
operation: authOperationLogout,
|
||
sessionID: body.SessionID,
|
||
}) {
|
||
return
|
||
}
|
||
if h.userClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.userClient.Logout(request.Context(), &userv1.LogoutRequest{
|
||
Meta: authRequestMeta(request, ""),
|
||
SessionId: body.SessionID,
|
||
RefreshToken: body.RefreshToken,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
|
||
httpkit.WriteOK(writer, request, map[string]bool{"revoked": resp.GetRevoked()})
|
||
}
|
||
|
||
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
|
||
func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
|
||
return authRequestMetaWithLoginContext(request, deviceID, "", "", "")
|
||
}
|
||
|
||
func authRequestMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
|
||
return &userv1.RequestMeta{
|
||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||
Caller: "gateway-service",
|
||
GatewayNodeId: "gateway-local",
|
||
SentAtMs: time.Now().UnixMilli(),
|
||
DeviceId: deviceID,
|
||
ClientIp: clientIP(request),
|
||
UserAgent: request.UserAgent(),
|
||
CountryByIp: countryByIP(request),
|
||
SessionId: auth.SessionIDFromContext(request.Context()),
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
Platform: strings.ToLower(strings.TrimSpace(platform)),
|
||
Language: strings.TrimSpace(language),
|
||
Timezone: strings.TrimSpace(timezone),
|
||
}
|
||
}
|
||
|
||
// authData 把 user-service 的 token response 转成外部 HTTP 扁平 data。
|
||
func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
|
||
if token == nil {
|
||
return authTokenData{IsNewUser: isNewUser}
|
||
}
|
||
|
||
return authTokenData{
|
||
UserID: userIDString(token.GetUserId()),
|
||
AppCode: token.GetAppCode(),
|
||
DisplayUserID: token.GetDisplayUserId(),
|
||
DefaultDisplayUserID: token.GetDefaultDisplayUserId(),
|
||
DisplayUserIDKind: token.GetDisplayUserIdKind(),
|
||
DisplayUserIDExpiresAtMs: token.GetDisplayUserIdExpiresAtMs(),
|
||
SessionID: token.GetSessionId(),
|
||
AccessToken: token.GetAccessToken(),
|
||
RefreshToken: token.GetRefreshToken(),
|
||
ExpiresInSec: token.GetExpiresInSec(),
|
||
TokenType: token.GetTokenType(),
|
||
IsNewUser: isNewUser,
|
||
ProfileCompleted: token.GetProfileCompleted(),
|
||
OnboardingStatus: token.GetOnboardingStatus(),
|
||
}
|
||
}
|
||
|
||
// clientIP 提取 gateway 看到的客户端地址。
|
||
func clientIP(request *http.Request) string {
|
||
for _, header := range []string{"X-Trusted-Client-IP", "X-Real-IP"} {
|
||
if ip := normalizeIP(request.Header.Get(header)); ip != "" {
|
||
return ip
|
||
}
|
||
}
|
||
forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
|
||
if forwarded != "" {
|
||
parts := strings.SplitSeq(forwarded, ",")
|
||
for part := range parts {
|
||
ip := normalizeIP(part)
|
||
if ip == "" {
|
||
continue
|
||
}
|
||
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
|
||
return ip
|
||
}
|
||
}
|
||
}
|
||
|
||
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||
if err != nil {
|
||
return normalizeIP(request.RemoteAddr)
|
||
}
|
||
|
||
return host
|
||
}
|
||
|
||
func normalizeIP(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if host, _, err := net.SplitHostPort(value); err == nil {
|
||
value = host
|
||
}
|
||
if addr, err := netip.ParseAddr(value); err == nil {
|
||
return addr.String()
|
||
}
|
||
return value
|
||
}
|
||
|
||
func isPublicClientAddr(addr netip.Addr) bool {
|
||
return addr.IsValid() &&
|
||
addr.IsGlobalUnicast() &&
|
||
!addr.IsPrivate() &&
|
||
!addr.IsLoopback() &&
|
||
!addr.IsLinkLocalUnicast() &&
|
||
!addr.IsLinkLocalMulticast() &&
|
||
!addr.IsMulticast() &&
|
||
!addr.IsUnspecified()
|
||
}
|
||
|
||
func countryByIP(request *http.Request) string {
|
||
// country_by_ip 不接受 JSON body;只采信 gateway/边缘层写入的地域头。
|
||
for _, header := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Vercel-IP-Country", "X-AppEngine-Country", "X-Country-Code"} {
|
||
country := strings.ToUpper(strings.TrimSpace(request.Header.Get(header)))
|
||
if country == "" || country == "XX" || country == "UNKNOWN" {
|
||
continue
|
||
}
|
||
if len(country) >= 2 && len(country) <= 3 {
|
||
return country
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|