设计一个可扩展的用户模型
设计表结构
在一个互联网产品中,用户登录是一个非常基础功能。比如说最常见的用户名+密码的登录方式。遇到这种登录方式,也许我们首先想到的就是创建一张 User 表,包含 username 和 password 两个字段,这样一个最简单的用户系统就被我们实现了,像下面这样:
id | name | password | 其它字段 |
---|---|---|---|
1 | alice | 12345 | |
2 | bob | 12345 |
但是,随着系统的不断演化,我们可能需要支持微信登录,于是我们往这张表里加了一些字段:
id | name | password | wechat_id | wechat_access_token | wechat_expires_in | 其它字段 |
---|---|---|---|---|---|---|
1 | alice | 12345 | 123 | xxxxxxxx | 7200 | |
2 | bob | 12345 |
后来,随着业务的不断发展,我们的系统还需要支持微博登录、QQ 登录,于是这个表又要增加几列。难道说后面每增加一种外部登录方式,我们都要修改表的结构吗?如果这样扩展下去,仅仅修改表的结构都会让人筋疲力尽,更不用说修改代码。
如何设计一个灵活高效的登录系统?我们需要一种可扩展的表结构。
首先分析用户的登录行为,也许用户每次都是微信扫码登录,这种情况下,用户就并不需要存储密码。其次,不管用户以哪种方式登录,登录完成后看到的都是个人信息,不管以哪种方式登录,本质上都是属于用户认证的行为。
于是,我们可以考虑,把用户的 User Profile 和认证信息分开存储。User 表只存储 Profile 信息,如下:
id | name | nick_name | birth等等 |
---|---|---|---|
1 | alice | ||
2 | bob |
把用户密码单独存储在一张表里,避免在查询用户 Profile 的时候同时也把密码带出来,这是不必要的,密码只在认证时有用,同时有些用户不一定有密码,和 Profile 存在一张表里也造成空间的浪费。
id | user_id | password |
---|---|---|
1 | 2 | xxxxxxx |
同时,我们也把外部 OAuth2 登录认证信息单独存放在一张表里:
id | user_id | oauth_name | oauth_id | access_token | refresh_token |
---|---|---|---|---|---|
1 | 2 | q-dfkk-123 | xxxxx | xxxx | |
2 | 3 | w-kkkh-421 | xxxx | xxxx |
后面再对接新的基于 OAuth2 的登录认证方式都可以基于这张表。
通过上面的改造,现在一个用户可以支持多种登录方式,每一种登录方式在登录后都可以通过 user_id 来获取 user 表里的 User Profile 信息,并且 user 表不存储认证信息,这样做到了敏感信息的隔离。
密码、身份证ID等敏感信息如何存储
到底怎么样存储用户的敏感信息,我觉得有两点需要注意:1) 用户的敏感信息绝对不能写到日志里面,之前发生的携程CVV码泄露的事件就是一个例子;2) 用户的敏感信息要加密存储。
说到敏感信息加密,第一个复现在脑海里的可能是MD5,SHA1等单向Hash算法。这类算法在一定程度上,确实可以起到保密的作用。但是破解的手段也非常简单,我们可以通过一台性能较好的计算机去无限次的重试,俗称「撞库」,或者我直接把一些常用密码的Hash值算出来,建立反查表。
大量的实践证明,单向的Hash算法是不安全的。于是有些公司去尝试在密码中加入salt
的做法,大概类似于md5(salt+password)
。如果这个salt是固定的,那么需要保护好这个salt。
事实上,现在可以通过彩虹表来对密码进行暴力破解。彩虹表通常会在100GB以上,和反查表不同的是,彩虹表存储的不是明文和密文的对应信息,而是存储了一条散列链。
尽管彩虹表很强大,但是加盐以后会大大增加破解的难度。例如,我们可以使用PBKDF2算法,该算法原理大致相当于在HASH算法基础上增加随机盐,并进行多次HASH运算,随机盐使得彩虹表的建表难度大幅增加,而多次HASH也使得建表和破解的难度都大幅增加。使用PBKDF2算法时,HASH算法一般选用sha1或者sha256,随机盐的长度一般不能少于8字节,HASH次数至少也要1000次,这样安全性才足够高。
但是,随着现代硬件的发展,PBKDF2也不再是一种值得推荐的加密算法
现在Spring security一般推荐使用 Bcrypt、Scrypt、Argon2等算法,相对于传统的单向Hash,这都是是一些慢的加密算法,这种算法会加入随机salt,加入 pepper 并且经过多次 Hash。这会大大加大破解的难度,在有限的时间内几乎很难破解出原始信息,下面是 Java Password4j这个库整理的主流算法的比较:
认证如何实现
web 应用主流的认证方式有两种,基于 session/cookie 的认证方式和基于 token 的认证方式。由于 session/cookie 的认证方式不能脱离浏览器,传统的 web 应用主要使用这种方式,我们主要介绍基于 token 的认证方式。
基于 token 的认证方式又分为有状态的 token 和无状态的 token。
无状态的 token 主要是使用类似于 jwt 这种自包含用户信息的 token。由于这种 token 的颁发之后时没有办法撤销的,只能等到自然过期,所以应用并不广泛。
下面我们可以基于 Spring Security 实现基于 OAuth2 的用户认证过程。
大致看下 Spring Security 认证过程:
@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.requestMatchers()
.antMatchers("/api/**");
}
}
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends
AuthorizationServerConfigurerAdapter {
// 用户认证
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()
.withClient("clientapp")
.secret("112233")
// 密码模式
.authorizedGrantTypes("password")
.scopes("read_userinfo", "read_contacts");
}
}
以上是配置,代码并不完整,后面有时间写一写如何基于 Spring Security 实现生产可用的用户系统。