Published on

设计一个可扩展的用户模型

Authors

设计表结构

在一个互联网产品中,用户登录是一个非常基础功能。比如说最常见的用户名+密码的登录方式。遇到这种登录方式,也许我们首先想到的就是创建一张 User 表,包含 username 和 password 两个字段,这样一个最简单的用户系统就被我们实现了,像下面这样:

idnamepassword其它字段
1alice12345
2bob12345

但是,随着系统的不断演化,我们可能需要支持微信登录,于是我们往这张表里加了一些字段:

idnamepasswordwechat_idwechat_access_tokenwechat_expires_in其它字段
1alice12345123xxxxxxxx7200
2bob12345

后来,随着业务的不断发展,我们的系统还需要支持微博登录、QQ 登录,于是这个表又要增加几列。难道说后面每增加一种外部登录方式,我们都要修改表的结构吗?如果这样扩展下去,仅仅修改表的结构都会让人筋疲力尽,更不用说修改代码。

如何设计一个灵活高效的登录系统?我们需要一种可扩展的表结构。

首先分析用户的登录行为,也许用户每次都是微信扫码登录,这种情况下,用户就并不需要存储密码。其次,不管用户以哪种方式登录,登录完成后看到的都是个人信息,不管以哪种方式登录,本质上都是属于用户认证的行为。

于是,我们可以考虑,把用户的 User Profile 和认证信息分开存储。User 表只存储 Profile 信息,如下:

idnamenick_namebirth等等
1alice
2bob

把用户密码单独存储在一张表里,避免在查询用户 Profile 的时候同时也把密码带出来,这是不必要的,密码只在认证时有用,同时有些用户不一定有密码,和 Profile 存在一张表里也造成空间的浪费。

iduser_idpassword
12xxxxxxx

同时,我们也把外部 OAuth2 登录认证信息单独存放在一张表里:

iduser_idoauth_nameoauth_idaccess_tokenrefresh_token
12QQq-dfkk-123xxxxxxxxx
23wechatw-kkkh-421xxxxxxxx

后面再对接新的基于 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这个库整理的主流算法的比较:

主流密码 Hash 算法比较

认证如何实现

web 应用主流的认证方式有两种,基于 session/cookie 的认证方式和基于 token 的认证方式。由于 session/cookie 的认证方式不能脱离浏览器,传统的 web 应用主要使用这种方式,我们主要介绍基于 token 的认证方式。

基于 token 的认证方式又分为有状态的 token 和无状态的 token。

无状态的 token 主要是使用类似于 jwt 这种自包含用户信息的 token。由于这种 token 的颁发之后时没有办法撤销的,只能等到自然过期,所以应用并不广泛。

下面我们可以基于 Spring Security 实现基于 OAuth2 的用户认证过程。

大致看下 Spring Security 认证过程:

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 实现生产可用的用户系统。