OAuth2 Authorization Code认证方式笔记
(2021年09月13日)
在为SICP课程开发OJ的时候,突然想给OJ加一个第三方登录方式,而南京大学的统一认证用的是LDAP,为了偷懒不承担法律风险只能退而求其次使用南大的GitLab服务提供的OAuth2来实现第三方身份认证。
OAuth2认证方式原理
- 用户向App发送指令获取某个资源
- App向认证服务器请求认证代码,将用户重定向到认证服务器
- 用户在认证服务器的页面输入用户密码登录,并同意请求
- 认证服务器重新导向到App,在URL内带有认证码的参数
- App用认证码向认证服务器请求访问密钥
- 认证服务器验证认证代码,将访问密钥返回给App
- App使用访问密钥访问资源服务器获取资源
SICP Online Judge的设计
在实现SICP OJ的登录中,App其实是由客户端(React App)和服务端(Spring Boot App)组成的。 OAuth2的Client ID和Secret保存在服务端,用户不能访问。
- 用户访问先访问客户端的web界面,点击登录按钮后重定向到服务器的某个URL,再由服务器把Client ID等参数加上,重定向到GitLab。
- 用户在GitLab登陆后,Redirect URL应该是客户端的地址,由客户端把code发送给服务端,服务端与GitLab认证有效后生成一个Session / JSON Web Token返回给客户端存储。(也可以直接返回服务端,服务端进行认证后再重定向回客户端,只不过这个过程需要全程维持一个
state
来区分不同的请求,而返回客户端的话不同的标签页就可以区分不同的请求,不需要state
了,所以我没有这么做。) - 服务端收到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();