Spring Security 动手实现 JWT Token 验证 篇五

/ SpringSecurityJWT / 没有评论 / 221浏览

Spring Security 基于 JWT Token 认证

在开始这篇文章之前,我们似乎应该思考下为什么需要搞清楚 Spring Security 的内部工作原理? 按照第二篇文章中的配置,一个简单的表单认证不就达成了吗? 更有甚者,为什么我们不自己写一个表单认证,用过滤器即可完成,大费周章引入Spring Security,看起来也并没有方便多少。 对的,在引入 Spring Security 之前,我们得首先想到,是什么需求让我们引入了 Spring Security,以及为什么是 Spring Security,而不是 shiro 等等其他安全框架。我的理解是有如下几点:

这一节,为了对之前分析的 Spring Security 源码和组件有一个清晰的认识,我实现一个 Restful JWT Token 的认证。

定义需求

在表单登录中,一般使用数据库中配置的用户表,权限表,角色表,权限组表…这取决于你的权限粒度,但本质都是借助了一个持久化存储,维护了用户的角色权限,而后给出一个 /login 作为登录端点,Restful 接口提交用户名和密码,验证用户名密码后返回一个 accessToken,和Refresh Token,在一定时间内可以使用 Refresh Token 获取新的 accessToken

在我们的登录 demo 中,也是类似的,我们把用户的信息,用户名、密码、权限存储在内存中(ConcurrentHashMap)来模拟数据库存储。

设计概述

整个认证流程如下图: 2019622153437-spring-security-architecture

整个 Spring Security 都是围绕以上这张架构图展开的,最顶层为最核心最抽象的 AuthenticationManager 接口, ProviderManagerAuthenticationManager 的一个具体实现,功能如其名字他的作用是管理 Provider 的, AuthenticationProvider 才是真正认证的接口,因此我们在实践中要实现我们自己的认证方式,也就是 AuthenticationProvider 的一个具体实现。当然可以实现多个,如果有多种认证方式,现实中往往也是有多种认证方式。

在此 Demo 中一共涉及两个 Filter ,LoginProcessingFilter 处理登录请求获取Access Token 和 Refresh Token 的过滤器。ApiAuthenticationProcessingFilter 认证 Token 是否有效以及用户是否有资源访问权限的过滤器。

这两个 Filter 的都分别对应自己的 Token 、Provider 实现类,整个逻辑和上图如出一辙。

简单做一个类比:

LoginProcessingFilter –> UsernamePasswordAuthenticationFilter
JwtUsernamePasswordAuthenticationToken –> UsernamePasswordAuthenticationToken
ProviderManager –> ProviderManager
JwtAuthenticationProvider –> DaoAuthenticationProvider
MemoryUserService –> UserDetailsService

JwtUsernamePasswordAuthenticationToken

主要用来存储认证用户名、密码、Refresh Token,这里扩展了 details 字段来存储 Refresh Token

/**
 * @author Created by https://zhangaoo.com.<br/>
 * @version Version: 0.0.1
 * @date DateTime: 2019/06/22 16:06:00<br/>
 */
public class JwtUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
    /**
     * 用来传递刷新 token
     */
    private Object details;

    public JwtUsernamePasswordAuthenticationToken(Object principal, Object credentials,Object details) {
        super(principal, credentials);
        //for password refresh token
        this.details = details;
    }

    public JwtUsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }

    @Override
    public Object getDetails(){
        return this.details;
    }
}

LoginProcessingFilter

Filter 主要是对登录用户信息或 Refresh Token 的简单 check ,具体检查在对应的 Provider

/**
 * @author Created by https://zhangaoo.com.<br/>
 * @version Version: 0.0.1
 * @date DateTime: 2019/06/22 16:02:00<br/>
 */
