实现JWT工具类

JWT相关配置(密钥、过期时间等)封装为一个常量类:

package top.wteng.jwtsecurity;

public class SecurityConstants {
    public static final String JWT_HEADER = "access-token";
    public static final int EXPIRATION_MINUTES = 24;
    public static final String ROLE_CLAIMS = "role";
    public static final String TOKEN_PREFIX = "";
    public static final String SECRET_KEY = "JUhs6j32^lderfewt3422*3243785fdfhght34u454trt,ldi823j3";
}

实现JWT工具类如下:

package top.wteng.jwtsecurity.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import top.wteng.jwtsecurity.SecurityConstants;

import javax.crypto.SecretKey;
import javax.xml.bind.DatatypeConverter;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

public class JwtUtil {
    private static final byte[] API_KEY_SECRET_BYTES = DatatypeConverter.parseBase64Binary(SecurityConstants.SECRET_KEY);
    private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(API_KEY_SECRET_BYTES);

    public static String createToken(String username, String id, List<String> roles) {
        long expiration = SecurityConstants.EXPIRATION_MINUTES * 60;
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + expiration * 1000);
        String tokenPrefix = Jwts.builder()
                .setHeaderParam("type", "JWT")
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256)
                .claim(SecurityConstants.ROLE_CLAIMS, String.join(",", roles))
                .setId(id)
                .setIssuer("SnailClimb")
                .setIssuedAt(createdDate)
                .setSubject(username)
                .setExpiration(expirationDate)
                .compact();
        return SecurityConstants.TOKEN_PREFIX + tokenPrefix; // 添加 token 前缀 "Bearer ";
    }

    public static String getId(String token) {
        Claims claims = getClaims(token);
        return claims.getId();
    }

    public static UsernamePasswordAuthenticationToken getAuthentication(String token) {
        Claims claims = getClaims(token);
        List<SimpleGrantedAuthority> authorities = getAuthorities(claims);
        String userName = claims.getSubject();
        return new UsernamePasswordAuthenticationToken(userName, token, authorities);
    }

    private static List<SimpleGrantedAuthority> getAuthorities(Claims claims) {
        // 注意传入角色信息时角色要以ROLE_开头,因为在校验时springscrurity会自动在角色名前加上前缀再比较
        String role = (String) claims.get(SecurityConstants.ROLE_CLAIMS);
        return Arrays.stream(role.split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

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

过滤器实现

package top.wteng.jwtsecurity.filter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import io.jsonwebtoken.JwtException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import top.wteng.jwtsecurity.SecurityConstants;
import top.wteng.jwtsecurity.util.JwtUtil;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private final Logger logger = LoggerFactory.getLogger(JwtAuthorizationFilter.class);

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

    private String getAccessToken(HttpServletRequest request) {
        String token = request.getHeader(SecurityConstants.JWT_HEADER);
        if (token == null) {
            token = request.getParameter(SecurityConstants.JWT_HEADER);
        }
        return token;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = getAccessToken(request);
        logger.info("Token Detected: {}", token);
        if (token == null) {
            SecurityContextHolder.clearContext();
            chain.doFilter(request, response);
            return;
        }
        UsernamePasswordAuthenticationToken authenticationToken = null;
        try {
            authenticationToken = JwtUtil.getAuthentication(token);
        } catch (JwtException e) {
            logger.error("Invalid Token: {}", token);
            SecurityContextHolder.clearContext();
            chain.doFilter(request, response);
            return;
        }
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        chain.doFilter(request, response);
    }
}

权限错误时的处理策略

有两种权限错误:

  • 未携带或token非法(校验错误或过期)
  • token正确,但是没有足够的权限,如携带的是普通用户的token,请求的却是只有管理员才能访问的接口,此时应抛出403

针对第一中情况,实现AuthenticationEntryPoint实现一个进入点,返回异常提示:

package top.wteng.jwtsecurity.exception;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import top.wteng.jwtsecurity.util.ResultUtil;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

public class UnAuthorizedExceptionEntryPoint implements AuthenticationEntryPoint {
    private final ObjectMapper mapper = new ObjectMapper();
    /**
     * 当用户尝试访问需要权限才能的REST资源而不提供Token或者Token错误或者过期时,
     * 将调用此方法发送401响应以及错误信息
     */
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=utf8"); // 通过json格式返回
        int statusCode = HttpServletResponse.SC_UNAUTHORIZED;
        response.setStatus(statusCode);
        PrintWriter writer = response.getWriter();
        writer.write(mapper.writeValueAsString(ResultUtil.error(statusCode, "UnAuthorized")));
        writer.flush();
        writer.close();
    }
}

针对第二种错误,实现AccessDeniedHandler,抛出403错误:

