SpringSecurity在UserDetailService抛异常

背景

所有的项目都会涉及到账户安全问题,会对账号设置密码或者短信验证码或者其他的三方授权,用户才能登录到系统中。

而做得比较完善的框架为数不对,SpringSecurity是目前比较主流的认证鉴权框架。Oauth2是比较完善的认证协议。两者结合便出现了SpringSecurityOAuth2.本篇将分享在使用这套框架的时候后关于异常怎么抛出的问题。

使用UserDetailService

使用SpringSecurity我们就知道需要定义UserDetailService来通过username拉去用户的信息(账号、密码、锁定状态、过期状态等等)。而这套做法主要真多password模式的。当我们需要使用自定义的模式的时候,验证逻辑只有写在UserdetailService的内部。

例如下边代码:

package com.xxx.xxx.auth.grant.mobile;



/**
 * 手机验证码登陆, 用户相关获取
 * (主要用于会员端的手机验证码登录)
 * 
 * @author marker
 */
@Slf4j
@Service("mobileUserDetailsService")
public class MobileUserDetailsService extends MyUserDetailsServiceAdapter implements MyUserDetailsService {


    @Resource
    private RemoteUserService remoteUserService;

    @Resource
    private RedisTemplate redisTemplate;

    @Resource
    private LoginCountService loginCountService;

    @Resource
    private SystemAuthConfig systemAuthConfig;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String phone) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String platform = request.getHeader(CommonHeaders.PLATFORM);// 平台

        String code     = (String) request.getAttribute("code");// 前端传递的验证码
        String codeType = (String) request.getAttribute("codeType");// 短信验证类型
        String source   = (String) request.getAttribute("source"); // 会员来源(来自门店服务的member_source)
        String countryCode   = (String) request.getAttribute("countryCode"); // 国家区号 不含+
        // 获取redis短信验证码
        String key = String.format(KeyFormat.SMS_CODE_FORMAT, codeType, countryCode, phone);
        String val = (String) redisTemplate.opsForValue().get(key);

        // 判断是否开启了ios审核模式
        if (systemAuthConfig.getAuditEnable()) {
            String auditPhone = systemAuthConfig.getAuditPhone();
            if (StringUtil.isNotBlank(auditPhone) && auditPhone.equals(phone)) {
                val = systemAuthConfig.getAuditPhoneCode();
            }
        }

        if (!code.equals(val)) { // 校验验证码是否正确
            // 登录失败次数+1
            loginCountService.countPlus1(phone);
            int count = loginCountService.getRemainLoginCount(phone);
            log.warn("登录失败,还可登录{}次", count);
            throw new LoginException("验证码不正确");
        }
        redisTemplate.delete(key); // 验证完成后删除短信验证码

        String userInfo = (String) request.getAttribute("userInfo");// 用户信息
        if (StringUtil.isBlank(userInfo)) {
            userInfo = "{}";
        }
        UserInfoExt wxUserInfo = JSON.parseObject(userInfo, UserInfoExt.class);

        // 如果Type不等于绑定信息
        RegisterWxUserDTO registerWxUserDTO = new RegisterWxUserDTO();
        registerWxUserDTO.setCountryCode(countryCode);
        registerWxUserDTO.setPhone(phone);
        registerWxUserDTO.setNickname(wxUserInfo.getNickname());
        registerWxUserDTO.setAvatar(wxUserInfo.getHeadimgurl());
        registerWxUserDTO.setOpenid(wxUserInfo.getOpenid());
        registerWxUserDTO.setUnionid(wxUserInfo.getUnionid());
        registerWxUserDTO.setPlatform(platform);
        registerWxUserDTO.setSource(source);
        // 用于判断把用户信息绑定到对应的openid里
        registerWxUserDTO.setType(wxUserInfo.getOauthType());

        R<UserInfo> r1 = remoteUserService.registerByPhone(registerWxUserDTO);
        if (r1.isFaild()) {
            throw new LoginException(r1.getMsg());
        }

        UserInfo thirdUser = r1.getData();
        thirdUser.getSysUser().setPassword(NOOP + "marker");

        return getUserDetails(thirdUser);
    }
}

