设计一个可扩展的登录系统,分为三步,首先要设计好数据表,其次拿到登录用户的cookie,然后再判断cookie是否有效
一、设计数据表
常用的第三方登录有qq、微信、微博等。以微博登录为例,由于微博使用OAuth2协议登录,所以,一个登录用户会包含他的微博身份的ID,一个Access Token用于代表该用户访问微博的API和一个过期时间。只要添加另外一个表就可以解决这个问题,把这个表和用户表关联起来。每一种X-Auth表都存储了用户的登录认证信息,并通过user_id
关联到Users
表。这样一来,不但登录过程简化了,而且一个用户可以使用多种方式登录。只要登录成功,拿到了user_id
,最后读取Users
表是为了获得用户的Profile,这样读出来的数据也更安全,因为Users
表不包含用户口令,不会因为暴露API而不小心把口令给泄露出去。
二、用户认证生成cookie
每个已认证用户的信息都必须通过Cookie来传递,服务器的session也无非是靠一个特殊名称的Cookie来识别而已,只不过由服务器本身帮你完成了解析Cookie、在session中查找User的过程,而代价却是内存占用高,单台服务器变成有状态,无法简单扩展成集群。遇到不懂事的年轻人,什么都敢往session里扔,很快就把服务器搞死了。
确认用户身份,我们需要一个统一的Authenticator
接口
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);
}
}
认证成功后的User
对象放到一个与业务逻辑相关的地方了,比如UserContext
中
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();
三、生成cookie和认证cookie
防止伪造用单向函数,MD5就是单向函数。方法是计算hash的时候,不仅只包含用户口令,还包含Cookie过期时间,以及其他相关随机数,这样计算的hash就非常安全。
假设用户仍以用户名"admin"
,口令"hello"
登录成功,系统可以知道:
- 该用户的id,例如,
1230001
; - 该用户的口令,例如,
"hello"
; - Cookie过期时间,可由当前时间戳+固定时长计算,例如,
1461288165
; - 系统固定的一个随机字符串,例如,
"secret"
。
把上面4部分拼起来,得到:
"1230001:hello:1461288165:secret"
当浏览器发送Cookie回服务器时,我们就可以按照下面的方式验证Cookie:
- 把Cookie分割成三部分,得到用户id,过期时间和hash值;
- 如果过期时间已到,直接丢弃;
- 根据用户id查找用户,得到用户口令;
- 按照生成Cookie时的算法计算md5,与Cookie自带的hash值对比。
如果用户自己对Cookie进行修改,无论改用户id、过期时间,还是hash值,都会导致最终计算结果不一致。
即使用户知道自己的id和口令,也知道服务器的生成算法,他也无法自己构造出有效的Cookie,原因就在于计算hash时的“系统固定的随机字符串”他不知道。
这个“系统固定的随机字符串”还有一个用途,就是编写代码的开发人员不知道生产环境服务器配置的随机字符串,他也无法伪造Cookie。
md5算法还可以换成更安全的sha1/sha256。
绑定用户把User用ThreadLocal
绑定到当前处理线程:
public class UserContext {
public static final ThreadLocal<User> current = new ThreadLocal<User>();
}
别忘了开闭原则“对扩展开放,对修改关闭”
public class UserContext implements AutoCloseable {
static final ThreadLocal<User> current = new ThreadLocal<User>();
public UserContext(User user) {
current.set(user);
}
public static User getCurrentUser() {
return current.get();
}
public void close() {
current.remove();
}
}
单与否不看代码量本身,而是看调用起来是不是简单。在Filter中调用起来就非常简单:
public class MyFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
User user = tryGetAuthenticatedUser(request, response);
try (UserContext context = new UserContext(user)) {
chain.doFilter(request, response);
}
}
}
使用场景如:
try (UserContext context = new UserContext(user)) {
// 当前用户是user:
processProfile(UserContext.getCurrentUser());
// 需要更高权限的admin才能执行的操作怎么办?
// 方法是获取一个admin用户:
try (UserContext context = new UserContext(getAdmin())) {
// 现在的当前用户是admin:
processAdminJob(UserContext.getCurrentUser());
}
// 现在当前用户又自动变回了普通user:
processProfile(UserContext.getCurrentUser());
}