1 Spring Security + OAuth搭建授权体系
网上有很多知识都是支离破碎的。现实中Spring security和oauth往往都是一起使用。他们共存在一个体系中扮演不同的角色。
OAuth的作用是授权。Security提供授权的依据。
就好像现在需要进入一个管理严格的小区。小区物业是OAuth,Security提供小区内所有符合要求可以进入小区的用户名单。物业会在小区入口检查用户的门禁卡。如果门禁卡上的名字和名单对上了,用户就被允许进入门禁卡上标示的单元。还会有一些人,他们的门禁卡标示了更多单元,他不仅可以去二单元,也可以去三单元。这个门禁卡就是Oauth发放的accessToken。它的作用是 标记用户的身份 (是否可以进入小区) 和 标记用户的权限 (进入小区以后能去到小区的什么地方)。
回到具体的业务中,我们的应用就是这个小区,接口就是小区中的单元,来访的用户就是http请求。
2 从请求OAuth开始
以微信生态的oauth举例。用户在微信生态登录,通过静默授权或者网页授权拿到用户的标示身份的信息以后,往往需要和业务系统的基础用户建立绑定关系。可以考虑包裹用户的unionid作为标示,理论上用户的unionid是不应该暴露的。所以需要加密。这里演示一个微信生态中的oauth请求的从发起请求到security鉴定身份和权限到最终返回accessToken的全链路历程。
curl请求:
curl --location --request POST 'http://xxx.com/api/auth/oauth/token?unionId=QHhpYW96YW8%3D&grant_type=wechat&scope=wechat
--header 'Authorization: Basic eGlhb3phb193ZWNoYXQ******2VjcmV0'
3 TokenEndPoint看整体流程
这个请求将会发往TokenEndPoint (org.springframework.security.oauth2.provider.endpoint)
只保留了核心代码如下,也可以看见oauth引导了整个授权的流程,扮演好了一个老实憨厚的小区保安的形象
public class TokenEndpoint extends AbstractEndpoint {
// 以下是核心部分代码...
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
// 1. 从 principal 中获取 clientId, 装载授权客户端信息
String clientId = getClientId(principal);
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
// 2. 从 parameters 中拿 clientId、scope、grantType 组装 TokenRequest
TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
// 3. 校验 client 信息
if (clientId != null && !clientId.equals("")) {
if (!clientId.equals(tokenRequest.getClientId())) {
// 双重校验: 确保从 principal 拿到的 client 信息与根据 parameters 得到的 client 信息一致
throw new InvalidClientException("Given client ID does not match authenticated client");
}
}
// 4. 根据 grantType 设置 TokenRequest 的 scope。
// 授权类型有: password 模式、authorization_code 模式、refresh_token 模式、client_credentials 模式、implicit 模式
if (!StringUtils.hasText(tokenRequest.getGrantType())) {
throw new InvalidRequestException("Missing grant type");
}
if (tokenRequest.getGrantType().equals("implicit")) {
throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
}
// 如果是授权码模式, 则清空 scope。 因为授权请求过程会确定 scope, 所以没必要传
if (isAuthCodeRequest(parameters)) {
if (!tokenRequest.getScope().isEmpty()) {
logger.debug("Clearing scope of incoming token request");
tokenRequest.setScope(Collections.<String> emptySet());
}
}
// 如果是刷新 Token 模式, 解析并设置 scope
if (isRefreshTokenRequest(parameters)) {
tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
}
// 5. 通过令牌授予者获取 token
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
if (token == null) {
throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
}
return getResponse(token);
}
最最重要的,也是我们需要填补的逻辑就是下面这句话。我们需要实现一个accessToken发放的granter。
OAuth2AccessToken token = this.getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
4 复合TokenGranter
TokenGranter有不同类型的多种,比如授权码模式AuthorizationCodeTokenGranter,客户端ClientCredentialsTokenGranter模式等等不一一细说。他们可以以组合的方式存在。
public static TokenGranter getTokenGranter(final AuthenticationManager authenticationManager, final AuthorizationServerEndpointsConfigurer endpoints, RedisUtil redisUtil, LdapTemplate ldapTemplate, SmsTemplate smsTemplate) {
// 默认tokenGranter集合
List<TokenGranter> granters = new ArrayList<>(Collections.singletonList(endpoints.getTokenGranter()));
// 增加验证码模式
granters.add(new CaptchaTokenGranter(authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), redisUtil));
// 手机验证码模式
granters.add(new MobileTokenGranter(authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), smsTemplate));
// Ldap认证模式
granters.add(new LdapTokenGranter(authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), ldapTemplate));
// wechat认证模式
granters.add(new WechatTokenGranter(authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), smsTemplate));
// 组合tokenGranter集合
return new CompositeTokenGranter(granters);
}
5 实现一种具体的Granter: WxTokenGranter
拿wxToken举例,只保留了核心代码:
/**
* WxTokenGranter
*
* @author yanghaolei
* @Date 2020/03/31 下午17:41
*/
public class WechatTokenGranter extends AbstractTokenGranter {
private final AuthenticationManager authenticationManager;
@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
HttpServletRequest request = WebUtil.getRequest();
// 1 合成userName [Spring Security]
String unionId = request.getParameter(TokenUtil.WECHAT_HEADER_UNIONID);
String userName = String.format(USERNAME_FORMAT, unionId, mobile);
// 2 合成userDetail [Spring Security]
Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters());
// 3 构建认证对象Authentication
Authentication userAuth = new UsernamePasswordAuthenticationToken(userName, TokenUtil.DEFAULT_PASSWROD);
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
try {
userAuth = authenticationManager.authenticate(userAuth);
} catch (AccountStatusException | BadCredentialsException ase) {
throw new BusinessException(AuthErrorCode.AUTH_CHECK_ERROR);
}
if (userAuth == null || !userAuth.isAuthenticated()) {
throw new BusinessException(AuthErrorCode.AUTH_CHECK_ERROR);
}
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
}
同样有一句最最最重要的代码,这句代码就是Spring Security嵌入的地方。
userAuth = authenticationManager.authenticate(userAuth);
之前我们说过security保存可以进入小区的用户的身份和这些用户进入小区以后能去到哪里的权限。我们通过请求oauth带过来的身份信息(加密后的unionId)在这里,我们要包装成security认识的形式(userDetails),然后和security保存的名单(很多个userDetails)进行比对来确认来访者的成分。最后拿到userDetail对象发放门禁卡(构筑accessToken)。也是整个环节的最后一步。
6 AuthenticationManager 经理发门禁卡了
现在只保留核心逻辑来看看Manager的逻辑:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 1 :从authentication获取之前构筑的userName
String username = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
// 2: 从缓存中检查是否存在和username一样的用户
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
// 3: 检索user[Spring Security执行部分 实现loadUserByUserName方法]
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)
// 4: 发放token
this.createSuccessAuthentication(principalToReturn, authentication, user);
}
第三步就是security实现的地方。配置自定义的用户userDetail对象配合数据库实现用户存储。核心就是实现 UserDetailsService接口中的loadUserByUsername()方法。这一方法返回accessToken构筑必需的user。
最后,第四步用userDetail进行构筑,返回accessToken信息。
如果对Spring Security感兴趣可以看看我的下一篇文章 聊聊SpringSecurity
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cC..",
"expires_in": 35999,
"user_id": "15",
"role_id": "3",
}