最近使用spring security oauth2 做开放平台,想要返回统一的返回值格式,做的过程中发现相当麻烦,好在效果总算达到了,在这里总结一下,希望能帮助到遇到相同问题的同学。实现方式并不完美,如果你有更好的方式或发现文内有问题,希望不吝赐教。
Oauth2 协议有4种认证方式,项目里用到了客户端模式和密码模式,都是依托于spring security oauth2。开发过程中发现框架原生响应的格式一般如下:
{
"error": "invalid_grant",
"error_description": "Bad credentials"
}
为了调用方的便利、保证平台的统一性,希望统一响应的格式,最起码有code和友好的提示信息message,比如:
{
"code": 401101,
"message": "客户端认证失败"
}
开发过程中需要对如下几种请求统一响应值格式
- 客户端/密码模式获取token失败——参数中未携带client_id、参数中client_id或client_secret不正确
- 密码模式获取token失败——参数中userName或password不正确
- 资源接口请求失败——未带token、token过期、token有效但资源权限不足
- 正常的oauth/token的响应体结构——重写原token格式
客户端/密码模式获取token失败——参数中未携带client_id、参数中client_id或client_secret不正确
密码模式获取token时需要先验证客户端、再验证用户,因此可以合这两种情况,由于密码模式获取token过程中也需要验证client,且验证逻辑与客户端模式相同,都会使用下面的方式。
这里有一个前提是client验证必须是basic auth方式,即在请求头中设置Authorization
参数,将client_id和client_secret以:间隔进行拼接,然后将拼接后的字符串使用 BASE64 编码与Basic
拼接,可生成 Authorization 参数的值。还有一个传参方式是form形式,form形式无法定义返回值格式,框架里写死了。
自定义一个OncePerRequestFilter子类,在filter中重写认证逻辑,再将其注入到AuthorizationServerSecurityConfigurer
/**
* basic auth 方式client认证过滤器
* 置于{@link org.springframework.security.web.authentication.www.BasicAuthenticationFilter}之前,
* 以实现客户端信息不全、认证失败时返回自定义响应信息
*
* @author zhangjw
* @version 1.0
*/
@Component
@Slf4j
public class CustomBasicAuthenticationFilter extends OncePerRequestFilter {
@Resource
private ClientDetailsService clientDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (!request.getRequestURI().contains("/oauth/token")) {
filterChain.doFilter(request, response);
return;
}
String[] clientDetails = this.isHasClientDetails(request);
// 客户端信息缺失
if (clientDetails == null) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_CLIENT_MISSING);
response.getWriter().write(JsonUtil.beanToJson(resp));
return;
}
try {
this.handle(request, response, clientDetails, filterChain);
} catch (CustomOauthException coe) {
// 客户端认证失败
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_CLIENT);
response.getWriter().write(JsonUtil.beanToJson(resp));
}
}
private void handle(HttpServletRequest request, HttpServletResponse response, String[] clientDetails, FilterChain filterChain) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
filterChain.doFilter(request, response);
return;
}
ClientDetails details = null;
try {
details = this.getClientDetailsService().loadClientByClientId(clientDetails[0]);
} catch (ClientRegistrationException e) {
log.info("client认证失败,{},{}", e.getMessage(), clientDetails[0]);
throw new CustomOauthException("client_id 或client_secret 不正确");
}
if (details == null) {
log.info("client认证失败,{}", clientDetails[0]);
throw new CustomOauthException("client_id或client_secret不正确");
}
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(details.getClientId(), details.getClientSecret(), details.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(request, response);
}
/**
* 判断请求头中是否包含client信息,不包含返回null Base64编码
*/
private String[] isHasClientDetails(HttpServletRequest request) {
String[] params = null;
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null) {
String basic = header.substring(0, 5);
if (basic.toLowerCase().contains("basic")) {
String tmp = header.substring(6);
String defaultClientDetails = new String(Base64.getDecoder().decode(tmp));
String[] clientArrays = defaultClientDetails.split(":");
if (clientArrays.length != 2) {
return params;
} else {
params = clientArrays;
}
}
}
String id = request.getParameter("client_id");
String secret = request.getParameter("client_secret");
if (header == null && id != null) {
params = new String[]{id, secret};
}
return params;
}
public ClientDetailsService getClientDetailsService() {
return clientDetailsService;
}
public void setClientDetailsService(ClientDetailsService clientDetailsService) {
this.clientDetailsService = clientDetailsService;
}
}
配置:在授权服务器 AuthorizationServerConfig 配置如下
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Resource
private CustomBasicAuthenticationFilter customBasicAuthenticationFilter;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.addTokenEndpointAuthenticationFilter(customBasicAuthenticationFilter);
}
}
密码模式获取token失败——参数中userName和password不正确
重写AuthenticationProvider,继承AbstractUserDetailsAuthenticationProvider抽象类,重点在重写retrieveUser这个方法,这个方法内调用自己的账户服务来认证用户信息,如果用户名密码不匹配时,抛出 InvalidGrantException异常,可以附带message,该异常是AuthenticationException的子类。
@Service
@Slf4j
public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
/**
* 验证用户
*/
@Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 伪代码,如果认证失败抛出 InvalidGrantException 然后下面的定义异常解析器oauth2ResponseExceptionTranslator捕获处理
throw new InvalidGrantException("用户名或密码验证失败");
}
}
/**
* 当执行 CustomAuthenticationProvider#retrieveUser抛出异常时,会被这个异常解析器处理,
* 可以在这里构造返回{@link ResponseEntity},加入code、message等字段,
*
* @author zhangjw
* @version 1.0
*/
@Slf4j
@Configuration
public class Oauth2ExceptionTranslatorConfiguration {
@Bean
public WebResponseExceptionTranslator<OAuth2Exception> oauth2ResponseExceptionTranslator() {
return new DefaultWebResponseExceptionTranslator() {
@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
OAuth2Exception body = OAuth2Exception.create(OAuth2Exception.ACCESS_DENIED, e.getMessage());
// 捕获后在返回值添加code、message
body.addAdditionalInformation("code", String.valueOf(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getCode()));
body.addAdditionalInformation("message", OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getDesc());
HttpHeaders headers = new HttpHeaders();
return new ResponseEntity<>(body, headers, HttpStatus.UNAUTHORIZED);
}
};
}
}
配置:在授权服务器 AuthorizationServerConfig 配置如下
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private WebResponseExceptionTranslator<OAuth2Exception> oauth2ResponseExceptionTranslator;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authenticationManager(authenticationManager)
.exceptionTranslator(oauth2ResponseExceptionTranslator) // 设置自定义的异常解析器
.tokenServices(tokenServices());
}
}
资源接口请求失败——未带token、token过期、资源权限不足
实现一个AuthenticationEntryPoint,直接用component注解,加入spring容器即可生效。在commence方法里通过response.getWriter().write 自定义响应值。这里可以通过异常cause区分是未带token还是token过期
/**
* resource服务器请求,验证token失败(未带token/token失效)时返回值重写
*
* @author zhangjw
* @version 1.0
*/
@Component
@Slf4j
public class CustomOAuthEntryPoint implements AuthenticationEntryPoint {
@Autowired
private ObjectMapper mapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws ServletException {
Throwable cause = authException.getCause();
response.setStatus(HttpStatus.OK.value());
response.setHeader("Content-Type", "application/json;charset=UTF-8");
try {
if (cause instanceof OAuth2AccessDeniedException) {
// 资源权限不足
OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_ACCESS_RESOURCE_INSUFFICIENT_AUTHORITY);
response.getWriter().write(mapper.writeValueAsString(resp));
} else if (cause == null || cause instanceof InvalidTokenException) {
// 未带token或token无效
// cause == null 一般可能是未带token
OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_ACCESS_RESOURCE_TOKEN_INVALID);
response.getWriter().write(mapper.writeValueAsString(resp));
}
} catch (IOException e) {
log.error("其他异常error", e);
throw new RuntimeException(e);
}
}
配置:在资源服务器中配置如下
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private DefaultTokenServices tokenServices;
@Autowired
private AuthenticationEntryPoint oauthEntryPoint;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
// 自定义oauthEntryPoint
resources.authenticationEntryPoint(oauthEntryPoint);
resources
.tokenServices(tokenServices)
.resourceId("xxx");
}
}
正常的oauth/token的响应体结构
默认的token如下,这里希望在外层加一层包裹,加上code、message字段,便于适用方判断token是否获取成功
{
"access_token": "b27c596d-db80-4393-ad4e-dddcad024b6b",
"token_type": "bearer",
"refresh_token": "21cee608-5775-48b3-8427-d7e894abd947",
"expires_in": 50662,
"scope": "read write"
}
适用切面来实现,切点是org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(..),目的是对oahth/token 的返回值重写
@Component
@Aspect
@Slf4j
public class AuthTokenAspect {
@Around("execution(* org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(..))")
public Object handleControllerMethod(ProceedingJoinPoint pjp) throws Throwable {
WebResp<Object> response = WebResp.ok();
Object proceed = null;
try {
proceed = pjp.proceed();
} catch (Throwable throwable) {
throw throwable;
}
if (proceed != null) {
ResponseEntity<OAuth2AccessToken> responseEntity = (ResponseEntity<OAuth2AccessToken>)proceed;
OAuth2AccessToken body = responseEntity.getBody();
if (responseEntity.getStatusCode().is2xxSuccessful()) {
response.setCode(0);
response.setMessage(WebResp.SUCCESS_MSG);
response.setData(body);
} else {
log.error("error:{}", responseEntity.getStatusCode().toString());
response.setCode(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getCode());
response.setMessage(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getDesc());
}
}
return ResponseEntity.status(200).body(response);
}
}
加好后获取token结果如下
{
"code": 0,
"message": "操作成功",
"data": {
"access_token": "b27c596d-db80-4393-ad4e-dddcad024b6b",
"token_type": "bearer",
"refresh_token": "21cee608-5775-48b3-8427-d7e894abd947",
"expires_in": 50662,
"scope": "read write"
}
}
更好的方法
因为开始做项目的时候,授权服务器和资源服务器放在了一个服务中,第三方请求到该服务后认证、授权。一种更好的方法是,把授权服务器、资源服务器单独部署,请求到API Gateway里,再路由到授权/资源服务器,在API Gateway 里根据授权/资源服务器返回结果(结果的类型是有限的,可以实现定义个枚举)重写,构造成统一的格式返回,换言之,不直接使用spirng security oauth框架的返回值,把整个服务包裹一层。