public class LoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
    private final ObjectMapper objectMapper;
    private final AuthenticationFailureHandler failureHandler;
    private final AuthenticationSuccessHandler successHandler;

    public  LoginProcessingFilter(String defaultFilterProcessesUrl,
                                 ObjectMapper objectMapper,
                                 AuthenticationSuccessHandler successHandler,
                                 AuthenticationFailureHandler failureHandler) {
        super(defaultFilterProcessesUrl);
        this.objectMapper = objectMapper;
        this.failureHandler = failureHandler;
        this.successHandler = successHandler;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        LoginRequest loginRequest;
        try {
            loginRequest = objectMapper.readValue(request.getReader(), LoginRequest.class);
        } catch (Exception e){
            throw new BadCredentialsException("Username or Password or refresh_token is invalid");
        }
        //对用户名密码或刷新 token 做初步的认证
        if(!checkUserInfo(loginRequest)){
            throw new BadCredentialsException("Username or Password or refresh_token is invalid");
        }
        JwtUsernamePasswordAuthenticationToken token = new JwtUsernamePasswordAuthenticationToken(
                loginRequest.getUsername(),
                loginRequest.getPassword(),
                loginRequest.getRefreshToken());
        return this.getAuthenticationManager().authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {
        successHandler.onAuthenticationSuccess(request,response,authResult);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
            throws IOException, ServletException {
        SecurityContextHolder.clearContext();
        failureHandler.onAuthenticationFailure(request, response, failed);
    }

    private boolean checkUserInfo(LoginRequest loginRequest){
        //check refresh token first
        if(StringUtils.isNotBlank(loginRequest.getRefreshToken())){
            return true;
        } else {
            //check username or password
            if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) {
                return false;
            } else {
                return true;
            }
        }
    }
}

defaultFilterProcessesUrl:用来传递登录接口地址,改过滤器只处理登录和刷新 Token 这两个操作,这里的登录接口的地址是 /api/tokensAuthenticationSuccessHandler:认证成功的自定义处理接口,具体的操作就是颁发 Token AuthenticationFailureHandler:认证失败的处理方法,返回友好的提示信息给调用者,具体实现可参考代码。

JwtAuthenticationProvider

登录具体的认证,用户密码检查,用户状态检查或 Refresh Token 检查. 这里注意返回的是也是 Authentication Token 自定义实现类,包括 LoginProcessingFilter 也是,注意两个地方返回是调用的是是两个不同的方法 LoginProcessingFilter 返回是认证还没结束父类方法中会执行 setAuthenticated(false);,等JwtAuthenticationProvider 认证结束后才是完整的认证结束,其父类方法中会调用 super.setAuthenticated(true);,标识已经认证通过了。

/**
 * @author Created by https://zhangaoo.com.<br/>
 * @version Version: 0.0.1
 * @date DateTime: 2019/06/22 16:23:00<br/>
 */
