前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Spring Boot 使用 JWT 进行身份和权限验证

Spring Boot 使用 JWT 进行身份和权限验证

作者头像
Guide哥
发布2020-05-08 01:06:53
3.5K0
发布2020-05-08 01:06:53
举报
文章被收录于专栏:JavaGuide

上周写了一个 适合初学者入门 Spring Security With JWT 的 Demo,这篇文章主要是对代码中涉及到的比较重要的知识点的说明。

适合初学者入门 Spring Security With JWT 的 Demo 这篇文章中说到了要在十一假期期间对代码进行讲解说明,但是,你们懂得,到了十一就一拖再拖,眼看着今天就是十一的尾声了,抽了一下午完成了这部分内容。

明天就要上班了,擦擦眼泪,还是一条好汉!扶我起来,学不动了。

如果 对 JWT 不了解的话,可以看前几天发的这篇原创文章:《一问带你区分清楚Authentication,Authorization以及Cookie、Session、Token》

Demo 地址:https://github.com/Snailclimb/spring-security-jwt-guide

Controller

这个是 UserControler 主要用来验证权限配置是否生效。

getAllUser()方法被注解@PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")修饰代表这个方法可以被 DEV,PM 这两个角色访问,而deleteUserById() 被注解@PreAuthorize("hasAnyRole('ROLE_ADMIN')")修饰代表只能被 ADMIN 访问。

代码语言:java
复制
/**
 * @author shuang.kou
 */
@RestController
@RequestMapping("/api")
public class UserController {

    private final UserService userService;

    private final CurrentUser currentUser;

    public UserController(UserService userService, CurrentUser currentUser) {
        this.userService = userService;
        this.currentUser = currentUser;
    }

    @GetMapping("/users")
    @PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")
    public ResponseEntity<Page<User>> getAllUser(@RequestParam(value = "pageNum", defaultValue = "0") int pageNum, @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
        System.out.println("当前访问该接口的用户为:" + currentUser.getCurrentUser().toString());
        Page<User> allUser = userService.getAllUser(pageNum, pageSize);
        return ResponseEntity.ok().body(allUser);
    }


    @DeleteMapping("/user")
    @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
    public ResponseEntity<User> deleteUserById(@RequestParam("username") String username) {
        userService.deleteUserByUserName(username);
        return ResponseEntity.ok().build();
    }
}

安全认证工具类

里面主要有一些常用的方法比如 生成 token 以及解析 token 获取相关信息等等方法。

代码语言:java
复制
/**
 * @author shuang.kou
 */
public class JwtTokenUtils {


    /**
     * 生成足够的安全随机密钥,以适合符合规范的签名
     */
    private static byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
    private static SecretKey secretKey = Keys.hmacShaKeyFor(apiKeySecretBytes);

    public static String createToken(String username, List<String> roles, boolean isRememberMe) {
        long expiration = isRememberMe ? SecurityConstants.EXPIRATION_REMEMBER : SecurityConstants.EXPIRATION;

        String tokenPrefix = Jwts.builder()
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .claim(SecurityConstants.ROLE_CLAIMS, String.join(",", roles))
                .setIssuer("SnailClimb")
                .setIssuedAt(new Date())
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
        return SecurityConstants.TOKEN_PREFIX + tokenPrefix;
    }

    private boolean isTokenExpired(String token) {
        Date expiredDate = getTokenBody(token).getExpiration();
        return expiredDate.before(new Date());
    }

    public static String getUsernameByToken(String token) {
        return getTokenBody(token).getSubject();
    }

