0%

多标识符统一登录与社交绑定:构建安全可扩展的认证体系

🌐 Language: English Version | 中文版

随着业务发展,登录入口逐渐增多:用户名、邮箱、手机号,以及 Google、GitHub、微信等社交账号。传统的”大宽表”用户设计在面对这种多样性时,往往陷入两难:要么不断添加字段导致表结构臃肿,要么限制登录方式失去灵活性。本文将探讨一种通过 AuthIdentity 与 User 分离 的领域模型,来构建可扩展的统一认证体系。

一、从”大宽表”到”分离模型”的演进

1.1 传统设计的困境

早期的用户表通常是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(50) UNIQUE,
email VARCHAR(100) UNIQUE,
mobile VARCHAR(20) UNIQUE,
password_hash VARCHAR(255),
google_id VARCHAR(100),
wechat_openid VARCHAR(100),
github_id VARCHAR(100),
-- 每新增一种登录方式,就要加字段
created_at TIMESTAMP
);

这种设计的痛点显而易见:

  • 扩展困难:新增登录方式需要修改表结构,线上大表变更风险高
  • 逻辑耦合:密码字段对所有用户都是必填的,无法支持”仅短信登录”或”仅社交登录”的用户
  • 唯一性混乱usernameemailmobile 各自有唯一约束,但社交账号的 ID 散落在不同字段,难以统一管理
  • 数据冗余:不用密码的用户, password_hash 字段为空;不用邮箱的用户,email 字段为空

1.2 分离模型的核心思想

将”用户是谁”(User)与”如何证明你是你”(AuthIdentity)彻底解耦:

  • User:只关心用户的基础信息,不关心怎么登录
  • AuthIdentity:只关心认证凭证,不关心用户其他信息
  • UserPassword:密码作为独立实体,支持”无密码用户”场景

这种分离带来的直接好处:新增一种登录方式,只需要插入一条 AuthIdentity 记录,无需修改任何表结构


二、领域模型设计

2.1 核心实体关系

classDiagram
    class User {
        +Long id
        +String nickname
        +String avatar
        +String status
        +datetime created_at
    }

    class AuthIdentity {
        +Long id
        +Long user_id
        +String identity_type
        +String identifier
        +datetime verified_at
    }

    class UserPassword {
        +Long user_id
        +String password_hash
    }

    User "1" --> "n" AuthIdentity : has
    User "1" --> "0..1" UserPassword : has

2.2 实体职责边界

User:用户聚合根

User 实体只存储业务无关的基础信息

1
2
3
4
5
6
7
8
public class User {
private Long id;
private String nickname;
private String avatar;
private UserStatus status; // ACTIVE, SUSPENDED, etc.
private LocalDateTime createdAt;
// 注意:没有 password, email, mobile 字段
}

设计意图:User 表应该足够稳定。无论后面支持多少种登录方式,这张表都不需要改动。它回答的是”这个用户存在吗”,而不是”这个用户怎么登录”。

AuthIdentity:认证标识

AuthIdentity 记录用户拥有的所有登录凭证,一条记录对应一种登录方式:

1
2
3
4
5
6
7
public class AuthIdentity {
private Long id;
private Long userId; // 关联到 User
private String identityType; // "email", "mobile", "username", "google", "wechat"
private String identifier; // 具体的标识值:邮箱地址、手机号、用户名、OpenID
private LocalDateTime verifiedAt; // 验证时间(邮箱/手机号需要验证)
}

关键设计

  • 复合唯一索引(identity_type, identifier) 确保同一个邮箱不能绑定两个账号
  • 灵活的 identifier13800138000 可以是手机号类型的标识符,也可以是用户名类型的标识符,互不冲突
  • 核心字段精简:AuthIdentity 只存储定位用户所需的最小信息,其他扩展数据可单独存储

UserPassword:独立的密码实体