package top.wteng.jwtsecurity.exception;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import top.wteng.jwtsecurity.util.ResultUtil;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    private final ObjectMapper mapper = new ObjectMapper();
    /**
     * 当用户尝试访问需要权限才能的REST资源而权限不足的时候,
     * 将调用此方法发送403响应以及错误信息
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=utf8");
        int statusCode = HttpServletResponse.SC_FORBIDDEN;
        response.setStatus(statusCode);
        PrintWriter writer = response.getWriter();
        writer.write(mapper.writeValueAsString(ResultUtil.error(statusCode, "No Enough Permissions")));
        writer.flush();
        writer.close();
    }
}

配置

继承WebSecurityConfigurerAdapter,实现相关配置,将过滤器、错误处理类等组合起来:

package top.wteng.jwtsecurity.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import top.wteng.jwtsecurity.SecurityConstants;
import top.wteng.jwtsecurity.exception.JwtAccessDeniedHandler;
import top.wteng.jwtsecurity.exception.UnAuthorizedExceptionEntryPoint;
import top.wteng.jwtsecurity.filter.JwtAuthorizationFilter;

import java.util.Arrays;
import java.util.Collections;

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilter(new JwtAuthorizationFilter(authenticationManager()))
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(new UnAuthorizedExceptionEntryPoint())
                .accessDeniedHandler(new JwtAccessDeniedHandler());
        http.headers().frameOptions().disable();
    }

    /**
     * Cors配置
     **/
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        org.springframework.web.cors.CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Collections.singletonList("*"));
        configuration.setAllowedHeaders(Collections.singletonList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "OPTIONS"));
        configuration.setExposedHeaders(Collections.singletonList(SecurityConstants.JWT_HEADER));
        configuration.setAllowCredentials(false);
        configuration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

Controller中使用

Controller中在需要授权的接口上添加@PreAuthorize注解即可:

@Controller
public class UserController {
    
    @RequestMapping(value = "/user", method = RequestMethod.GET)
    @PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_ADMIN')")
    @ResponseBody
    public User getUser() {
        // TOOD
    }
}
springscurity会自动在角色前加上ROLE_前缀再与用户角色匹配,即即使写成@PreAuthorize('hasAnyRole('USER')'),在匹配时也会变成ROLE_USER去匹配,因此在设置SimpleGrantedAuthority时,设置的角色名要带前缀。

获取用户凭据或认证信息

  • 在普通bean中可以通过上下文获取:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof AnonymousAuthenticationToken)) {
    String currentUserName = authentication.getName();
    return currentUserName;
}
  • Controller方法中可以直接注入Principal Authentication
@Controller
public class UserController {
    @Autowired
    private UserRep userRep;

    @RequestMapping(value = "/user", method = RequestMethod.GET)
    @ResponseBody
    public User getUser(Principal principal, Authentication authentication) {
        System.out.pringln("name = " + authentication.getName());
        return this.userRep.findByUsername(principal.getName());
    }
}
  • 通过自定义接口获取:
public interface IAuthenticationFacade {
    Authentication getAuthentication();
}
@Component
public class AuthenticationFacade implements IAuthenticationFacade {

    @Override
    public Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

Controller中注入:

@Controller
public class UserController {
    @Autowired
    private IAuthenticationFacade authenticationFacade;
    @Autowired
    private UserRep userRep;

    @RequestMapping(value = "/user", method = RequestMethod.GET)
    @ResponseBody
    public User getUser() {
        Authentication authentication = authenticationFacade.getAuthentication();
        return this.userRep.findByUsername(authentication.getName());
    }
}

自定义注解注入用户信息

虽然可以直接在Controller方法中注入Principal Authentication ,但是只能获取到id以及name,想要获取到完整的User对象,可以自定义参数注解,通过实现HandlerMethodArgumentResolver参数解析器,根据name查询数据库,将user对象注入到方法参数里。

首先自定义一个注解:

package top.wteng.jwtsecurity.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthedUser {
}

然后实现HandlerMethodArgumentResolver,自定义一个参数解析器,将@AuthedUser注解标注的参数解析为User对象进行注入,如下:

package top.wteng.jwtsecurity.resolver

import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import top.wteng.jwtsecurity.annotation.AuthedUser;
import top.wteng.jwtsecurity.entity.User;
import top.wteng.jwtsecurity.repository.UserRep;
import java.lang.Exception;

@Component
public class AuthedUserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private UserRep userRep;
    
    @Override
    public Boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(AuthedUser.class) != null
    }

    @Override
    public User resolveArgument(MethodParameter: parameter, ModelAndViewContainer: mavContainer, NativeWebRequest: webRequest, WebDataBinderFactory: binderFactory) {
        Principal principal = webRequest.getUserPrincipal();
        User user = null;
        try {
            user = userRep.findByUsername(principal.getName());
        } catch (e: Exception) {
        }
        if (user == null) {
            throw new HttpServerErrorException(HttpStatus.UNAUTHORIZED, "user is invalid");
        }
        return user;
    }
}

这样,就可以直接在Controller方法中直接注入user对象:

@Controller
public class UserController {

    @RequestMapping(value = "/user", method = RequestMethod.GET)
    @ResponseBody
    public User getUser(@AuthedUser user) {
        return user;
    }
}