邮箱登录与 Google 登录的账号模型设计与实现

上周我给自己的博客系统 BitLog 换掉了原来的用户名登录,改为邮箱登录,并接入了 Google 第三方登录。

这篇文章不会怎么在 Google Cloud Console 里申请 OAuth 客户端,那类步骤各处都有,而且随控制台改版很快过期。我想记录的是账号模型层面的几个决定:为什么这么建表,以及每个决定挡住了什么问题。

技术栈是 Java 21、Spring Boot 3.5、Spring Security、MyBatis-Plus、MySQL、Redis。

一、动手顺序:先定模型,再写流程

先说改动的顺序。

第一个提交不是"把登录接口的用户名换成邮箱",而是一个数据库迁移。这个迁移里做了三件事:user_accountemail_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.emailuser_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.comfoo@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 的,它的性质正好可以复用:客户端发起授权时设置,授权服务器原样回传。

于是流程变成:

  1. 已登录用户点"绑定 Google",前端先调一个接口,服务端生成一个随机令牌,在 Redis 里存 token -> userId,短 TTL
  2. 前端带着这个令牌跳转到授权入口
  3. 服务端把令牌拼进 state 一起发给 Google
  4. 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 登录的账号模型设计与实现

相关推荐
苏三说技术2 小时前
阿里开源了一个神级Agent项目
后端
IT_陈寒2 小时前
Redis莫名连接失败,查了三天居然是配置的锅
前端·人工智能·后端
Thomas21432 小时前
scala 闭包
开发语言·后端·scala
名字还没想好☜3 小时前
Java 用 LinkedHashMap 三行实现 LRU 缓存:accessOrder、removeEldestEntry 与线程安全
java·后端·安全·spring·缓存
谢亮_vipxieliang3 小时前
ValidX vs Google Guava Preconditions:验证 vs 断言
java·spring boot·后端·spring cloud·hibernate·guava
mldong10 小时前
C# 开发者也有自己的轻量工作流引擎了:NuGet 装包,5 分钟跑通一条审批流
后端·c#·.net
爱学堂IT分享12 小时前
鹤翔老师pmp项目管理培训认证考试课 (直播录播题库送学时证明)【共58课时】-51CTO学堂
后端
王的宝库12 小时前
Go 项目结构:从单文件到标准工程布局
开发语言·后端·golang
码事漫谈13 小时前
类型即世界:一个 C++ 程序员的本体论入门
后端