@Slf4j
@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private JwtTokenHelper tokenHelper;
    @Autowired
    private MemoryUserService memoryUserService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.notNull(authentication, "No authentication data provided");
        //username and password
        String username = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();
        String refreshToken = (String) authentication.getDetails();
        //当本次请求是刷新 Token 时
        if (StringUtils.isNotBlank(refreshToken)) {
            // decode and verify refresh token is valid
            Jws<Claims> jwsClaims = tokenHelper.parseClaims(refreshToken);
            //check token type only access_token
            String tokenType = jwsClaims.getBody().get("type").toString();
            if (!REFRESH_TOKEN.equals(tokenType)) {
                log.warn("Invalid token type,must be a refresh token,token:" + refreshToken + ",type:" + tokenType);
                throw new InvalidJwtToken("Invalid token type,must be a refresh token");
            }
            UserContext userContext = UserContext.create(jwsClaims.getBody().get("userId", String.class),
                    jwsClaims.getBody().getSubject(), Collections.emptyList(),
                    Constant.REFRESH_TOKEN);
            return new JwtUsernamePasswordAuthenticationToken(userContext, null, Collections.emptyList());
        } else {
            //本次请求是获取 Access Token 和 Refresh Token
            MyUserDetails userDetails = memoryUserService.loadUserByUsername(username);
            //Check password
            if (!userDetails.getPassword().equals(password)) {
                throw new BadCredentialsException("Authentication Failed. Invalid username or password.");
            }
            //检查用户状态
            if (!userDetails.isAccountNonExpired() || !userDetails.isAccountNonLocked() || !userDetails.isCredentialsNonExpired() || !userDetails.isEnabled()) {
                log.warn("User was deleted please contact administrator: " + userDetails.getUsername());
                throw new UserDisabledException("User was disabled please contact administrator");
            }
            UserContext userContext = UserContext.create(userDetails.getUserId(), userDetails.getUsername(), userDetails.getAuthorities(), Constant.ACCESS_TOKEN);
            //认证通过,传递自定义的用户上下文(注意两个构造器的区别)
            return new JwtUsernamePasswordAuthenticationToken(userContext, null, Collections.emptyList());
        }
    }
    // 标识该 Provider 只处理 JwtUsernamePasswordAuthenticationToken 一种 Token
    @Override
    public boolean supports(Class<?> authentication) {
        return (JwtUsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

配置 RestSecurityConfig

/**
 * @author Created by https://zhangaoo.com.<br/>
 * @version Version: 0.0.1
 * @date DateTime: 2019/06/28 10:01:00<br/>
 */
@Slf4j
@Configuration
@EnableWebSecurity
public class RestSecurityConfig extends WebSecurityConfigurerAdapter {
    //认证用户身份的provider(处理登录的provider)
    @Autowired
    private JwtAuthenticationProvider jwtAuthenticationProvider;
    //访问控制,检查 Token 权限的Provider
    @Autowired
    private ApiAuthenticationProvider apiAuthenticationProvider;
    //认证通过的自定义处理方法
    @Autowired
    private AuthenticationSuccessHandler successHandler;
    //认证失败的自定义处理方法
    @Autowired
    private AuthenticationFailureHandler failureHandler;
    //jackson 序列化用的工具类
    @Autowired
    private ObjectMapper objectMapper;
    //Spring Security 默认帮我们实现好的Provider Manager
    @Autowired
    private AuthenticationManager authenticationManager;
    //解析、认证、生成Token的工具类
    @Autowired
    private JwtTokenHelper jwtTokenHelper;
    //获取所有接口权限的服务
    @Autowired
    private AuthorityService authorityService;
    //权限不足访问拒绝处理类
    @Autowired
    private AjaxAccessDeniedHandler ajaxAccessDeniedHandler;

    /**
     * 注意不要忘记注入这个 Bean ,否则做具体认证时加载不到实现认证的Provider,比如这里的 jwtAuthenticationProvider
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * Filter for login API
     * @param loginEntryPoint
     * @return
     * @throws Exception
     */
    protected LoginProcessingFilter buildLoginProcessingFilter(String loginEntryPoint) throws Exception {
        LoginProcessingFilter filter = new LoginProcessingFilter(loginEntryPoint, objectMapper,successHandler, failureHandler );
        filter.setAuthenticationManager(this.authenticationManager);
        return filter;
    }

    /**
     * Filter for resource request API
     * @param pathsToSkip
     * @param pattern
     * @return
     * @throws Exception
     */
    protected ApiAuthenticationProcessingFilter buildApiAuthenticationProcessingFilter(List<String> pathsToSkip, List<String> pattern) throws Exception {
        SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, pattern);
        ApiAuthenticationProcessingFilter filter
                = new ApiAuthenticationProcessingFilter(failureHandler, matcher, jwtTokenHelper);
        filter.setAuthenticationManager(this.authenticationManager);
        return filter;
    }
    //注入我们自己实现的 Provider 
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(jwtAuthenticationProvider);
        auth.authenticationProvider(apiAuthenticationProvider);
    }
    //核心配置
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        List<String> permitAllEndpointList = Arrays.asList(AUTHENTICATION_URL);
        //模拟从数据库获取接口的访问权限,并初始化
        List<ApiAuthority> authorities = authorityService.getAllAuthority();
        for(ApiAuthority rule : authorities){
            http
                    .authorizeRequests()
                    .antMatchers(HttpMethod.resolve(rule.getMethod()), rule.getApiUrl()).hasAuthority(rule.getAuthority());
        }
        http
                .csrf().disable()
                .exceptionHandling().accessDeniedHandler(ajaxAccessDeniedHandler)
            .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//使用 JWT Token 接口无状态
            .and()
                .authorizeRequests()
                .antMatchers(permitAllEndpointList.toArray(new String[permitAllEndpointList.size()])).permitAll()//登录接口不需要认证
            .and()
                .authorizeRequests()
                .antMatchers(API_ROOT_URL).authenticated()//除了登录接口,所有满足/api/**都需要认证
            .and()
                .addFilterBefore(new CustomCorsFilter(), UsernamePasswordAuthenticationFilter.class)
                //注册 LoginProcessingFilter  注意放置的顺序 这很关键
                .addFilterBefore(buildLoginProcessingFilter(AUTHENTICATION_URL), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(buildApiAuthenticationProcessingFilter(permitAllEndpointList,Arrays.asList(API_ROOT_URL)), UsernamePasswordAuthenticationFilter.class);
    }
}

WebSecurityConfigAdapter 提供了我们很大的便利,不需要关注 AuthenticationManager 什么时候被创建,只需要使用其暴露的configure(AuthenticationManagerBuilder auth) 便可以添加我们自定义的 Provider 。剩下的一些细节,注释中基本都写了出来。

运行效果

获取 Access Token 和 Refresh Token

