Skip to content

登录方式改造 · 技术方案

版本:v1.0 | 最后更新:2026-07-11 适用:wallet-server(Kotlin/Spring Cloud 后端)/ wallet-pc(Next.js 前端)/ ios 配套:产品方案.md登录方式原型.html一句话:在现有 Email/JWT 体系上,"挂"三个新的登录入口——Google、Apple、钱包签名——三者最终都汇聚到同一个 UserAccount 和同一套 JWT 签发逻辑,做到"多入口、一账号、一套会话"。


0. 现有体系(改造的地基)

先说清楚要在什么之上改,避免重复造轮子:

组件位置作用
用户鉴权接口wallet-front/wallet-front-api/.../apis/user/UserAuthApi.ktregister / login / loginEmailVerify / refreshToken / enable2fa
鉴权逻辑wallet-logic-service/.../user/service/impl/UserAuthServiceImpl.ktissueToken() 签发 JWT、registerByEmail()
账号逻辑.../user/service/impl/UserAccountServiceImpl.kt密码哈希(当前 MD5+盐)、校验
账号表wallet-base/wallet-front-dal/.../user/model/UserAccount.ktt_user_accountemail / password / salt
JWT 工具wallet-base/wallet-support/.../util/JwtUtil.ktEC(ES256) 私钥签发,tokenId 存 Redis LOGIN_TOKEN:{userId}:{origin}
资产初始化iAssetService.initCryptoAssets(userId, agencyId)注册后初始化 USDT/USDC 账本
2FAGoogleAuthUtil.kt + t_user_info.twoFactorSecretTOTP,登录后二次校验

核心设计原则:新登录方式只负责"证明你是谁",证明完之后一律复用 issueToken() 出 JWT、复用 2FA、复用资产初始化。 新方式 = 新的"身份证明来源",不是新的会话体系。


1. 总体架构

                         ┌─────────────────────────────────────────┐
  Email + 密码/验证码 ───▶│                                         │
  Google id_token ───────▶│   身份校验层(新增 3 个 verify 分支)     │
  Apple id_token ────────▶│   → 解析出唯一身份标识 (email / sub /   │──┐
  钱包签名 (SIWE) ────────▶│      wallet address)                    │  │
                         └─────────────────────────────────────────┘  │

                    ┌──────────────────────────────────────────────────────┐
                    │ 账号解析/合并层                                        │
                    │  ① 按 (provider, openid) 查 t_user_oauth → 命中即老用户 │
                    │  ② 未命中但 email 撞库 → 绑定到已有 UserAccount        │
                    │  ③ 全新 → 建 UserAccount(需邀请码)+ initCryptoAssets  │
                    └──────────────────────────────────────────────────────┘

                    ┌──────────────────────────────────────────────────────┐
                    │ 会话层(完全复用现有逻辑)                              │
                    │  issueToken() 出 EC-JWT → Redis 记 tokenId → 2FA 校验   │
                    └──────────────────────────────────────────────────────┘

2. 库表改动

2.1 新增 t_user_oauth(社交账号绑定表)

一个用户可绑定多个第三方(Google + Apple + Email 同一账号),用独立映射表,不污染 t_user_account

sql
CREATE TABLE t_user_oauth (
  id           BIGINT       PRIMARY KEY AUTO_INCREMENT,
  user_id      BIGINT       NOT NULL COMMENT '关联 t_user_account.id',
  provider     VARCHAR(16)  NOT NULL COMMENT 'google / apple',
  openid       VARCHAR(191) NOT NULL COMMENT '第三方唯一标识:Google sub / Apple sub',
  email        VARCHAR(191)          COMMENT '第三方返回的邮箱(Apple 可能是中继邮箱)',
  union_id     VARCHAR(191)          COMMENT '预留',
  created_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_provider_openid (provider, openid),
  KEY idx_user (user_id)
) COMMENT '第三方登录绑定';

2.2 新增 t_user_wallet_auth(钱包登录绑定表)

注意:这是登录用的钱包地址绑定,和"充值地址(Pay Gateway 下发)"是两码事,不要混。

sql
CREATE TABLE t_user_wallet_auth (
  id           BIGINT       PRIMARY KEY AUTO_INCREMENT,
  user_id      BIGINT       NOT NULL,
  address      VARCHAR(64)  NOT NULL COMMENT '钱包地址(统一小写存储)',
  chain_type   VARCHAR(16)  NOT NULL DEFAULT 'EVM' COMMENT 'EVM / TRON',
  created_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_address (address, chain_type),
  KEY idx_user (user_id)
) COMMENT '钱包签名登录绑定';

2.3 t_user_account 兼容性调整

  • password / salt 允许为空:Google/Apple/钱包用户可能从没设过密码(passwordless)。现有 loginEmailVerify 已经能识别"无密码账号",逻辑上兼容。
  • 建议加 register_source VARCHAR(16)email/google/apple/wallet)便于运营归因。

3. Google 登录(OIDC)

3.1 流程

