OAuth2 Authorization Code认证方式笔记

2021年09月13日

在为SICP课程开发OJ的时候,突然想给OJ加一个第三方登录方式,而南京大学的统一认证用的是LDAP,为了偷懒不承担法律风险只能退而求其次使用南大的GitLab服务提供的OAuth2来实现第三方身份认证。

OAuth2认证方式原理

flow

  1. 用户向App发送指令获取某个资源
  2. App向认证服务器请求认证代码,将用户重定向到认证服务器
  3. 用户在认证服务器的页面输入用户密码登录,并同意请求
  4. 认证服务器重新导向到App,在URL内带有认证码的参数
  5. App用认证码向认证服务器请求访问密钥
  6. 认证服务器验证认证代码,将访问密钥返回给App
  7. App使用访问密钥访问资源服务器获取资源

SICP Online Judge的设计

在实现SICP OJ的登录中,App其实是由客户端(React App)和服务端(Spring Boot App)组成的。 OAuth2的Client ID和Secret保存在服务端,用户不能访问。

  1. 用户访问先访问客户端的web界面,点击登录按钮后重定向到服务器的某个URL,再由服务器把Client ID等参数加上,重定向到GitLab。
  2. 用户在GitLab登陆后,Redirect URL应该是客户端的地址,由客户端把code发送给服务端,服务端与GitLab认证有效后生成一个Session / JSON Web Token返回给客户端存储。(也可以直接返回服务端,服务端进行认证后再重定向回客户端,只不过这个过程需要全程维持一个state来区分不同的请求,而返回客户端的话不同的标签页就可以区分不同的请求,不需要state了,所以我没有这么做。)
  3. 服务端收到code,用code和client secret去交换access token,然后用access token去获取用户信息,找到本地对应的用户,生成对应的身份凭证返回给客户端存储。

具体的代码和实现

开发环境下的React App运行在http://localhost:3000,服务器运行在http://localhost:8080

首先,在GitLab上创建一个应用,重定向地址是http://localhost:3000,有read_user权限,获得Client ID和secret。

当用户点击登录的时候,生成一个state保存一下返回地址(比如说OAuth2获得code之后给谁发请求、登陆成功之后重定向到哪里之类的,但其实也可以整个随机值),然后重定向到服务器的登录页面:

const state = `oauth-${btoa("/auth/gitlab/login/callback")}-${btoa(redirect)}`;
window.location.href = `${config.baseNames.api}/auth/gitlab/login?state=${state}`;

然后服务器把Client ID加上之后把用户丢给GitLab:

// Controller
HttpHeaders headers = new HttpHeaders();
headers.setLocation(String.format("%s/oauth/authorize?client_id=%s&state=%s",
        config.getEndpoint(), config.getClientId(), state));
return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER);

GitLab认证成功之后,会带着state和code返回到客户端,如http://localhost:3000?state=xxx&code=xxx,在客户端上检测有没有这个值(其实用个路径区分一下会更好),把值提取出来,发送给服务端换取JSON Web Token。

const parts = params.state.split("-");
if (parts.length !== 3) {
    window.alert(`Invalid state: \n${params.state}`);
    window.location.href = config.baseNames.web;
} else {
    const url = atob(parts[1]);
    const redirect = atob(parts[2]);
    http().post(url, {
        code: params.code,
        state: params.state,
        platform: `web-${config.version}`
    })
        .then((res) => {
            if (res.status === 200) {
                dispatch(set(res.data));
            }
            window.location.href = `${config.baseNames.web}#`;
        })
        .catch((err) => {
            console.error(err);
            window.location.href = `${config.baseNames.web}#/auth/login?redirect=${redirect}` +
                `&error=${err.response.data.message}`;
        });
}

服务器收到code之后,用client ID、secret、code去换access token(这里用的是OkHttp3):

String url = String.format("%s/oauth/token?client_id=%s&client_secret=%s&code=%s" +
        "&grant_type=authorization_code&redirect_uri=%s", config.getEndpoint(),
        config.getClientId(), config.getClientSecret(), code, config.getRedirectUri());
RequestBody body = RequestBody.create(new byte[0], null);
Request request = new Request.Builder().url(url).post(body).build();

然后再用access token去换user info:

String url = String.format("%s/api/v4/user", config.getEndpoint());
Request request = new Request.Builder()
        .header("Authorization", token.getTokenType() + " " + token.getAccessToken())
        .url(url).get().build();

换到user info之后就用里面的信息去找本地对应的用户,生成用户认证信息返回给客户端,完成登录。

实现改进

没必要手写Request,可以用Spring的OAuth2 Authentication Manager完成,大致用法如下:

ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab")
        .clientId(config.getClientId())
        .clientSecret(config.getClientSecret())
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .redirectUri(config.getRedirectUri())
        .scope(config.getScope())
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationUri(String.format("%s/oauth/authorize", config.getEndpoint()))
        .tokenUri(String.format("%s/oauth/token", config.getEndpoint()))
        .userInfoUri(String.format("%s/api/v4/user", config.getEndpoint()))
        .userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
        .build();

OAuth2AuthorizationRequest request = OAuth2AuthorizationRequest
        .authorizationCode()
        .authorizationUri(registration.getProviderDetails().getAuthorizationUri())
        .clientId(registration.getClientId())
        .redirectUri(redirectUri)
        .scopes(registration.getScopes())
        .state(state)
        .build();
OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse
        .success(code).redirectUri(redirectUri).state(state).build();

OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(request, response);
OAuth2AuthorizationCodeAuthenticationToken token =
        new OAuth2AuthorizationCodeAuthenticationToken(registration, exchange);
Authentication authentication = service.authenticate(token);
return (String) authentication.getCredentials(); // access token, not user info

当OAuth2 token认证成功后,credentials即为用户的access token,之后也还是要自己去获取用户信息(这里改用了Spring自带的RestTemplate):

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<String> entity = new HttpEntity<>(null, headers);
return rest.exchange(url, HttpMethod.GET, entity, GitlabUserInfo.class).getBody();