这段代码有几处地方抛出了异常,而这些异常在SpringSecurity框架下经过层层转换,到你真真需要拦截的时候,发现拦截不了了。

为什么UserDetailService抛出的异常不能拦截到

来看看SpringSecurity的源代码 在DaoAuthenticationProvider.class类中,找到这个retrieveUser方法

protected final UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication);

这个方法主要就是调用userdetailService.loadUserByUsername 方法,加载用户的信息,包含账号密码、角色、权限等等信息

来看看他的实现代码,不得不吐槽一下为啥转换跑出来的异常。。。


protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation"); } return loadedUser; } catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; } catch (InternalAuthenticationServiceException ex) { throw ex; } catch (Exception ex) { throw new InternalAuthenticationServiceException(ex.getMessage(), ex); } }

他我们自定义的异常做了一层转换,导致我们的异常不能按照我们的思路去捕获。

那怎么解决呢,这段代码hi框架层提供了的,我们如果改他的源代码会比较麻烦,或者是开源项目提交一段优化代码,但别人的设计也可能有他设计的道理。所以在全局拦截器中拦截
InternalAuthenticationServiceException 在把原始的异常拿出来包装返回对象。

使用WebResponseExceptionTranslator来解救异常

这段代码的意思就是提取原始的异常信息,然后判断,然后再转换为R对象返回给前端。
当然这个包装的异常提供了序列化的机制,能够自动转换为R对象。

package com.xxx.xxx.common.security.component;

/**
 * @author system
 *  OAuth Server 异常处理,重写oauth 默认实现
 */
@Slf4j
public class MyWebResponseExceptionTranslator implements WebResponseExceptionTranslator {

    private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

    protected MessageSourceAccessor messages = SecurityMessageSourceUtil.getAccessor();

    @Override
    public ResponseEntity<OAuth2Exception> translate(Exception e) {

        // Try to extract a SpringSecurityException from the stacktrace
        Throwable[] causeChain = throwableAnalyzer.determineCauseChain(e);

        Exception ase = (AuthenticationException) throwableAnalyzer
                .getFirstThrowableOfType(AuthenticationException.class, causeChain);


        // LoginException 处理
        ase = (LoginException) throwableAnalyzer.getFirstThrowableOfType(LoginException.class,
                causeChain);
        if (ase != null) {//
            return handleRException(new RException(((LoginException) ase).getResult()));
        }
        // RException 处理
        ase = (RException) throwableAnalyzer.getFirstThrowableOfType(RException.class,
                causeChain);
        if (ase != null) {//
            return handleRException((RException)ase);
        }


        // OAuth2Exception
        ase = (ClientException) throwableAnalyzer.getFirstThrowableOfType(ClientException.class,
                causeChain);
        if (ase != null) {
            // 如果是oauth2的异常,读取自定义message配置
            String oAuth2ErrorCode = ((ClientException) ase).getErrorMessage();
            String msg = messages.getMessage(
                    "springSecurityOauth2."+oAuth2ErrorCode, ase.getMessage(), Locale.CHINA);
            return handleOAuth2Exception(OAuth2Exception.create(oAuth2ErrorCode, msg));
        }


        return handleOAuth2Exception(new ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e));

    }

    private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) {

        int status = e.getHttpErrorCode();
        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.CACHE_CONTROL, "no-store");
        headers.set(HttpHeaders.PRAGMA, "no-cache");
        headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.toString());

        // 客户端异常直接返回客户端,不然无法解析
        if (e instanceof ClientAuthenticationException) {
            return new ResponseEntity<>(e, headers, HttpStatus.valueOf(status));
        }
        return new ResponseEntity<>(new MyAuth2Exception(e.getMessage(), e.getOAuth2ErrorCode()), headers,
                HttpStatus.valueOf(status));

    }

    private ResponseEntity<OAuth2Exception> handleRException(RException e) {

        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.CACHE_CONTROL, "no-store");
        headers.set(HttpHeaders.PRAGMA, "no-cache");
        headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.toString());

        MyAuth2Exception exception = new MyAuth2Exception(e.getResult());
        // 客户端异常
        return new ResponseEntity<>(exception, headers,
                HttpStatus.valueOf(200));

    }

}

来源: 雨林博客(www.yl-blog.com)