1
2
3
4
5
public class UserPassword {
private Long userId; // 与 User 一对一
private String passwordHash; // Argon2id 哈希值
private LocalDateTime updatedAt;
}

为什么要单独一张表?

  1. 支持无密码用户:只用短信验证码或社交登录的用户,没有 UserPassword 记录
  2. 密码策略独立:可以单独对密码表做安全加固(如更严格的访问控制、审计日志)
  3. 历史密码管理:如果需要”不能重复使用最近 5 次密码”的策略,可以扩展为一对多关系

三、模型在场景中的体现

3.1 场景一:用户注册

用邮箱注册

1
2
3
4
5
6
7
8
9
10
11
-- 1. 创建 User
INSERT INTO users (id, nickname, status, created_at)
VALUES (1, '张三', 'ACTIVE', NOW());

-- 2. 创建 AuthIdentity (email)
INSERT INTO auth_identities (user_id, identity_type, identifier, verified_at)
VALUES (1, 'email', 'zhangsan@example.com', NOW());

-- 3. 创建 UserPassword
INSERT INTO user_passwords (user_id, password_hash)
VALUES (1, '$argon2id$v=19$m=65536,t=3,p=4$...');

用微信一键注册(无密码用户):

1
2
3
4
5
6
7
8
9
-- 1. 创建 User
INSERT INTO users (id, nickname, avatar, status, created_at)
VALUES (2, '微信用户', 'https://wx.qq.com/avatar.jpg', 'ACTIVE', NOW());

-- 2. 创建 AuthIdentity (wechat)
INSERT INTO auth_identities (user_id, identity_type, identifier)
VALUES (2, 'wechat', 'oABCD1234567890');

-- 注意:没有 user_passwords 记录

3.2 场景二:新增登录方式

用户原本用邮箱登录,现在想绑定手机号:

1
2
3
-- 只需插入一条记录,无需修改 users 表
INSERT INTO auth_identities (user_id, identity_type, identifier, verified_at)
VALUES (1, 'mobile', '13800138000', NOW());

用户想再绑定 GitHub:

1
2
INSERT INTO auth_identities (user_id, identity_type, identifier)
VALUES (1, 'github', '12345678');

体会一下这种扩展性:每新增一种登录方式,就是一行 INSERT,系统代码里也只是新增一个 identity_type 的枚举值。

3.3 场景三:登录时的模型协作

用户输入 zhangsan@example.com + 密码登录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public LoginResult login(String input, String password) {
// 1. 查找 AuthIdentity
AuthIdentity identity = authIdentityRepo
.findByTypeAndIdentifier("email", input)
.orElseThrow(() -> new IdentityNotFoundException());

// 2. 找到关联的 User
User user = userRepo.findById(identity.getUserId())
.orElseThrow(() -> new UserNotFoundException());

// 3. 验证密码(从独立的 UserPassword 表)
UserPassword userPwd = userPasswordRepo.findByUserId(user.getId())
.orElseThrow(() -> new PasswordNotSetException());

if (!passwordEncoder.matches(password, userPwd.getPasswordHash())) {
throw new InvalidPasswordException();
}

// 4. 颁发 Token
return issueToken(user);
}

注意这里的查询路径:AuthIdentity → User → UserPassword,三个实体各司其职。

3.4 场景四:社交账号绑定冲突

用户 A 已登录,尝试绑定一个已被用户 B 占用的微信:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void bindSocialAccount(Long currentUserId, String wechatOpenid) {
// 1. 检查这个微信是否已被绑定
Optional<AuthIdentity> existing = authIdentityRepo
.findByTypeAndIdentifier("wechat", wechatOpenid);

if (existing.isPresent()) {
Long boundUserId = existing.get().getUserId();
if (!boundUserId.equals(currentUserId)) {
// 已被其他用户绑定,拒绝
throw new IdentityAlreadyBoundException(
"该微信已绑定到用户 " + boundUserId);
}
// 已被当前用户绑定,幂等返回
return;
}

// 2. 创建新的 AuthIdentity
AuthIdentity newIdentity = new AuthIdentity();
newIdentity.setUserId(currentUserId);
newIdentity.setIdentityType("wechat");
newIdentity.setIdentifier(wechatOpenid);
authIdentityRepo.save(newIdentity);
}

