自定义Spring security oauth2 响应/异常信息

最近使用spring security oauth2 做开放平台,想要返回统一的返回值格式,做的过程中发现相当麻烦,好在效果总算达到了,在这里总结一下,希望能帮助到遇到相同问题的同学。实现方式并不完美,如果你有更好的方式或发现文内有问题,希望不吝赐教。

Oauth2 协议有4种认证方式,项目里用到了客户端模式和密码模式,都是依托于spring security oauth2。开发过程中发现框架原生响应的格式一般如下:

{
    "error": "invalid_grant",
    "error_description": "Bad credentials"
}

为了调用方的便利、保证平台的统一性,希望统一响应的格式,最起码有code和友好的提示信息message,比如:

{
    "code": 401101,
    "message": "客户端认证失败"
}

开发过程中需要对如下几种请求统一响应值格式

  1. 客户端/密码模式获取token失败——参数中未携带client_id、参数中client_id或client_secret不正确
  2. 密码模式获取token失败——参数中userName或password不正确
  3. 资源接口请求失败——未带token、token过期、token有效但资源权限不足
  4. 正常的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框架的返回值,把整个服务包裹一层。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,681评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,710评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,623评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,202评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,232评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,368评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,795评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,461评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,647评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,476评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,525评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,226评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,785评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,857评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,090评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,647评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,215评论 2 341