Spring Security和JWT结合
先引入pom.xml,主要看dependency部分:
<parent>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-parent</artifactId>
   <version>3.1.5</version>
   <relativePath/> <!– lookup parent from repository –>
</parent>
<groupId>com.maxshu</groupId>
<artifactId>test_SpringSecurity</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>test_SpringSecurity</name>
<description>test_SpringSecurity</description>
<properties>
   <java.version>17</java.version>
   <kotlin.version>1.8.22</kotlin.version>
</properties>
<dependencies>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
   </dependency>
   <dependency>
      <groupId>org.jetbrains.kotlin</groupId>
      <artifactId>kotlin-reflect</artifactId>
   </dependency>
   <dependency>
      <groupId>org.jetbrains.kotlin</groupId>
      <artifactId>kotlin-stdlib</artifactId>
   </dependency>
   <dependency>
      <groupId>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
   </dependency>
   <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
   </dependency>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
   </dependency>
   <!– Spring Security –>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
   </dependency>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
   </dependency>
   <!– Hutool JWT –>
   <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-jwt</artifactId>
      <version>5.8.22</version>
   </dependency>
   <!– GSon, Json –>
   <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.10.1</version>
   </dependency>
</dependencies>
下面是配置文件application.yml:
server:
  address: 0.0.0.0
  port: 80
logging:
  level:
    org.springframework.security.web.FilterChainProxy: trace
    org.springframework.security.web.access.ExceptionTranslationFilter: trace
    org.springframework.security: debug
spring:
  application:
    name: test_SpringSecurity
  profiles:
    active: dev
  main.banner-mode: ‘off’
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: db
    password: db_password
    url: jdbc:mysql://127.0.0.1:3306/test?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
  jpa:
    database: MYSQL
    show-sql: true
    generate-ddl: true
    database-platform: org.hibernate.dialect.MySQLDialect
    hibernate:
      ddl-auto: update
      naming:
          implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
          physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy  # Springboot 3.x用
