前言
本文利用到的 JustAuth 的传送门。
本文纯属菜鸡视角。在开发者相当简略的官方使用文档的基础上,进入源码查看文档中使用的函数的具体实现,同时通过 QQ 第三方登录这一特例,工具开发者非常规范的命名和注释,推测整个工具的实现逻辑。
绝大部分第三方登录采用 OAuth2.0 协议,其流程符合如下流程图:
关于 OAuth2.0 流程复杂化了(用户授权登录后,服务器不能直接拿到可以唯一标识用户的 id)登录流程,到底在安全性上如何提供了好处,请自行谷歌。
A 阶段
跳转到 QQ 的授权登录网页
必需参数 response_type client_id redirect_uri state
其中 response_type 为一定值
B 阶段
用户授权登录后,腾讯那边带上必要的数据以 GET 参数的模型通过 GET 访问我们设定的返回地址。
得到的数据 code state
并要校验发回的 state 与 A 阶段的 state 是否相同
正文
准备阶段
// 官方文档中并未有此函数,只是我自用的。private AuthQqRequest getAuthQqRequest(){
String client_id = 填入你自己的 client_id;
String redirect_uri = 填入你自己的 redirect_url;
String client_secret = 填入你自己的 client_secret;
AuthConfig build = AuthConfig.builder()
.clientId(client_id)
.clientSecret(client_secret)
.redirectUri(redirect_uri)
.build();
return new AuthQqRequest(build);
}
A 阶段
/**
* 官方伪代码
*/
@RequestMapping("/render/{source}")
public void renderAuth(@PathVariable("source") String source, HttpServletResponse response) throws IOException {AuthRequest authRequest = getAuthRequest(source);
String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());
response.sendRedirect(authorizeUrl);
}
/**
* 我的具体到 QQ 上的实现
* 因为我胸无大志只想着 QQ 所以不需要用 {source} 来确定我在用谁的(是微信啊,还是 QQ 啊还是 gitee 啊)的第三方登录功能。*/
@RequestMapping("/render")
public void render(HttpServletResponse resp) throws IOException {AuthQqRequest authQqRequest = getAuthQqRequest();
resp.sendRedirect(authQqRequest.authorize(AuthStateUtils.createState());
}
}
B 阶段
/**
* 官方文档的伪代码
*/
@RequestMapping("/callback/{source}")
public Object login(@PathVariable("source") String source, AuthCallback callback) {AuthRequest authRequest = getAuthRequest(source);
AuthResponse response = authRequest.login(callback);
return response;
}
进行心无大志,醉心 QQ 的化简
/**
* 官方文档的伪代码
*/
@RequestMapping("/callback/QQ")
public Object login(AuthCallback callback) {AuthRequest authRequest = getAuthQqRequest();//getAuthQqRequest()是准备阶段我自用的那个函数
AuthResponse response = authRequest.login(callback);
return response;
}
问题来了:这一阶段应当要完成的 state 校验是如何处理的呢?
合理的推测是在 authRequest.login(callback);
中的 login 函数中实现的(这里的 callback
是AuthCallback
类的实例,而 AuthCallback
中有 code,state 等在 B 阶段时会被以 GET 参数形式被第三方调用回调地址传回的参数。因为 SpringMVC 导致这些参数直接被封装到 callback
中了。)。因此进入代码探究:
default AuthResponse login(AuthCallback authCallback) {throw new AuthException(AuthResponseStatus.NOT_IMPLEMENTED);
}
这是 AuthRequest
类中的 login 方法,从异常信息可知,其依赖子类的具体实现。
public abstract class AuthDefaultRequest implements AuthRequest{
// 省略
public AuthResponse login(AuthCallback authCallback) {
try {AuthChecker.checkCode(source, authCallback);
this.checkState(authCallback.getState());
AuthToken authToken = this.getAccessToken(authCallback);
AuthUser user = this.getUserInfo(authToken);
return AuthResponse.builder().code(AuthResponseStatus.SUCCESS.getCode()).data(user).build();} catch (Exception e) {Log.error("Failed to login with oauth authorization.", e);
return this.responseError(e);
}
}
// 省略
}
显然,答案在 this.checkState(authCallback.getState())
之中,去看看 checkState 方法。
public abstract class AuthDefaultRequest implements AuthRequest{
// 省略
protected void checkState(String state) {if (StringUtils.isEmpty(state) || !authStateCache.containsKey(state)) {throw new AuthException(AuthResponseStatus.ILLEGAL_REQUEST);
}
}
// 省略
}
现在,问题进一步细化。第一,校验 state 不为空无需多言,之后这里在检验 state 是否存在内存中,也就是说它之前就已经存入了内存,什么时候?怎么做的?第二,authStateCache
是什么?
关于第一个疑问,推测是在 A 阶段中的 String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());
过程中缓存了 state 状态值是最合理的,因为紧接其后的 B 阶段就已经要求校验了。在此基础上有两个衍生推测:其一是 AuthStateUtils.createState()
完成了缓存工作,其二是 authRequest.authorize(...)
完成了缓存工作。
前往查看代码
public class AuthStateUtils {public static String createState() {return UuidUtils.getUUID();
}
}
猜测一否决。
public String authorize(String state) {return UrlBuilder.fromBaseUrl(source.authorize())
.queryParam("response_type", "code")
.queryParam("client_id", config.getClientId())
.queryParam("redirect_uri", config.getRedirectUri())
.queryParam("state", getRealState(state))
.build();}
进一步怀疑由 getRealState(state)
实现
public abstract class AuthDefaultRequest implements AuthRequest{
// 省略
protected String getRealState(String state) {if (StringUtils.isEmpty(state)) {state = UuidUtils.getUUID();
}
// 缓存 state
authStateCache.cache(state, state);
return state;
}
// 省略
}
猜测证实。确实在这一步完成了 state 的缓存。接下来就是考虑 `
authStateCache` 到底是个什么东西。与问题二相同。
public abstract class AuthDefaultRequest implements AuthRequest {
// 省略
protected AuthStateCache authStateCache;
// 省略
}
AuthDefaultRequest
作为子类继承了父类 AuthDefaultRequest
的成员变量 authStateCache
。那么authStateCache
是被如何赋值?在目前截取到的代码中,authStateCache
均被直接使用而未见赋值,做出 authStateCache
可能在构造器中被赋值的推测是合理的。
public abstract class AuthDefaultRequest implements AuthRequest {
protected AuthConfig config;
protected AuthSource source;
protected AuthStateCache authStateCache;
public AuthDefaultRequest(AuthConfig config, AuthSource source) {this(config, source, AuthDefaultStateCache.INSTANCE);
}
public AuthDefaultRequest(AuthConfig config, AuthSource source, AuthStateCache authStateCache) {
this.config = config;
this.source = source;
this.authStateCache = authStateCache;
if (!AuthChecker.isSupportedAuth(config, source)) {throw new AuthException(AuthResponseStatus.PARAMETER_INCOMPLETE);
}
// 校验配置合法性
AuthChecker.checkConfig(config, source);
}
// 省略
}
AuthDefaultRequest
作为一个抽象类,是不可能被 new 的,我们 new 的一般都是它的具体实现类,具体到 QQ 上:
public class AuthQqRequest extends AuthDefaultRequest {public AuthQqRequest(AuthConfig config) {super(config, AuthDefaultSource.QQ);
}
public AuthQqRequest(AuthConfig config, AuthStateCache authStateCache) {super(config, AuthDefaultSource.QQ, authStateCache);
}
// 省略
}
而回看准备阶段, 用于生成 AuthQqRequest
的代码, 我们是 new AuthQqRequest(build)
这样创建实例的。即
new AuthQqRequest(build)
new AuthQqRequest(build,AuthDefaultSource.QQ)
new AuthDefaultRequest(build, AuthDefaultSource.QQ)
new AuthDefaultRequest(build, AuthDefaultSource.QQ,AuthDefaultStateCache.INSTANCE)
到第四个构造器时图穷匕见,authStateCache
被赋值为 AuthDefaultStateCache.INSTANCE
,那么接下来就要看AuthDefaultStateCache.INSTANCE
是个啥了。
public enum AuthDefaultStateCache implements AuthStateCache {
INSTANCE;
private AuthCache authCache;
AuthDefaultStateCache() {authCache = new AuthDefaultCache();
}
/**
* 存入缓存
*/
@Override
public void cache(String key, String value) {authCache.set(key, value);
}
// 省略
/**
* 是否存在 key,如果对应 key 的 value 值已过期,也返回 false
*/
@Override
public boolean containsKey(String key) {return authCache.containsKey(key);
}
// 省略
}
注意 AuthDefaultStateCache
是一个枚举,再加INSTANCE
,这种写法其实是一种通过枚举实现的单例模式。具体情况可以 Google,换成常见的单例形式,应该如此:
public enum AuthDefaultStateCache implements AuthStateCache {
/*
INSTANCE;
private AuthCache authCache;
AuthDefaultStateCache() {authCache = new AuthDefaultCache();
}
*/
// 以下内容的效用与上面原码中上面的被注释部分差不多。主要体现一个单例模式。private AuthDefaultStateCache(){} // 私有构造
private static AuthDefaultStateCache INSTANCE = null; // 私有单例对象
// 静态工厂
public static AuthDefaultStateCache getInstance(){if (INSTANCE == null) { // 双重检测机制
synchronized (AuthDefaultStateCache.class) { // 同步锁
if (INSTANCE == null) { // 双重检测机制
INSTANCE = new AuthDefaultStateCache();}
}
}
return INSTANCE;
}
// 其它部分是一个单例类内部的成员变量和一些方法。不存在什么等效。}
既然是单例模式,那么 AuthDefaultStateCache.INSTANCE
在整个应用中都是那一个,自然而然地,在 A 阶段获得它存储之后,再在 B 阶段获得,仍然是它,也因此自然可以查询之前被存下来的 state 了。