前言

本文利用到的JustAuth的传送门

在我开发过程中,用到了QQ的第三方登录,在自己完成一遍之后希望能利用成熟库以更简洁的代码加以实现,并一法通百法通地弄上微博登录。于是我找到并使用了JustAuth工具。在使用过程中,以在第三方登录中的校验码state如何被工具保存跟踪为切入点,看一看工具的源代码。

不过本文纯属菜鸡视角。

绝大部分第三方登录采用OAuth2.0协议,其流程符合如下流程图:
流程图.png
关于OAuth2.0流程复杂化了(用户授权登录后,服务器不能直接拿到可以唯一标识用户的id)登录流程,到底在安全性上如何提供了好处,请自行谷歌

正文

准备阶段

    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);
    }

官方文档中并未有此函数,只是我自用的。
不过其中涉及的类AuthQqRequest,类AuthConfig均是官方提供的。
应当能猜到这是用来存储第三方登录时必需的信息的。
JustAuth在调用对应第三方登陆各阶段的方式时应当会需要我交给它上面返回的那个AuthQqRequest类的实例,并内部从实例中获取数据,帮助完成步骤。

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());
}

此阶段:
用户访问 http://mysite.site/render 网页时被跳转到QQ的授权登录网页。

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;
}

此阶段:
用户授权登录后,腾讯那边带上必要的数据以GET参数的模式通过GET访问我们设定的返回地址。我们得到的数据有codestate

本文的主题就在这一段被引发——校验发回的B阶段的state与A阶段的state。A和B这两个阶段不同,两个阶段对应的访问路径,访问者也不同,那么是如何实现的呢?

结合代码,合理的推测是在authRequest.login(callback)中的login函数中实现的。

同时,在实际代码中在IDE的帮助下,发现这里的callbackAuthCallback类的实例,而AuthCallback中有code,state等成员变量。考虑callback之前作为被GET调用的方法的参数,显然QQ传回来的codestate字段的值就被直接封装到其中了(SpringMVC的功能)。要么B阶段用来校验的state的值已经有了。A阶段的呢?

进入源代码看看authRequest#login方法:

default AuthResponse login(AuthCallback authCallback) {
        throw new AuthException(AuthResponseStatus.NOT_IMPLEMENTED);
    }

这是AuthRequest#login方法的方法头,从异常信息AuthResponseStatus.NOT_IMPLEMENTED可知,其依赖子类的具体实现。

因此使用IDEA断点调试,打在AuthRequest#login断点导致程序暂停时,可以看到authRequestAuthDefaultRequest类的实例。

那么看一看AuthDefaultRequest#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.containsKey(state)

从语义上可以了解,以B阶段state的值取一个缓存中找,看看这个值是否已经作为键值在缓存中了。也就是说A阶段的state值会被作为一个键值存入authStateCache

A阶段的state被存入的时机,显然只有在A阶段中的String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());。因为紧接其后的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的缓存。


阳光号
129 声望5 粉丝