#          physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy   # Springboot 2.x用
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true
要建立一个配置类:
@Configuration
@EnableWebSecurity
//@EnableMethodSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) //controller启用注解机制的安全,PreAuthorize才会有效
public class WebSecurityConfig {
    private static final Logger logger = LoggerFactory.getLogger(WebSecurityConfig.class);
    @Autowired
    private MyAccessDeniedHandler accessDeniedHandler;
    @Autowired
    private MyUnauthorizedHandler unauthorizedHandler;
    @Bean 
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
    @Bean
    public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
        return new JwtAuthenticationTokenFilter();
    }
    @Bean
    public JwtAuthenticationProvider jwtAuthenticationProvider(){
        return new JwtAuthenticationProvider();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public WebSecurityCustomizer ignoringCustomizer() {
        //不需要鉴权的访问放这里,在filter之前执行:
        return (web) -> web.ignoring()
                        //如果这些路径没有在Controller中配置,用这些路径来访问的话也会鉴权失败,而且要跟Controller中严格匹配,包括访问时尾部不要”/”。
                        //允许对于网站静态资源的无授权访问
                        .requestMatchers(HttpMethod.GET,”/”,”/*.html”,”/*.css”,”/*.js”,”/static/**”)
                        //对登录注册允许匿名访问
                        .requestMatchers(“/user/login”,”/user/register”,”/user/logout”)
                        //跨域请求会先进行一次options请求
                        .requestMatchers(HttpMethod.OPTIONS)
                        //测试时全部运行访问.permitAll();
                        .requestMatchers(“/test/**”);
    }
    @Bean
    public SecurityFilterChain filterChain2(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                //由于使用的是JWT,这里不需要csrf防护
                .csrf((csrf)->csrf.disable())
                //已经经过鉴权filter的访问,再走http过滤:
                .authorizeHttpRequests((authorize) -> authorize
                        .anyRequest().authenticated());
        //基于token,所以不需要session
        httpSecurity.sessionManagement((sm)-> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        // 禁用缓存
        httpSecurity.headers((header)->header.cacheControl((control)->control.disable()));
        //使用自定义provider
        httpSecurity.authenticationProvider(jwtAuthenticationProvider());
        //添加JWT filter
        httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        //添加自定义未授权和未登录结果返回
        httpSecurity.exceptionHandling((handle)->handle
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(unauthorizedHandler));
        return httpSecurity.build();
    }
}
下面是JwtAuthenticationProvider类:
//登录的时候用来鉴权
public class JwtAuthenticationProvider implements AuthenticationProvider {
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationProvider.class);
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private UserDetailsService userDetailsService;
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = String.valueOf(authentication.getPrincipal());
        String password = String.valueOf(authentication.getCredentials());
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        if(passwordEncoder.matches(password,userDetails.getPassword())){
            if(userDetails instanceof UserDetailsImpl) {
                ((UserDetailsImpl)userDetails).setIsLogin(true);
            }
            return new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
        }
        throw new BadCredentialsException("Password is error!");
    }
    @Override
    public boolean supports(Class<?> authentication) {
//        return UsernamePasswordAuthenticationToken.class.equals(authentication);
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}
下面是JwtAuthenticationTokenFilter类:
//不能定义为Component、Service等。
//所有访问都会来过滤。
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
    private final static String AUTH_HEADER = "Authorization";
    private final static String AUTH_HEADER_TYPE = "Bearer";
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtAuthenticationProvider jwtAuthenticationProvider;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // get token from header:  Authorization: Bearer <token>
        String authHeader = request.getHeader(AUTH_HEADER);
        if (Objects.isNull(authHeader) || !authHeader.startsWith(AUTH_HEADER_TYPE)){
            //不做鉴权处理,所以会直接返回鉴权失败
            filterChain.doFilter(request,response);
            return;
        }
        String authToken = authHeader.split(" ")[1];
        logger.debug("authToken:{}" , authToken);
        //verify token,token的提供在AuthController的登录时给的。
        if (!JWTUtil.verify(authToken, MyConstant.JWT_SIGNER)) {
            logger.info("invalid JWT token.");
            //不做鉴权处理,所以会直接返回鉴权失败
            filterChain.doFilter(request,response);
            return;
        }
        final String userName = (String) JWTUtil.parseToken(authToken).getPayload("username");
        UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
        if(request.getRequestURI().equals("/user/logout")){ //退出登录
            if(userDetails instanceof UserDetailsImpl) {
                ((UserDetailsImpl)userDetails).setIsLogin(false);
            }
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json;charset=UTF-8");
            Message message = new Message<>(true, "退出登录成功");
            Gson gson = new Gson();
            response.getWriter().println(gson.toJson(message));
            response.getWriter().flush();
            response.getWriter().close();
            return; //不再过滤了。
        }
        if(userDetails instanceof UserDetailsImpl){
            if(!((UserDetailsImpl)userDetails).getIsLogin()){ //没有登录,即使带了AUTH字段也不行
                //不做鉴权处理,所以会直接返回鉴权失败
                filterChain.doFilter(request,response);
                return;
            }
        }
        //带了token,且已经登录的处理。
        // 注意,这里使用的是3个参数的构造方法,此构造方法将认证状态设置为true
        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        //将认证过了凭证保存到security的上下文中以便于在程序中使用
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}
下面是MyAccessDeniedHandler类:
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    private static final Logger logger = LoggerFactory.getLogger(MyAccessDeniedHandler.class);
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //logger.error("access error: "+accessDeniedException.getMessage(), accessDeniedException);
        logger.error("access error: "+accessDeniedException.getMessage());
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        Message message = new Message<>(false, "禁止访问");
        Gson gson = new Gson();
        response.getWriter().println(gson.toJson(message));
        response.getWriter().flush();
        response.getWriter().close();
    }
}
下面是MyUnauthorizedHandler类:
@Component
public class MyUnauthorizedHandler implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(MyUnauthorizedHandler.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
//logger.error("Unauthorized error: "+authException.getMessage(), authException);
logger.error("Unauthorized error: "+authException.getMessage());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
Message message = new Message<>(false, "认证失败");
Gson gson = new Gson();
response.getWriter().println(gson.toJson(message));
response.getWriter().flush();
response.getWriter().close();
}
}
下面是MyConstant类:
public class MyConstant {
    private static final String JWT_SIGN_KEY = "yunbu_key_de$xxSia4R2#@dffDE";
    public static final HMacJWTSigner JWT_SIGNER = new HMacJWTSigner(
            AlgorithmUtil.getAlgorithm("HMD5"), JWT_SIGN_KEY.getBytes(StandardCharsets.UTF_8));
}
本地调试需要,增加一个CORS_Filter类来允许跨域请求:
@Component
public class CORS_Filter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest reqs = (HttpServletRequest) req;
        String curOrigin = reqs.getHeader("Origin");
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("Access-Control-Allow-Origin", curOrigin == null ? "true" : curOrigin);
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods", "POST, PUT, PATCH, GET, OPTIONS, DELETE, HEAD");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "Origin, X-Api-Key, Aaccess-Control-Allow-Origin, Authority, Authorization, Content-Type, Accept, Version-Info, X-Requested-With");
//        response.setContentType("application/json;charset=UTF-8");
        chain.doFilter(req, res);
    }
}
下面为Role和User相关的几个类:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Role {
private RoleType roleType;
}
public enum RoleType {
    ADMIN("admin"),
    USER("user");
    private String roleName;
    RoleType(String roleName) {
        this.roleName = roleName;
    }
    public String getRoleName() {
        return roleName;
    }
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
private String userName;
private String password;
private static Boolean isLogin = false; //static是临时测试为了保持登录状态,后续挪到数据库后就不能用static了。
public static void setIsLogin(Boolean isLogin){
User.isLogin = isLogin;
}
public static Boolean getIsLogin(){
return User.isLogin;
}
private List<Role> roles;
}
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user;
public void setIsLogin(Boolean isLogin){
User.setIsLogin(isLogin);
}
public Boolean getIsLogin(){
return User.getIsLogin();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles()
.stream()
.map(role -> new SimpleGrantedAuthority(role.getRoleType().getRoleName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = getUserByName(username);
        if(user == null){
            throw new UsernameNotFoundException("User isn't exist!");
        }
        return new UserDetailsImpl(user);
    }
    public User getUserByName(String userName) {
        if (!"007".equals(userName)) {
            return null; //找不到user
        }
//        List<Role> roles = List.of(new Role(RoleType.ADMIN), new Role(RoleType.USER));
//        List<Role> roles = List.of( new Role(RoleType.USER));
        List<Role> roles = List.of( new Role(RoleType.ADMIN));
        return new User(userName, passwordEncoder.encode("123456"), roles);
    }
}
几个和前端打交道的DTO类:
@Data
public class Message<T> {
    private Boolean success;
    private String msg;
    private Page page = null;
    private List<T> dataList = null;
    public Message(){
        ;
    }
    public Message(Boolean success, String msg){
        setSuccessAndMsg(success, msg);
    }
    public void setSuccessAndMsg(Boolean success, String msg){
        this.success = success;
        this.msg = msg;
    }
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Page{
        private Integer num;
        private Integer size;
        private Integer total;
        private Long totalRec;
    }
}
@Data
public class SignInReq {
private String username;
private String password;
}
@Data
@AllArgsConstructor
public class JWTAuthToken {
    String jwtToken;
}
最后两个controller类,第一个处理登陆注册:
@RestController
@RequestMapping("/user")
public class AuthController {
    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
    @Autowired
    private AuthenticationManager authenticationManager;
    @PostMapping ("/login")
    public Message<JWTAuthToken> postLogin(@RequestBody SignInReq req) {
        logger.info("postLogin, req: {}", req);
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
        authenticationManager.authenticate(authenticationToken); //在config里设置了之后,这里会调用JwtAuthenticationProvider的鉴权
        //上一步没有抛出异常说明登录成功,我们向用户颁发jwt令牌,检验在JwtAuthenticationTokenFilter里面。
        String token = JWT.create()
                .setPayload("username", req.getUsername())
                .setSigner(MyConstant.JWT_SIGNER)
                .sign();
        //登录成功,返回JWT格式的token给客户端后续再请求时从Header的
        JWTAuthToken jwtAuthToken = new JWTAuthToken(token);
        Message<JWTAuthToken> message = new Message<>(true, "");
        message.setDataList(new ArrayList<JWTAuthToken>(Arrays.asList(jwtAuthToken)));
        return message;
    }
    @GetMapping ("/login")
    public String getLogin() {
        logger.info("getLogin");
        return "getLogin";
    }
    @PostMapping ("/register")
    public Message<String> postRegister(@RequestBody SignInReq req) {
        logger.info("postRegister, req: {}", req);
        //存入数据库。
        Message<String> message = new Message<>(true, "");
        message.setDataList(new ArrayList<String>(Arrays.asList("注册成功")));
        return message;
    }
}
第二个用来处理业务:
@RestController
@RequestMapping("/aaa")
public class TestController {
    private static final Logger logger = LoggerFactory.getLogger(TestController.class);
    @RequestMapping("/aaa")
    public Message<String> aaa() {
        logger.info("aaa");
        Message<String> message = new Message<>(true, "");
        message.setDataList(new ArrayList<String>(Arrays.asList("aaa")));
        return message;
    }
    @PreAuthorize("hasAuthority('admin')") //需要以什么角色来访问
    @GetMapping("/bbb")
    public Message<String> bbb(){
        Message<String> message = new Message<>(true, "");
        message.setDataList(new ArrayList<String>(Arrays.asList("bbb")));
        return message;
    }
    @PreAuthorize("hasAuthority('user')")
    @GetMapping("/ccc")
    public Message<String> ccc(){
        Message<String> message = new Message<>(true, "");
        message.setDataList(new ArrayList<String>(Arrays.asList("ccc")));
        return message;
    }
    @PreAuthorize("hasAuthority('user') || hasAuthority('admin')")
    @GetMapping("/ddd")
    public Message<String> ddd(){
        Message<String> message = new Message<>(true, "");
        message.setDataList(new ArrayList<String>(Arrays.asList("ddd")));
        return message;
    }
}
我们使用curl来模拟注册、登陆、访问、退出登录等操作:
//注册(存数据库时才有用,现在临时测试固定了用户没意义): curl -X POST -H 'Content-Type:application/json' -d '{"username":"007","password":"123456"}'  'http://127.0.0.1/user/register'
//模拟登录: curl -X POST -H 'Content-Type:application/json' -d '{"username":"007","password":"123456"}'  'http://127.0.0.1/user/login'
//会打印出返回的 token,记住该token。
//模拟登录后的访问:curl -X GET -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJITUQ1In0.eyJ1c2VybmFtZSI6IjAwNyJ9.nj2Wp_-CCQGZ44-8uomApw' 'http://127.0.0.1/aaa/aaa'
//这里路径/aaa/aaa、/aaa/bbb、/aaa/ccc、/aaa/ddd的处理参考:TestController
//模拟退出登录: curl -X GET  -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJITUQ1In0.eyJ1c2VybmFtZSI6IjAwNyJ9.nj2Wp_-CCQGZ44-8uomApw' 'http://127.0.0.1/user/logout'