关键点:通过 (identity_type, identifier) 的唯一约束,天然防止了重复绑定。冲突处理只需要简单的判断逻辑。


四、模型带来的架构优势

4.1 扩展性:新增登录方式零表结构变更

对比传统设计:

方案 新增登录方式的成本
大宽表 ALTER TABLE 添加字段,线上大表风险高,需要灰度发布
分离模型 INSERT 一条记录,代码新增枚举值,纯业务逻辑变更

4.2 灵活性:支持”渐进式”认证

用户可以先注册一个”无密码”账号(仅社交登录),后续再设置密码、绑定手机号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 初始状态:只有微信登录
SELECT * FROM auth_identities WHERE user_id = 1;
-- user_id | identity_type | identifier
-- 1 | wechat | oABCD123...

-- 后续绑定手机号
INSERT INTO auth_identities ... ('mobile', '13800138000');

-- 后续设置密码
INSERT INTO user_passwords ... (1, 'password_hash');

-- 最终状态:三种登录方式任选
SELECT identity_type, identifier FROM auth_identities WHERE user_id = 1;
-- wechat | oABCD123...
-- mobile | 13800138000

4.3 数据一致性:账号解绑不会丢失用户数据

解绑手机号只是删除一条 AuthIdentity 记录:

1
2
DELETE FROM auth_identities 
WHERE user_id = 1 AND identity_type = 'mobile' AND identifier = '13800138000';

User 实体不受影响,用户的订单、文章、评论等业务数据都还在。只要保留至少一种 AuthIdentity,用户就能继续登录。


五、实现时的注意事项

5.1 索引设计

1
2
3
4
5
6
7
8
9
10
11
-- 核心查询:根据登录方式找用户
CREATE UNIQUE INDEX idx_auth_identity_type_identifier
ON auth_identities(identity_type, identifier);

-- 管理场景:查看用户的所有登录方式
CREATE INDEX idx_auth_identity_user_id
ON auth_identities(user_id);

-- 密码查询
CREATE UNIQUE INDEX idx_user_password_user_id
ON user_passwords(user_id);

5.2 事务边界

创建用户时需要保证三张表的一致性:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public User createUserWithEmail(String email, String password) {
// 1. 创建 User
User user = userRepo.save(new User());

// 2. 创建 AuthIdentity
authIdentityRepo.save(new AuthIdentity(user.getId(), "email", email));

// 3. 创建 UserPassword
userPasswordRepo.save(new UserPassword(user.getId(), hash(password)));

return user;
}

5.3 安全考量(简要)

  • 验证码:用于验证手机号/邮箱所有权,验证后应标记 verified_at
  • 密码策略:使用 Argon2id,参数建议 m=65536, t=3, p=4
  • Token 存储:社交账号的 AccessToken 应单独存储并加密,防止泄露
  • 防暴力破解:对 identifier + identity_type 维度限流,而非仅 IP

六、总结

AuthIdentity 与 User 分离的核心价值在于:将”身份”与”认证方式”解耦

这种设计让系统可以:

  1. 灵活扩展:新增登录方式只需添加记录,无需改表结构
  2. 渐进演进:支持用户从”社交登录”逐步完善到”多因子认证”
  3. 数据安全:解绑登录方式不会丢失用户业务数据
  4. 逻辑清晰:每个实体职责单一,代码易于维护

当你的产品从”仅用户名密码”发展到”支持手机号、邮箱、微信、GitHub、企业 SSO”等多种登录方式时,这种分离模型能帮你避免一次次痛苦的表结构迁移,让认证体系真正成为可演进的业务能力。