主题
登录方式改造 · 技术方案
版本: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.kt | register / login / loginEmailVerify / refreshToken / enable2fa 等 |
| 鉴权逻辑 | wallet-logic-service/.../user/service/impl/UserAuthServiceImpl.kt | issueToken() 签发 JWT、registerByEmail() |
| 账号逻辑 | .../user/service/impl/UserAccountServiceImpl.kt | 密码哈希(当前 MD5+盐)、校验 |
| 账号表 | wallet-base/wallet-front-dal/.../user/model/UserAccount.kt → t_user_account | email / password / salt 等 |
| JWT 工具 | wallet-base/wallet-support/.../util/JwtUtil.kt | EC(ES256) 私钥签发,tokenId 存 Redis LOGIN_TOKEN:{userId}:{origin} |
| 资产初始化 | iAssetService.initCryptoAssets(userId, agencyId) | 注册后初始化 USDT/USDC 账本 |
| 2FA | GoogleAuthUtil.kt + t_user_info.twoFactorSecret | TOTP,登录后二次校验 |
核心设计原则:新登录方式只负责"证明你是谁",证明完之后一律复用 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() 出 JWT3.2 关键点
- 必须在后端验
id_token,绝不能只信前端传来的 email。用 Google 的 JWKS(https://www.googleapis.com/oauth2/v3/certs)验签,aud必须等于自己的 Client ID(防止别的 App 的 token 冒用)。 - 可直接用
google-api-client(GoogleIdTokenVerifier)省掉手写验签。 - 前端 Web 用 Google Identity Services;iOS 用
GoogleSignInSDK,拿到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 专属坑:
- 姓名/邮箱只在"第一次授权"返回:Apple 只在用户首次同意时把
name、email回给你,之后再登录只有sub。→ 首次必须落库,错过就再也拿不到。 - 中继邮箱(Private Relay):用户可能选"隐藏我的邮箱",你拿到的是
xxx@privaterelay.appleid.com。要能正常存、正常发信(若要给 relay 邮箱发邮件,需在 Apple 后台配置并验证发信域名)。 - 验签公钥:用 Apple 的 JWKS(
https://appleid.apple.com/auth/keys),iss==https://appleid.apple.com,aud== 你的 App Client ID / Service ID。
4.2 平台差异
| 平台 | 用什么 |
|---|---|
| iOS App | AuthenticationServices 原生 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:00Z6.3 后端验签
- Java/Kotlin 用 web3j:
Sign.signedPrefixedMessageToKey(message, signatureData)恢复公钥 →Keys.getAddress()得地址 → 与传入address比对(都转小写)。 - 仓库当前没有 web3j,需在
wallet-support的pom.xml加org.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的邮箱框下方加分隔线「—— 或 ——」+ 三个按钮:Google、Apple、Connect Wallet(原型已画好,见交互原型)。 - 新增
SocialLoginButtons.tsx(封装 GIS / Apple JS / wagmi 触发)。 - 新增"补填邀请码"弹窗
CompleteInviteModal.tsx:当后端返回needInviteCode时弹出。 - 复用现有 2FA 弹窗逻辑(社交/钱包登录后若账号开了 2FA,同样要过)。
8. 安全要点(务必)
| 项 | 要求 |
|---|---|
| 后端验 token | Google/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.clientId、oauth.apple.clientId/serviceId、walletconnect.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 Wallet | nonce + 验签 + web3j | wagmi/RainbowKit | P1 |
| 密码 MD5→bcrypt | 平滑升级逻辑 | 无 | P1.5 顺带 |
上线顺序:Google+Apple+补填邀请码(P0 一起上,否则 iOS 过不了审)→ Connect Wallet(P1)→ 密码升级(P1.5)。