    /**
     * 获取用户所有角色
     */
    public static List<SimpleGrantedAuthority> getUserRolesByToken(String token) {
        String role = (String) getTokenBody(token)
                .get(SecurityConstants.ROLE_CLAIMS);
        return Arrays.stream(role.split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private static Claims getTokenBody(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }
}

过滤器

先来看一下比较重要的两个过滤器。

第一个过滤器主要用于根据用户的用户名和密码进行登录验证(用户请求中必须有用户名和密码这两个参数),它继承了 UsernamePasswordAuthenticationFilter 并且重写了下面三个方法:

  1. attemptAuthentication(): 验证用户身份。
  2. successfulAuthentication() :用户身份验证成功后调用的方法。
  3. unsuccessfulAuthentication():用户身份验证失败后调用的方法。
代码语言:java
复制
/**
 * @author shuang.kou
 * 如果用户名和密码正确,那么过滤器将创建一个JWT Token 并在HTTP Response 的header中返回它,格式:token: "Bearer +具体token值"
 */
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private ThreadLocal<Boolean> rememberMe = new ThreadLocal<>();
    private AuthenticationManager authenticationManager;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 设置登录请求的 URL
        super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {

        ObjectMapper objectMapper = new ObjectMapper();
        try {
            // 从输入流中获取到登录的信息
            LoginUser loginUser = objectMapper.readValue(request.getInputStream(), LoginUser.class);
            rememberMe.set(loginUser.getRememberMe());
            // 这部分和attemptAuthentication方法中的源码是一样的,
            // 只不过由于这个方法源码的是把用户名和密码这些参数的名字是死的,所以我们重写了一下
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    loginUser.getUsername(), loginUser.getPassword());
            return authenticationManager.authenticate(authRequest);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 如果验证成功,就生成token并返回
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authentication) {

        JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        List<String> roles = jwtUser.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());
        // 创建 Token
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), roles, rememberMe.get());
        // Http Response Header 中返回 Token
        response.setHeader(SecurityConstants.TOKEN_HEADER, token);
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
    }
}

这个过滤器继承了 BasicAuthenticationFilter,主要用于处理身份认证后才能访问的资源,它会检查 HTTP 请求是否存在带有正确令牌的 Authorization 标头并验证 token 的有效性。

代码语言:java
复制
/**
 * 过滤器处理所有HTTP请求,并检查是否存在带有正确令牌的Authorization标头。例如,如果令牌未过期或签名密钥正确。
 *
 * @author shuang.kou
 */
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

    private static final Logger logger = Logger.getLogger(JWTAuthorizationFilter.class.getName());

    public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {

        String authorization = request.getHeader(SecurityConstants.TOKEN_HEADER);
        // 如果请求头中没有Authorization信息则直接放行了
        if (authorization == null || !authorization.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果请求头中有token,则进行解析,并且设置授权信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));
        super.doFilterInternal(request, response, chain);
    }

    /**
     * 这里从token中获取用户信息并新建一个token
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String authorization) {
        String token = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");

        try {
            String username = JwtTokenUtils.getUsernameByToken(token);
            // 通过 token 获取用户具有的角色
            List<SimpleGrantedAuthority> userRolesByToken = JwtTokenUtils.getUserRolesByToken(token);
            if (!StringUtils.isEmpty(username)) {
                return new UsernamePasswordAuthenticationToken(username, null, userRolesByToken);
            }
        } catch (SignatureException | ExpiredJwtException exception) {
            logger.warning("Request to parse JWT with invalid signature . Detail : " + exception.getMessage());
        }
        return null;
    }
}

当用户使用 token 对需要权限才能访问的资源进行访问的时候,这个类是主要用到的,下面按照步骤来说一说每一步到底都做了什么。

  1. 当用户使用系统返回的 token 信息进行登录的时候 ,会首先经过doFilterInternal()方法,这个方法会从请求的 Header 中取出 token 信息,然后判断 token 信息是否为空以及 token 信息格式是否正确。
  2. 如果请求头中有 token 并且 token 的格式正确,则进行解析并判断 token 的有效性,然后会在 Spring Security 全局设置授权信息SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));

CurrentUser

我们在讲过滤器的时候说过,当认证成功的用户访问系统的时候,它的认证信息会被设置在 Spring Security 全局中。那么,既然这样,我们在其他地方获取到当前登录用户的授权信息也就很简单了,通过SecurityContextHolder.getContext().getAuthentication();方法即可。为此,我们实现了一个专门用来获取当前用户的类:

代码语言:java
复制
/**
 * @author shuang.kou
 * 获取当前请求的用户
 */
@Component
public class CurrentUser {

    private final UserDetailsServiceImpl userDetailsService;

