设计一个可扩展的用户登录系统 (2)

转载 2017年01月11日 23:26:49

转自 廖雪峰老师

------------------------------

 设计一个可扩展的用户登录系统 (1)中,我们设计了可扩展的数据库表的结构,基本思想是:

  • Users表只存储User的Profile信息,没有任何认证信息(例如,不存Password);
  • 每一种登录方式对应一个XxxAuth表,该表存储对应的认证信息,以及一个userId字段用于关联到某个User

数据库结构再好,代码写得乱七八糟,一样没法扩展。所以本文讨论的,就是如何编写认证代码。

现在的问题是,在Web系统中,由于HTTP请求本质上是无状态的,每个已认证用户的信息都必须通过Cookie来传递。不对啊,我们无论用ASP、PHP还是JSP,打开服务器的session就可以识别用户了啊!

少年,服务器的session也无非是靠一个特殊名称的Cookie来识别而已,只不过由服务器本身帮你完成了解析Cookie、在session中查找User的过程,而代价却是内存占用高,单台服务器变成有状态,无法简单扩展成集群。遇到不懂事的年轻人,什么都敢往session里扔,很快就把服务器搞死了。

所以,除了演示程序外,我们从不用服务器提供的session。

如果仔细思考用户的登录过程,又可以发现,其实不同的登录方式实现起来复杂度也是不同的。

用户名+口令登录

当用户需要以用户名+口令来登录时,我们会让用户填写一个登录表单,如果验证通过,就给用户生成一个可靠的Cookie来标识这个用户:


如果用户继续访问其他页面,我们就需要利用这个Cookie来识别用户。

通过第三方网站登录

当用户需要以第三方OAuth登录时,我们会让用户重定向到第三方登录页,例如微博登录页,如果用户在第三方登录成功,第三方会再把用户重定向回我们的网站,并附上一个code表示是否验证通过。如果验证通过,我们还需要给用户生成一个可靠的Cookie来标识这个用户:


通过HTTP Authorization Header登录

这种方式通常不是用户自己发起的请求,而是由代表用户的机器发起的请求。因为每个页面都会附上Authorization: Basic XXXXX这个Header,所以每个页面都需要验证。

通过X-API-Token登录

这种方式和上一种情况类似,也是由代表用户的机器发起的请求,不同的是用X-API-Token代替了Authorization Header,更安全可靠。同上,每个页面都需要验证。

如何认证

现在问题来了,这么多类型的认证,怎么才能把代码写得能看明白?

复杂的问题都要分解成几步。我们先看通过用户名+口令的表单登录。

在这种条件下,用户首先要被导向到一个登录URL,例如,/signin,然后填写用户名和口令。具体验证方式就是利用Users表和LocalAuth表,如果验证成功,我们就创建一个可信的Cookie给用户。

通过第三方网站登录也是类似的,要先把用户导向到登录URL,登录成功后,创建一个可信的Cookie。

剩下的问题就只有一个:用户每访问一个普通页面,如何确认用户身份?

确认用户身份,我们需要一个统一的Authenticator接口。以Java为例,该接口看起来如下:

public interface Authenticator {
    // 认证成功返回User,认证失败抛出异常,无认证信息返回null:
    User authenticate(HttpServletRequest request, HttpServletResponse response) throws AuthenticateException;
}

接下来,对于每一种类型的认证,我们都编写一个对应的Authenticator的实现类。例如,针对表单登录后的Cookie,需要一个LocalCookieAuthenticator

public LocalCookieAuthenticator implements Authenticator {
    public User authenticate(HttpServletRequest request, HttpServletResponse response) {
        String cookie = getCookieFromRequest(request, 'cookieName');
        if (cookie == null) {
            return null;
        }
        return getUserByCookie(cookie);
    }
}

对于直接用Basic认证的Authorization Header,我们需要一个BasicAuthenticator

public BasicAuthenticator implements Authenticator {
    public User authenticate(HttpServletRequest request, HttpServletResponse response) {
        String auth = getHeaderFromRequest(request, "Authorization");
        if (auth == null) {
            return null;
        }
        String username = parseUsernameFromAuthorizationHeader(auth);
        String password = parsePasswordFromAuthorizationHeader(auth);
        return authenticateUserByPassword(username, password);
    }
}

对于用API Token认证的方式,同样编写一个APIAuthenticator

public APIAuthenticator implements Authenticator {
    public User authenticate(HttpServletRequest request, HttpServletResponse response) {
        String token = getHeaderFromRequest(request, "X-API-Token");
        if (token == null) {
            return null;
        }
        return authenticateUserByAPIToken(token);
    }
}

然后在一个统一的入口处,例如Filter里面,把这些Authenticator全部串起来,让它们依次自己去尝试认证:

public class GlobalFilter implements Filter {
    // 所有的Authenticator都在这里:
    Authenticator[] authenticators = initAuthenticators();