前端(GIS SDK) ──拿到 id_token──▶ 后端 POST /user/auth/oauth/google { idToken }
后端:
  1. 校验 id_token 签名(用 Google 公钥 JWKS,缓存)
  2. 校验 aud == 我方 Google Client ID、iss == accounts.google.com、exp 未过期
  3. 取 payload.sub(Google 唯一 ID)、email、email_verified
  4. 走 §5 账号解析/合并 → issueToken() 出 JWT

3.2 关键点

  • 必须在后端验 id_token,绝不能只信前端传来的 email。用 Google 的 JWKS(https://www.googleapis.com/oauth2/v3/certs)验签,aud 必须等于自己的 Client ID(防止别的 App 的 token 冒用)。
  • 可直接用 google-api-clientGoogleIdTokenVerifier)省掉手写验签。
  • 前端 Web 用 Google Identity Services;iOS 用 GoogleSignIn SDK,拿到 idToken 后同样打这个后端接口。

3.3 后端接口(示意,挂在 UserAuthApi.kt

kotlin
@PostMapping("/oauth/google")
fun loginWithGoogle(@RequestBody req: OAuthLoginRequest): ApiResult<LoginVo> {
    val payload = googleTokenVerifier.verify(req.idToken)          // 验签+aud+exp
        ?: return ApiResult.fail("INVALID_GOOGLE_TOKEN")
    return oauthLoginService.loginOrRegister(
        provider = "google",
        openid   = payload.subject,
        email    = payload.email,
        inviteCode = req.inviteCode,      // 首次注册时前端带上(见 §5/§7)
        origin   = req.origin
    )
}

4. Apple 登录(Sign in with Apple)

4.1 流程

和 Google 类似(也是 OIDC,验 id_token),但有 3 个 Apple 专属坑

  1. 姓名/邮箱只在"第一次授权"返回:Apple 只在用户首次同意时把 nameemail 回给你,之后再登录只有 sub。→ 首次必须落库,错过就再也拿不到。
  2. 中继邮箱(Private Relay):用户可能选"隐藏我的邮箱",你拿到的是 xxx@privaterelay.appleid.com。要能正常存、正常发信(若要给 relay 邮箱发邮件,需在 Apple 后台配置并验证发信域名)。
  3. 验签公钥:用 Apple 的 JWKS(https://appleid.apple.com/auth/keys),iss == https://appleid.apple.comaud == 你的 App Client ID / Service ID。

4.2 平台差异

平台用什么
iOS AppAuthenticationServices 原生 ASAuthorizationAppleIDProvider,拿 identityToken 打后端
Web (wallet-pc)Sign in with Apple JS,配 Service ID + Return URL;拿 id_token 打后端

4.3 合规红线

iOS App Store 审核规则 4.8:App 只要提供了第三方登录(如 Google),就必须同时提供 Sign in with Apple。→ Google 和 Apple 在 iOS 上要一起上,不能只上 Google。


5. 账号解析 / 合并逻辑(三种登录共用)

这是最需要写对的部分,统一封装成 oauthLoginService.loginOrRegister(...)

kotlin
fun loginOrRegister(provider, openid, email, inviteCode, origin): LoginVo {
    // ① 已绑定过 → 直接登录
    val bound = userOauthMapper.findByProviderOpenid(provider, openid)
    if (bound != null) return issueLogin(bound.userId, origin)

    // ② 未绑定,但邮箱撞库 → 绑定到已有账号(同一个人,不建第二个钱包)
    val existed = email?.let { userAccountService.findByEmail(it) }
    if (existed != null) {
        userOauthMapper.insert(provider, openid, email, existed.id)
        return issueLogin(existed.id, origin)
    }

    // ③ 全新用户 → 必须有邀请码(代理归属,见产品方案 §4)
    if (inviteCode.isNullOrBlank()) return LoginVo.needInviteCode()   // 前端跳"补填邀请码"
    val agencyId = agencyService.resolveByInviteCode(inviteCode)
        ?: return LoginVo.invalidInviteCode()
    val userId = userAccountService.createPasswordless(email, agencyId, registerSource = provider)
    userOauthMapper.insert(provider, openid, email, userId)
    iAssetService.initCryptoAssets(userId, agencyId)                  // 复用现有资产初始化
    return issueLogin(userId, origin)
}

private fun issueLogin(userId, origin): LoginVo {
    // 复用现有:2FA 判断 + issueToken()
    if (userAuthService.need2fa(userId)) return LoginVo.need2fa(tempToken)
    return LoginVo.ok(userAuthService.issueToken(userId, origin))
}

要点:

  • 邮箱撞库自动合并(②)——同一个人用 Email 注册过、又用 Google 登录,应落到同一 userId,不能产生两个钱包/两份资产。
  • 新用户必须有邀请码(③)——对应产品方案 §4 的"补填邀请码"。返回 needInviteCode 让前端弹补填页,用户填完再带 inviteCode 重打接口。
  • 全程复用 issueToken() / initCryptoAssets() / 2FA,不新增会话体系

6. 钱包签名登录(SIWE / Sign-In with Ethereum)

6.1 原理:用"签名"证明你拥有这个地址的私钥

不上传私钥,只做一次"挑战-签名-验签":

① 前端 POST /user/auth/wallet/nonce { address }
   后端生成随机 nonce,存 Redis(key=WALLET_NONCE:{address},TTL 5min),返回一段待签名文本
② 用户在 MetaMask/OKX 点"签名"(personal_sign),得到 signature
③ 前端 POST /user/auth/wallet/verify { address, signature }
   后端:ecrecover(签名, 文本) 得到地址 → 与 address 比对一致 → 校验 nonce 未用过
   → 走 §5 账号解析(按 address 查 t_user_wallet_auth)→ issueToken()

6.2 待签名文本(防钓鱼、防重放)

遵循 EIP-4361 格式,包含域名、nonce、时间:

cardplus.com wants you to sign in with your Ethereum account:
0xAbc...123

Sign in to CardPlus. This request will not trigger any blockchain
transaction or cost any gas fee.

URI: https://cardplus.com
Nonce: 8f3a...(一次性,验完即删)
Issued At: 2026-07-11T08:00:00Z

6.3 后端验签

  • Java/Kotlin 用 web3jSign.signedPrefixedMessageToKey(message, signatureData) 恢复公钥 → Keys.getAddress() 得地址 → 与传入 address 比对(都转小写)。
  • 仓库当前没有 web3j,需在 wallet-supportpom.xmlorg.web3j:core(仅用其 crypto 工具做验签,不连节点,轻量)。
  • nonce 必须一次性:验完立即从 Redis 删,防重放。

6.4 前端

  • wallet-pc(Next.js)用 wagmi + RainbowKit / WalletConnect v2:一个「Connect Wallet」按钮即可覆盖 MetaMask、OKX、手机扫码。
  • 首次连钱包的新用户同样要走"补填邀请码"(§5 的 ③)。

7. 前端改造(wallet-pc)

登录页现有结构:AuthLayout(左视觉栏 + 右 login_box)+ LoginForm(Email)。改动:

  • LoginForm 的邮箱框下方加分隔线「—— 或 ——」+ 三个按钮:GoogleAppleConnect Wallet(原型已画好,见交互原型)。
  • 新增 SocialLoginButtons.tsx(封装 GIS / Apple JS / wagmi 触发)。
  • 新增"补填邀请码"弹窗 CompleteInviteModal.tsx:当后端返回 needInviteCode 时弹出。
  • 复用现有 2FA 弹窗逻辑(社交/钱包登录后若账号开了 2FA,同样要过)。

8. 安全要点(务必)

要求
后端验 tokenGoogle/Apple 的 id_token 必须后端验签 + 验 aud/iss/exp,绝不能只信前端传的 email
nonce 一次性钱包登录 nonce 验完即删,TTL≤5min,防重放
签名文本绑定域名SIWE 文本含域名/URI,防跨站钓鱼签名
邮箱撞库合并社交邮箱 == 已有 Email 账号 → 合并,禁止重复建号
2FA 一致性新登录方式登录后同样触发已开启的 TOTP 2FA
密码哈希升级(顺带做)现为 MD5(password+salt),偏弱。建议改 bcrypt:登录校验时若发现是旧 MD5,验证通过后静默重算为 bcrypt 回写,实现无感平滑迁移
地址统一小写EVM 地址大小写敏感问题,入库/比对统一 toLowerCase()

密码平滑升级示意

kotlin
fun checkAndUpgrade(input: String, acc: UserAccount) {
    val ok = when (acc.hashAlgo) {
        "bcrypt" -> BCrypt.checkpw(input, acc.password)
        else     -> md5(input + acc.salt) == acc.password    // 旧数据
    }
    if (ok && acc.hashAlgo != "bcrypt") {
        acc.password = BCrypt.hashpw(input, BCrypt.gensalt()) // 静默升级
        acc.hashAlgo = "bcrypt"; userAccountMapper.update(acc)
    }
}

9. 需要的外部配置 / 准备

说明
Google Cloud 项目建 OAuth 2.0 Client ID(Web 一个 + iOS 一个),配授权域名/回调
Apple Developer开启 Sign in with Apple;Web 需建 Service ID + 配 Return URL + 私钥(.p8);配中继邮箱发信域名
WalletConnect申请 Project ID(v2 必需)
后端配置项oauth.google.clientIdoauth.apple.clientId/serviceIdwalletconnect.projectId;沿用现有 application.yml 配置方式
依赖后端加 google-api-client(验 Google token)、web3j:core(钱包验签);前端加 @react-oauth/google、Apple JS、wagmi+@rainbow-me/rainbowkit

10. 工作量拆解(供排期)

模块后端前端备注
Google 登录验签接口 + 账号合并GIS 按钮P0
Apple 登录验签接口(复用合并逻辑)Apple JS/iOS 原生P0,iOS 强制
补填邀请码needInviteCode 分支补填弹窗P0,代理归属根基
Connect Walletnonce + 验签 + web3jwagmi/RainbowKitP1
密码 MD5→bcrypt平滑升级逻辑P1.5 顺带

上线顺序:Google+Apple+补填邀请码(P0 一起上,否则 iOS 过不了审)→ Connect Wallet(P1)→ 密码升级(P1.5)。