上周我给自己的博客系统 BitLog 换掉了原来的用户名登录,改为邮箱登录,并接入了 Google 第三方登录。
这篇文章不会怎么在 Google Cloud Console 里申请 OAuth 客户端,那类步骤各处都有,而且随控制台改版很快过期。我想记录的是账号模型层面的几个决定:为什么这么建表,以及每个决定挡住了什么问题。
技术栈是 Java 21、Spring Boot 3.5、Spring Security、MyBatis-Plus、MySQL、Redis。
一、动手顺序:先定模型,再写流程
先说改动的顺序。
第一个提交不是"把登录接口的用户名换成邮箱",而是一个数据库迁移。这个迁移里做了三件事:user_account 加 email_verified 字段、password 改为可空、新建 user_identity 表。
sql
ALTER TABLE `user_account`
ADD COLUMN `email_verified` tinyint NOT NULL DEFAULT 0
COMMENT '邮箱是否已验证:0-未验证,1-已验证' AFTER `email`;
ALTER TABLE `user_account`
MODIFY COLUMN `email` varchar(128) DEFAULT NULL COMMENT '登录邮箱,统一小写存储';
ALTER TABLE `user_account`
MODIFY COLUMN `password` char(60) DEFAULT NULL
COMMENT '密码,BCrypt 加密;第三方登录用户为空';
CREATE TABLE `user_identity`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint unsigned NOT NULL COMMENT '用户ID(逻辑关联 user_account.id)',
`provider` varchar(32) NOT NULL COMMENT '第三方平台标识:google',
`provider_user_id` varchar(128) NOT NULL COMMENT '第三方平台用户唯一ID(Google 为 sub)',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_provider_uid` (`provider`, `provider_user_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB COMMENT ='第三方身份关联表';
Google 登录的代码是两天后才写的,在第三方登录还一行代码都没有的时候,存放第三方身份的表就已经建好了。
认证流程的每一处分支(建号、并号、绑定、解绑)都要读写这几张表,需要先想清楚数据怎么存,后面的分支判断基本是自然推导出来的;反过来先写流程再回头改表,改的是已经落地的数据。
但先建模能省掉的只是一类返工。它保证了"流程写不下去"这种阻塞不会发生,不保证模型本身是对的。第六节讲的就是一个先建了模、照样漏掉的约束,那次漏掉的代价同样是一次迁移。这两件事要分开看:建模顺序解决的是依赖问题,模型正确性要靠别的手段发现。
下面三节是这三个字段/表各自的理由,第六节是那个反例。
二、决定一:第三方身份独立成表
最省事的做法是在 user_account 上加一列 google_id。我没这么做,原因有三个:
多平台要加多列。 Google、GitHub、微信,每接一个加一列,每列都稀疏,所以绝大多数行都是 NULL。
平台侧的元数据没地方放。 除了平台的用户唯一标识,还需要存这个第三方账号的数据(后面会讲为什么),比如 Google 登录的方案就要变成 google_id + google_email 两列,乘以平台数。
绑定关系是有生命周期的实体。 用户会绑、会解绑,绑定时间需要记录。这些是行的属性,不是列的属性。
独立表还顺带给出了一个干净的约束位置。最终的模型是这样(provider_email 和第二个唯一约束是后来补的,见第六节):