    public CurrentUser(UserDetailsServiceImpl userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public JwtUser getCurrentUser() {
        return (JwtUser) userDetailsService.loadUserByUsername(getCurrentUserName());
    }

    /**
     * TODO:由于在JWTAuthorizationFilter这个类注入UserDetailsServiceImpl一致失败,
     * 导致无法正确查找到用户,所以存入Authentication的Principal为从 token 中取出的当前用户的姓名
     */
    private static String getCurrentUserName() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() != null) {
            return (String) authentication.getPrincipal();
        }
        return null;
    }
}

异常相关

JWTAccessDeniedHandler实现了AccessDeniedHandler主要用来解决认证过的用户访问需要权限才能访问的资源时的异常。

代码语言:java
复制
/**
 * @author shuang.kou
 * AccessDeineHandler 用来解决认证过的用户访问需要权限才能访问的资源时的异常
 */
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
    /**
     * 当用户尝试访问需要权限才能的REST资源而权限不足的时候,
     * 将调用此方法发送401响应以及错误信息
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        accessDeniedException = new AccessDeniedException("Sorry you don not enough permissions to access it!");
        response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
    }
}

JWTAuthenticationEntryPoint 实现了 AuthenticationEntryPoint 用来解决匿名用户访问需要权限才能访问的资源时的异常

代码语言:java
复制
/**
 * @author shuang.kou
 * AuthenticationEntryPoint 用来解决匿名用户访问需要权限才能访问的资源时的异常
 */
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    /**
     * 当用户尝试访问需要权限才能的REST资源而不提供Token或者Token过期时,
     * 将调用此方法发送401响应以及错误信息
     */
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }
}

配置类

在 SecurityConfig 配置类中我们主要配置了:

  1. 密码编码器 BCryptPasswordEncoder(存入数据库的密码需要被加密)。
  2. AuthenticationManager 设置自定义的 UserDetailsService以及密码编码器;
  3. 在 Spring Security 配置指定了哪些路径下的资源需要验证了的用户才能访问、哪些不需要以及哪些资源只能被特定角色访问;
  4. 将我们自定义的两个过滤器添加到 Spring Security 配置中;
  5. 将两个自定义处理权限认证方面的异常类添加到 Spring Security 配置中;
代码语言:java
复制
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserDetailsServiceImpl userDetailsServiceImpl;

    /**
     * 密码编码器
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService createUserDetailsService() {
        return userDetailsServiceImpl;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 设置自定义的userDetailsService以及密码编码器
        auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(bCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and()
                // 禁用 CSRF
                .csrf().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/auth/login").permitAll()
                // 指定路径下的资源需要验证了的用户才能访问
                .antMatchers("/api/**").authenticated()
                .antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
                // 其他都放行了
                .anyRequest().permitAll()
                .and()
                //添加自定义Filter
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                // 不需要session(不创建会话)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 授权异常处理
                .exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint())
                .accessDeniedHandler(new JWTAccessDeniedHandler());

    }

}

跨域:

在这里踩的一个坑是:如果你没有设置exposedHeaders("Authorization")暴露 header 中的"Authorization"属性给客户端应用程序的话,前端是获取不到 token 信息的。

代码语言:java
复制
@Configuration
public class CorsConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                //暴露header中的其他属性给客户端应用程序
                //如果不设置这个属性前端无法通过response header获取到Authorization也就是token
                .exposedHeaders("Authorization")
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                .maxAge(3600);
    }
}

JWT 常见优缺点分析

优点

  1. 无状态,服务器不需要存储 Session 信息。
  2. 有效避免了 CSRF 攻击。
  3. 适合移动端应用。
  4. 单点登录友好。

缺点

  1. 注销登录等场景下 token 还有效
  2. token 的续签问题

关于 JWT 常见优缺点分析以及解决方案会在下一篇文章中单独介绍到。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-10-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 JavaGuide 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Controller
  • 安全认证工具类
  • 过滤器
  • CurrentUser
  • 异常相关
  • 配置类
  • JWT 常见优缺点分析
    • 优点
      • 缺点
      相关产品与服务
      多因子身份认证
      多因子身份认证(Multi-factor Authentication Service,MFAS)的目的是建立一个多层次的防御体系,通过结合两种或三种认证因子(基于记忆的/基于持有物的/基于生物特征的认证因子)验证访问者的身份,使系统或资源更加安全。攻击者即使破解单一因子(如口令、人脸),应用的安全依然可以得到保障。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档