    // 每个页面都会执行的代码:
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        User user = null;
        for (Authenticator auth : this.authenticators) {
            user = auth.authenticate(request, response);
            if (user != null) {
                break;
            }
        }
        // user放哪?
        chain.doFilter(request, response);
    }
}

现在,一个可扩展的认证体系在Web层就基本搭建完成了,我们可以随意组合各种Authenticator,优先级高的放前面。一旦某个Authenticator成功地认证了用户,后面的Authenticator就不执行了。

最后只剩一个问题:认证成功后的User对象放哪?

放session里?NO,我们在前面已经拒绝了使用服务器提供的session。放request里?也不好,因为HTTP级别的对象太低级,很难传到业务层里。那你说应该放哪?当然是放到一个与业务逻辑相关的地方了,比如UserContext中。把Filter代码改写如下:

public class GlobalFilter implements Filter {
    Authenticator[] authenticators = initAuthenticators();

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        // 链式认证获得User:
        User user = tryGetAuthenticatedUser(request, response);
        // 把User绑定到UserContext中:
        try (UserContext ctx = new UserContext(user)) {
            chain.doFilter(request, response);
        }
    }
}

这样一来,任何地方需要获得当前User时,只需要写:

User user = UserContext.getCurrentUser();

是不是太简单了?

最后总结一下我们编写认证逻辑的思路:

  1. 每一种认证方式都是一种Authenticator的实现;
  2. 把所有认证方式串起来,在一个统一的Filter入口来认证;
  3. 认证后的User对象用UserContext存储,并提供一个简单的方法返回当前User

好处如下:

  1. 认证方式可简单扩展;
  2. 认证逻辑统一在一处。

还有一个最大的好处,就是业务相关的代码根本就不需要依赖底层HTTP对象,比如session和request,它们只依赖UserContext,这才是真正的解耦,并且非常容易测试业务逻辑,因为不再需要模拟session和request。

赶快按照上述思想,把上面的认证代码调通后,细心的同学才能发现,本文还遗留了几个小问题:

  1. 表单和OAuth认证成功后,如何生成“可信”的Cookie?
  2. 如何根据“可信”的Cookie识别用户?
  3. UserContext怎么编写?

这些小问题将会在系列(3)中一一解答。

设计一个可扩展的用户登录系统

在Web系统中,用户登录是最基本的功能。要实现用户名+密码登录,很多同学的第一想法就是直接创建一个Users表,包含username和password两列,这样,就可以实现登录了:id | usern...

设计一个可扩展的用户登录系统

在Web系统中,用户登录是最基本的功能。要实现用户名+密码登录,很多同学的第一想法就是直接创建一个Users表,包含username和password两列,这样,就可以实现登录了: id | us...

设计一个可扩展的用户登录系统 (3)

转 廖雪峰老师---------------- 设计一个可扩展的用户登录系统 (3) 廖雪峰 / 编程 / 2016-4-22 12:12 / 阅读: 5867 在系列 (1)和系列 (2)中...

高性能可扩展mysql(用户模块设计,分区表使用)

如何把用户的属性存到表中?问题: 需求:单独保存会员级别信息(没有用户登录名)->sql无法执行数据更新异常当我们数据量比较大时,更新一次就需要很长的时间数据删除异常数据冗余问题 级别积分上限,级...

译文 Ceph:一个可扩展,高性能分布式文件系统

我们开发Ceph,一个分布式文件系统,它提供了优秀的性能、可靠性和可伸缩性。Ceph通过用一个伪随机数据分布函数(CRUSH)替代分布 表来最大化的分离数据与元数据管理,这个算法用于异构和动态不可靠的...

8个常用于可扩展系统的设计模式

1,负载均衡 – 把一个请求按一定hash算法或规则分配到服务器组中的一台去处理,以分担单个服务器的压力。这一般多见于大型网站的构架。2,分头收集(Scatter and Gather) –  把一个...
  • Matol
  • Matol
  • 2011年03月14日 10:27
  • 647

ceph存储 Ceph论文译文--Ceph:一个可扩展,高性能分布式文件系统

Ceph:一个可扩展,高性能分布式文件系统 Sage A. Weil Scott A. Brandt Ethan L. Miller Darrell D. E. Long Carlos Ma...
  • skdkjxy
  • skdkjxy
  • 2014年11月20日 08:25
  • 1386

Ceph论文译文--Ceph:一个可扩展,高性能分布式文件系统

译者注:本文是出于作者对于ceph的兴趣,在开源中国上关注ceph翻译,没有看到ceph论文的相关翻译,索性在阅读过程中把它翻译了出来,花费了几个周末时间,翻译过程中收获颇多,现把译文分享出来,如对您...
  • juvxiao
  • juvxiao
  • 2014年09月23日 11:56
  • 7113
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:设计一个可扩展的用户登录系统 (2)
举报原因:
原因补充:

(最多只允许输入30个字)