两个唯一约束各管一个方向:
(provider, provider_user_id)一个 Google 账号只能绑一个本站账号,否则登录时无从判断该进哪个(user_id, provider)一个本站账号在一个平台只能绑一个号
还要注意 user_account.email 和 user_identity.provider_email 是两个互不相干的值。前者是本站的登录凭证,后者只是"绑定的第三方 Google 账号"的显示文本。第六、七节会用到这个区分。
对应到代码,第三方登录的入口第一件事就是查这张表:
java
@Transactional(rollbackFor = Exception.class)
@Override
public LoginResult loginWithOAuth(OAuthLoginParam param) {
UserIdentityDO identity = userIdentityDAO.getByProvider(param.getProvider(), param.getProviderUserId());
if (identity != null) {
return issueTokenForUser(identity.getUserId());
}
// ...首次登录,走建号或并号
}
老用户直接凭平台唯一标识换 token,不碰邮箱。邮箱只在首次登录时用一次,用来决定是并入既有账号还是新建账号。第四节会展开这个区分。
三、决定二:password 允许为空
通过 Google 注册的用户没有密码,所以需要把 password 列改成 NULL。
留空之后,密码登录这条路自然走不通:
java
private Long doCreateUser(String email, String username, String rawPassword, UserRoleEnum role) {
// 第三方登录建号时没有密码,留空即可:Bcrypt 对空密文一律返回不匹配,密码登录自然走不通
String encodedPassword = rawPassword != null ? passwordEncoder.encode(rawPassword) : null;
UserDO newUser = new UserDO().setUsername(username).setEmail(email).setPassword(encodedPassword);
userDAO.save(newUser);
UserInfoDO userInfo = new UserInfoDO().setUserId(newUser.getId()).setAvatar("").setUserRole(role);
userInfoDAO.save(userInfo);
return newUser.getId();
}
不需要额外的分支去拦截"无密码用户尝试密码登录",passwordEncoder.matches() 对空密文返回 false 就够了。
"密码是否为空"这个信号在两个地方被用到。
首次设置密码,不能走修改密码的接口。 修改密码要校验旧密码,无密码用户没有旧密码可填,所以需要一个单独的 initPassword。但这个接口必须拒绝已有密码的用户:
java
// 已有密码只能走带旧密码校验的修改流程,否则会话一旦被劫持即可静默改掉密码
if (StringUtils.hasText(user.getPassword())) {
throw ResultCode.OPERATION_NOT_ALLOWED.toException("已设置密码,请使用修改密码");
}
如果不拦这一下,initPassword 就成了一个不需要旧密码的改密接口。拿到会话的攻击者可以直接把密码改掉,完成账号接管。
解绑第三方账号,要保证还剩至少一种登录方式。
java
List<UserIdentityDO> identities = userIdentityDAO.listByUserId(userId);
// 解绑后须至少保留一种登录方式:既无密码又无其他绑定时放行,账号将永久无法登录
boolean hasOtherIdentity = identities.stream().anyMatch(identity -> !identity.getProvider().equals(provider));
if (!StringUtils.hasText(user.getPassword()) && !hasOtherIdentity) {
throw ResultCode.OPERATION_NOT_ALLOWED.toException("解绑后将无法登录,请先设置密码");
}
一个只用 Google 登录、从没设过密码的用户,解绑 Google 之后账号就永久锁死了,这个错误一旦发生无法自助恢复。
如果当初给第三方用户生成了随机密码,这两处判断都写不出来。
第三方用户的用户名从哪来
Google 的 ID Token 里有 name,但它不满足用户名的约束(可能含空格、可能超长、可能重复、可能为空)。所以需要一个生成器,依次降级:第三方昵称(滤掉非法字符后)→ 邮箱前缀 → 前缀加四位随机数,最多重试 5 次,仍然拿不到就报错。
真正值得说的是它的边界:
java
/**
* 依次尝试第三方昵称、邮箱前缀,都不可用时退化为带随机后缀的名字。
*/
public String generate(String preferredName, String email) { ... }
这里的"未被占用"只在检查那一刻成立。两个请求同时通过检查、同时插入,后一个照样会撞唯一索引。生成器只负责给出一个大概率可用的名字,唯一性由数据库的唯一索引兜底。
把"先检查再插入"当成原子操作是并发下的经典错误。检查的作用是降低冲突概率,不是保证唯一,真正的保证只能在数据库那一层。
四、决定三:把"验证过"和"有邮箱"分开(只做了一半)
这一节是整个改造里最需要小心的地方,也是三个决定里唯一没做完的一个。先把结论放前面:原则在第三方登录这条链路上落地了,在本站注册那条链路上还欠着,欠的部分在第八节。
第三方登录首次进来时,本站没有这个 (provider, providerUserId) 的记录,此时要在两条路里选一条:新建一个账号,或者并入某个已有账号。
判断依据只能是邮箱。如果同一个人先用邮箱注册过,再用 Google 登录,直接建新号会造成两个孤立账号。数据分散在两边,所以按邮箱并号是必要的。
但这就产生了一个攻击面:如果邮箱没有经过平台验证,任何人都可以在第三方平台注册一个填着别人邮箱的账号,然后用它登进对方的本站账号。
Google 的 ID Token 里有 email_verified 字段,说明这个邮箱是否被 Google 验证过。这个字段必须校验:
java
// 邮箱是下面合并账号与建号的唯一依据,未经平台验证就用它等于让任何人
// 注册一个填着他人邮箱的第三方账号,即可登进对方账号
if (!param.isEmailVerified()) {
ResultCode.OAUTH_EMAIL_UNVERIFIED.throwException();
}
// 同一个人先用邮箱注册、后用第三方登录,按邮箱并入既有账号,避免产生两个孤立账号
String email = EmailUtil.normalize(param.getEmail());
UserDO existing = email != null ? userDAO.getByEmail(email) : null;
if (existing != null) {
if (userIdentityDAO.existsByUserAndProvider(existing.getId(), param.getProvider())) {
ResultCode.OPERATION_NOT_ALLOWED.throwException("该邮箱对应的账号已绑定其他账号,请用原账号登录后处理");
}
userIdentityDAO.bind(existing.getId(), param.getProvider(), param.getProviderUserId(), email);
return issueTokenForUser(existing.getId());
}
校验的位置很关键:它在并号逻辑之前、老用户直接登录之后。已经绑定过的用户走第一段 identity != null 就返回了,不受影响。他们的身份凭据是平台唯一标识,不是邮箱。只有邮箱要被当作身份依据时,才需要它经过验证。
背后的原则是:"填了邮箱"和"证明了这个邮箱是我的"是两件事。 把它们合并成一个状态,就没法表达"这个邮箱还不能作为身份依据"。
之所以"只做了一半",是因为这里校验的 param.isEmailVerified() 来自 Google ID Token 的 email_verified claim,是每次授权回调时现拿的,和建表时加的那个 user_account.email_verified 列不是一回事。那个列当初是按同一个原则预留的,但到今天为止应用代码从没读写过它。本站注册走验证码,验证通过后也没有回写。原则本身是对的,落地只落了一边。
邮箱归一化必须只有一份实现
Foo@Gmail.com 和 foo@gmail.com 是同一个邮箱。如果注册时归一化了、登录时没归一化,用户注册完就登不进去;反过来,如果注册时没归一化、唯一索引就形同虚设,同一个邮箱能用不同大小写注册出多个账号。
一开始归一化逻辑散在各个方法里,后来抽成了工具类:
java
/**
* 归一化邮箱:去首尾空白并转小写。
* 注册、登录、改绑必须共用同一份实现------两处逻辑一旦出现分歧,
* 就能用大小写变体绕过唯一性检查,在库里造出同一邮箱的多个账号。
*/
public static String normalize(String email) {
return StringUtils.hasText(email) ? email.trim().toLowerCase(Locale.ROOT) : null;
}
实现只有一行,但它必须是全局唯一的一行。这类"逻辑很简单但必须处处一致"的规则,是最适合抽工具类的场景。
同期还给 user_account.email 补了唯一索引作为最后兜底。应用层的检查总有并发窗口,改邮箱的流程尤其明显:
java
// 发码到提交之间有验证码有效期那么长的窗口,其间该邮箱可能被他人注册走,
// 故此处重查一次,唯一索引为最后兜底
String newEmail = checkEmailAvailable(userId, req.getEmail());
verificationCodeService.verify(VerifyCodePurpose.CHANGE_EMAIL, newEmail, req.getCode());
userDAO.updateEmail(userId, newEmail);
发码时查一次、提交时再查一次,中间仍有窗口,唯一索引负责堵死它。
五、验证码的滥用面
改成邮箱验证码之后,系统多了一个会产生真实成本的公开接口:任何人调一次,就发出一封邮件。围绕这一点做了几层防护。
限流要分维度,因为挡的是不同的东西
限流不是设一个数就完了。这里有三个维度,各自挡的攻击不一样,少任何一个都有绕过路径:
- IP 维度(每小时 20 封)挡的是枚举,防止一个脚本按邮箱列表挨个试。这种攻击里邮箱一直在变,邮箱维度的限流形同虚设,只有按来源算的这一层能起作用。
- 邮箱 + 用途维度(60 秒冷却 + 日配额)挡的是轰炸,防止持续对同一个受害者邮箱发信。这种攻击里 IP 限流可能完全无效:只要攻击者手上有一批 IP,每个 IP 只发一封,全都合规,受害者收件箱照样被塞满。所以必须有一层是按受害者算的。
- 错误次数(同一验证码累计错 5 次即作废)挡的是爆破。前两个维度限制的是发信,这个限制的是猜测。
三个维度正交,各挡各的,少一个就有一条完整的绕过路径。
需要说清楚的是,没有哪一层是封死的。IP 是最容易换的东西,代理池、云主机、住宅代理,成本都不高,所以第一层挡得住随手写的脚本,挡不住真想搞你的人。限流的作用是抬高成本、把滥用压到可接受的量级,不是把攻击变成不可能,所以指望某一层"绝对安全"是不可能的。
至于具体取值,比如 20、60 秒、5 次。我没有真实流量来校准,选的是"正常用户不会撞到、攻击者会觉得难受"的量级。真上了量应该按实际的误伤率调,这里的数字不值得抄。有价值的是维度的划分,不是数值。
限流必须在业务判断之前
这是我改了一遍才想明白的地方。最初的写法是先查邮箱是否已注册,再限流:
java
// 错误的顺序
if (userDAO.isEmailTaken(email)) {
ResultCode.USER_ALREADY_EXISTS.throwException(email); // 提前返回,没走到限流
}
verificationCodeService.issueAndSend(...);
问题在于,"邮箱已注册"这个分支提前返回了,根本没走到限流代码。攻击者可以无限次调用这个接口,用返回结果枚举出哪些邮箱在本站注册过。
正确的顺序是 IP 限流在最前面,任何业务判断之前:
java
public void sendRegisterCode(String email) {
String normalizedEmail = EmailUtil.normalize(email);
verificationCodeService.guardSendIp(); // 必须在存在性判断之前
if (userDAO.isEmailTaken(normalizedEmail)) {
ResultCode.USER_ALREADY_EXISTS.throwException(normalizedEmail);
}
verificationCodeService.issueAndSend(VerifyCodePurpose.REGISTER, normalizedEmail);
}
所以 IP 限流和邮箱维度的配额被拆成了两个方法:guardSendIp() 在最前面挡住枚举,issueAndSend() 里的冷却和日配额只在确定要发信时才消耗,否则一个不存在的邮箱也会占掉配额。
同样的道理,找回密码接口对不存在的邮箱是静默返回的:
java
public void sendResetPasswordCode(String email) {
String normalizedEmail = EmailUtil.normalize(email);
verificationCodeService.guardSendIp();
if (userDAO.getByEmail(normalizedEmail) == null) {
return; // 不报错,不提示"该邮箱未注册"
}
verificationCodeService.issueAndSend(VerifyCodePurpose.RESET_PASSWORD, normalizedEmail);
}
用户体验上略有损失(真的填错邮箱时没有提示),但换来的是不泄露"哪些邮箱注册过"。注册接口做不到静默,因为用户必须知道邮箱被占用了,所以只能靠限流兜住。
校验环节的两个细节
java
if (!rateLimiter.tryAcquire(attemptsKey, CODE_MAX_ATTEMPTS, CODE_TTL).allowed()) {
redisTemplate.delete(List.of(codeKey, attemptsKey));
ResultCode.VERIFY_CODE_INVALID.throwException("验证码错误次数过多,请重新获取");
}
if (stored == null || !stored.equals(input)) {
ResultCode.VERIFY_CODE_INVALID.throwException();
}
第一,验证码在 Redis 里是明文存的。6 位数字的取值空间只有 100 万,哈希之后彩虹表几秒就能反查,加密存储没有实际意义。真正防爆破的是失败次数上限,所以次数判断放在比对之前,错够 5 次就作废验证码本身,不给继续猜的机会。
第二,"没申请过验证码"和"验证码填错了"走的是同一个错误码。如果区分开,就能用它探测某个邮箱是否正在走注册或找回密码流程。
邮件本身
java
@Async
@Override
public void sendVerificationCode(String to, String action, String code, Duration ttl) {
String brand = mailProperties.getFromName();
// 验证码前置于主题,用户在通知栏即可读到,无需打开邮件
String subject = "%s 是你的 %s 验证码".formatted(code, brand);
doSend(to, subject, MailTemplates.verificationCodeHtml(...),
MailTemplates.verificationCodeText(...), "验证码邮件-" + action);
}
HTML 和纯文本同时发。 通行的说法是纯 HTML 邮件在部分客户端和垃圾邮件过滤器上可能表现更差。
HTTP 客户端必须设超时。 这个是踩过的:
java
/** 必须设超时:默认无超时,服务端挂起会占死异步线程池,最终静默停止发信 */
private static RestClient buildRestClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
return RestClient.builder().requestFactory(factory).build();
}
发信是 @Async 的,走的是共享线程池。如果第三方服务挂起而客户端没有超时,线程会一直卡在那里,池子占满之后整个系统的异步任务全部停摆。而且是静默的,日志里什么都不会有。
日志不能记邮件主题。 因为验证码被前置到主题里了,打印主题等于把凭证写进日志:
java
if (!StringUtils.hasText(mailProperties.getApiKey())) {
log.warn("未配置 mail.api-key,邮件未发送 to={} type={}", MaskUtil.email(to), logLabel);
// 正文含验证码,仅 DEBUG 输出,避免生产漏配时把凭证写进日志
log.debug("未发送的邮件正文 to={}\n{}", MaskUtil.email(to), text);
return;
}
本地开发时需要看到验证码内容(没配 API Key 时邮件发不出去),但这行日志必须是 DEBUG 级别。如果是 INFO,生产环境万一漏配了 API Key,所有验证码就会明文写进日志文件。收件人地址一律走 MaskUtil.email() 脱敏。
六、一个功能上不报错的 bug
前面三节说的都是提前想到的。这一节是没想到的,也是第一节那个说法的反例:表是先建的,该漏的照样漏了。
user_identity 建表时只有一个唯一约束:(provider, provider_user_id)。它保证了一个 Google 账号不会绑到两个本站账号上。
但反方向没约束:同一个本站账号可以绑定任意多个 Google 账号。
这个 bug 的暴露路径值得记一下。功能测试是完全通过的,绑两个 Google 账号,两个都能登进同一个本站账号,逻辑上说得通,没有任何报错。
它是在写"账号设置"页面时暴露的。这个页面要显示"当前绑定的 Google 账号",而这句话没法翻译成一个确定的值:绑了两个就得显示两个,可用户认知里"Google 账号"只有一个。
界面表达不出来的状态,往往是数据模型本就不该允许的状态。
修复是一个迁移:
sql
-- 第三方侧邮箱与本站登录邮箱无从属关系,用户可用 A 邮箱注册、绑定 B 邮箱的 Google,
-- 需存下来供界面展示究竟绑的是哪个第三方账号
ALTER TABLE `user_identity`
ADD COLUMN `provider_email` varchar(128) DEFAULT NULL
COMMENT '第三方平台侧邮箱,与本站登录邮箱无从属关系' AFTER `provider_user_id`;
-- 一个账号在同一平台只应绑一个第三方号。原表仅约束 (provider, provider_user_id),
-- 同一账号可重复绑多个 Google:登录都能进,但界面无从表达当前绑的是哪一个
ALTER TABLE `user_identity`
ADD UNIQUE KEY `uk_user_provider` (`user_id`, `provider`);
两个改动是同一个问题的两面。加唯一约束限定"一个账号一个平台只绑一个";加 provider_email 是因为界面要显示"绑的是哪个 Google 账号",而这个邮箱和本站登录邮箱没有从属关系。用户完全可以用工作邮箱注册本站、绑私人邮箱的 Google,两者都是合法的独立值,所以必须单独存。
约束加上之后,业务代码里多了两处对应的拦截。一处在绑定流程:
java
if (userIdentityDAO.existsByUserAndProvider(userId, param.getProvider())) {
throw ResultCode.OPERATION_NOT_ALLOWED.toException("该平台账号已被其他账号绑定");
}
另一处在第三方登录的并号分支:
java
// 该账号可能已绑同平台的另一个号(绑定不要求两侧邮箱一致),再并入会撞 uk_user_provider。
// 拦在这里是为了给出可读原因,否则用户只会看到回跳后的 server_error
if (userIdentityDAO.existsByUserAndProvider(existing.getId(), param.getProvider())) {
ResultCode.OPERATION_NOT_ALLOWED.throwException("该邮箱对应的账号已绑定其他账号");
}
不加这个判断,数据库唯一索引也会拦住,但抛出的是 DuplicateKeyException。在 OAuth 回调这条链路上,用户看到的是浏览器跳回前端后的一个 server_error,完全不知道发生了什么。约束保证正确性,业务判断保证可解释性,两者都要。
值得记的不是这个 bug 本身,而是发现它的路径。功能测试只验证"能不能跑通",而跑通不代表状态合法,这个 bug 从头到尾没让任何一条流程失败过。
七、OAuth 回调带不了 JWT
这一节是实现层面的问题,做过第三方绑定(而不只是第三方登录)的人可能会撞上。
问题
登录和绑定,走的是同一套 OAuth 授权流程,回调地址也一样。但它们的语义完全不同:
- 登录:不知道你是谁,用 Google 身份找出或创建一个本站账号
- 绑定:已经知道你是谁(当前有登录态),把一个 Google 身份挂到这个账号上
问题在于,OAuth 授权是浏览器导航 完成的:用户跳去 Google,授权后 Google 把浏览器重定向回你的回调地址。这个过程中,前端没有机会设置请求头,Authorization: Bearer <token> 带不过去。回调请求到达服务端时,服务端不知道这是谁发起的。
解法:用 state 参数捎带一次性令牌
OAuth 协议里的 state 参数本来是防 CSRF 的,它的性质正好可以复用:客户端发起授权时设置,授权服务器原样回传。
于是流程变成:
- 已登录用户点"绑定 Google",前端先调一个接口,服务端生成一个随机令牌,在 Redis 里存
token -> userId,短 TTL - 前端带着这个令牌跳转到授权入口
- 服务端把令牌拼进
state一起发给 Google - Google 回调时原样带回
state,服务端切出令牌,反查出 userId
令牌拼进 state 的地方:
java
resolver.setAuthorizationRequestCustomizer(builder -> {
builder.additionalParameters(params -> params.put("prompt", "select_account"));
String intent = currentBindIntent();
if (StringUtils.hasText(intent)) {
// 覆盖而非追加:customizer 只拿得到 builder,读不出已生成的 state。
builder.state(STATE_KEY_GENERATOR.generateKey()
+ OAuth2SuccessHandler.STATE_INTENT_SEPARATOR + intent);
}
});
Spring Security 在这里有两处限制,写的时候会撞上:AuthorizationRequestCustomizer 拿到的 builder 只能写不能读,取不到框架已生成的 state,所以只能自己生成随机部分再整体覆盖。覆盖时必须自己保证随机强度,state 的防 CSRF 职责不能丢;另外 customizer 签名里没有 HttpServletRequest,发起请求时带的参数只能从 RequestContextHolder 取(RequestContextFilter 的 order 早于 Security 过滤器链,到这里必然已就绪)。
回调侧按分隔符切出令牌,有就是绑定,没有就是登录。令牌是一次性的,取出即删:
java
// 令牌一次性消费:授权链路可能被重放,取完即删使重放无从关联到账号
String userIdValue = redisTemplate.opsForValue().getAndDelete(RedisKeyConstants.getOAuthBindIntentKey(intentToken));
if (userIdValue == null) {
throw ResultCode.INVALID_PARAMETER.toException("绑定请求已失效,请重新发起");
}
Long userId = Long.valueOf(userIdValue);
用 getAndDelete 而不是先读后删,是为了让读取和消费成为一个原子操作。整个授权回调 URL 是可能被重放的(它出现在浏览器历史、可能被日志记录),令牌用过即失效之后,重放的请求关联不到任何账号。
绑定不校验 emailVerified
这一点和第四节形成对照:
java
// 绑定不校验 emailVerified:认的是平台侧唯一标识,邮箱在此只作展示
绑定的时候,本站用户身份来自 intentToken(源头是本站自己的登录态),Google 侧只需要提供一个唯一标识 sub。邮箱在这里只是存下来给界面显示的,不参与任何身份判断,所以不需要验证。
回到第四节的原则:只有当邮箱要被当作身份依据时,才需要它经过验证。 登录时的并号用邮箱决定并入哪个账号,所以必须校验;绑定时邮箱只是展示数据,所以不必。同一个字段在不同流程里的安全要求不同,取决于它承担什么职责。
两个附带的处理
回调处理器不在全局异常处理的覆盖范围内。
java
// 这里位于过滤器链上,GlobalExceptionHandler 覆盖不到;不兜住异常,
// 用户在浏览器导航途中看到的会是容器的 500 错误页而非回跳后的提示
LoginResult result;
try {
result = authService.loginWithOAuth(param);
} catch (BusinessException e) {
log.warn("第三方登录被拒 provider={} code={} message={}", param.getProvider(), e.getCode(), e.getMessage());
redirectWithError(response, ERROR_REJECTED);
return;
} catch (RuntimeException e) {
log.error("第三方登录处理异常 provider={}", param.getProvider(), e);
redirectWithError(response, ERROR_SERVER);
return;
}
@RestControllerAdvice 只覆盖 DispatcherServlet 之后的请求,OAuth 成功处理器在 Security 过滤器链上,异常直接冒到容器。而这条链路上"用户"是一个正在导航的浏览器,抛异常的结果是一个白底黑字的 Tomcat 错误页。所有异常必须自己接住,转成回跳前端时的错误参数。
Access Token 不放进 URL。
java
refreshTokenCookie.write(response, result.refreshToken());
// Access Token 不放进 URL,前端回跳后用 Cookie 里的 Refresh Token 换取,
// 与页面刷新恢复会话走同一条路径
response.sendRedirect(oAuth2Properties.getRedirectUri());
把 token 拼在重定向 URL 上是常见做法,但 URL 会进浏览器历史、可能被 Referer 带出去、可能被中间层日志记录。这里只写一个 HttpOnly 的 Refresh Token Cookie,前端回跳后主动调刷新接口换 Access Token。附带的好处是,这条路径和"用户刷新页面后恢复会话"完全一致,前端不需要为 OAuth 回调写单独的分支。
八、值得优化的地方
只支持 Google 一个 provider。 表结构是按多平台设计的,provider 是字符串字段,但实际只接了一家。第二家接进来之前,不知道这个抽象是否真的够用。比如有些平台不返回邮箱,现在的并号逻辑就要改。
并发建号依赖唯一索引兜底,但没有重试。 第三节提到 UsernameGenerator 返回的用户名在并发下仍可能撞索引,调用方目前没有实现重试,冲突时是直接失败的。
没有登录设备/会话管理。 Refresh Token 存在 Redis 里,但用户看不到自己在哪些设备登录过,也不能远程踢掉某个会话。
限流是固定窗口。 用 Redis 计数器实现的固定窗口,存在窗口边界上双倍放行的问题。对当前量级够用,但不是滑动窗口。
小结
这次改造里真正花时间的不是接第三方登录的流程代码,那部分 Spring Security 已经封装得很完整。花时间的是想清楚几个状态该怎么表达:
- 身份(第三方账号)和账号(本站用户)是两个东西,用独立的表关联,而不是往用户表加列
- "有密码"和"没密码"必须能区分,因为第三方账号登录的时候没有密码
- "有邮箱"和"验证过邮箱"必须能区分,第三方链路上靠 ID Token 的 claim 做到了,本站注册必须要验证码
- 一个账号在一个平台只能绑一个号,这条是事后补的,代价是一次迁移
这些决定单独看都不复杂,但它们决定了后面每一个分支判断能不能写出来。
至于模型本身对不对,第一节已经说了建模顺序保证不了这件事。我这次找到的最好用的检查手段来自第六节:拿界面去逼问模型。把每个要显示的值说清楚"它到底是什么",凡是答不出确定答案的地方,多半就是模型漏了约束。这个办法比功能测试灵敏,因为它问的不是"能不能跑通",而是"这个状态说得通吗"。
本文首发于个人博客:邮箱登录与 Google 登录的账号模型设计与实现