➜  ~ curl -H 'Content-Type: application/json' -X POST -d '{"username": "admin","password":"admin123456"}' http://127.0.0.1:8808/api/tokens
{"code":200,"data":{"access_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MTM2LCJleHAiOjE1NjE5MDc3MzZ9.OLD1IcMG4pLRzW4uQtDs4TCF18lsBSB7-BJAbEaZStOeFMxGHSfyG2D3G960R7Qn0i7SKq8ryxc-FrqiwOMsVg","refresh_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6IjEiLCJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImlzcyI6InpoYW5nYW9vLmNvbSIsImp0aSI6IjliMDBiNzAwLTRmNDYtNDczZS04ODk3LTBkY2U4YzE5ZDRkYiIsImlhdCI6MTU2MTkwNDEzNiwiZXhwIjoxNTYxOTMyOTM2fQ.WRoQkZE8-bP6ENUCKqbUTO5DNo0gu7z7J-EnLqC9cDjgh6EfopOx0wM0QDCp7iU_B1186tfAXYw-tczdbHEFDw"}}% 

刷新 Token

➜  ~ curl -H 'Content-Type: application/json' -X POST -d '{"refresh_token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6IjEiLCJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImlzcyI6InpoYW5nYW9vLmNvbSIsImp0aSI6IjliMDBiNzAwLTRmNDYtNDczZS04ODk3LTBkY2U4YzE5ZDRkYiIsImlhdCI6MTU2MTkwNDEzNiwiZXhwIjoxNTYxOTMyOTM2fQ.WRoQkZE8-bP6ENUCKqbUTO5DNo0gu7z7J-EnLqC9cDjgh6EfopOx0wM0QDCp7iU_B1186tfAXYw-tczdbHEFDw"}' http://127.0.0.1:8808/api/tokens
{"code":200,"data":{"access_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MzE4LCJleHAiOjE1NjE5MDc5MTh9.TJ-0SrIbiVW81nA9Ep91Wq1GEGVhMqFIq9ppi9ZRiYkbk88W1DG84YhA1m9KIvWgOmb6tYxPVIvLsM_9QSH06Q"}}% 

接口权限测试

测试代码在内存中写死了两个用户,admin/admin123456testUser/testUser123456admin 同时具有 adminuser 两个权限,testUser 只有 user 权限。 访问 /api/user/all/api/user/admin 需要 admin 权限,而访问 /api/user/testUser 只需要 user 权限。

正常测试

➜  ~ curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MzE4LCJleHAiOjE1NjE5MDc5MTh9.TJ-0SrIbiVW81nA9Ep91Wq1GEGVhMqFIq9ppi9ZRiYkbk88W1DG84YhA1m9KIvWgOmb6tYxPVIvLsM_9QSH06Q' http://127.0.0.1:8808/api/user/all

{"code":200,"data":{"admin":{"id":"1","username":"admin","password":"admin123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"testUser":{"id":"2","username":"testUser","password":"testUser123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}}}%  
➜  ~ curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0VXNlciIsInNjb3BlcyI6W10sInVzZXJJZCI6IjIiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0NzU3LCJleHAiOjE1NjE5MDgzNTd9.BIFKr7yznjVClWeiamiB8WfkYk3iVoGDEM0f90Hy7idrTOJ8udBkTLtAIPrnMgg1w_Hk_60EEPx00lNXDb337Q' http://127.0.0.1:8808/api/user/testUser
{"code":200,"data":{"id":"2","username":"testUser","password":"testUser123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}}%  

拒绝访问测试

curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0VXNlciIsInNjb3BlcyI6W10sInVzZXJJZCI6IjIiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0NzU3LCJleHAiOjE1NjE5MDgzNTd9.BIFKr7yznjVClWeiamiB8WfkYk3iVoGDEM0f90Hy7idrTOJ8udBkTLtAIPrnMgg1w_Hk_60EEPx00lNXDb337Q' http://127.0.0.1:8808/api/user/all     

{
    "code": 5000,
    "data": "Access is denied"
} 

如果要自定义拒绝访问返回信息,一个例子如下:

@Slf4j
@Component
public class AjaxAccessDeniedHandler implements AccessDeniedHandler {
    private final ObjectMapper mapper;
    @Autowired
    public AjaxAccessDeniedHandler(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        mapper.writeValue(response.getWriter(), new ResponseData(ResponseData.FAIL, AuthMessages.AUTH_009));
    }
}

以上代码完整的实现了一个Spring Security 整合 JWT Token 例子,上述 Demo 未详细介绍的 Spring Cloud 相关的技术还包括 zuul 网关 Feign Client 、Eureka等,后续会继续介绍。

源代码参考 GitHub