详解SpringBoot+SpringSecurity+jwt整合及初体验

作者:啤酒就辣条 时间:2023-11-28 23:54:48 

原来一直使用shiro做安全框架,配置起来相当方便,正好有机会接触下SpringSecurity,学习下这个。顺道结合下jwt,把安全信息管理的问题扔给客户端,

准备

首先用的是SpringBoot,省去写各种xml的时间。然后把依赖加入一下


<!--安全-->
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!--jwt-->
<dependency>
 <groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt</artifactId>
 <version>0.9.1</version>
</dependency>

application.yml加上一点配置信息,后面会用


jwt:
secret: secret
expiration: 7200000
token: Authorization

可能用到代码,目录结构放出来一下

详解SpringBoot+SpringSecurity+jwt整合及初体验

配置

SecurityConfig配置

首先是配置SecurityConfig,代码如下


@Configuration
@EnableWebSecurity// 这个注解必须加,开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)//保证post之前的注解可以使用
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
 JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

@Autowired
 JwtUserDetailsService jwtUserDetailsService;

@Autowired
 JwtAuthorizationTokenFilter authenticationTokenFilter;

//先来这里认证一下
 @Autowired
 public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
   auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoderBean());
 }

//拦截在这配
 @Override
 protected void configure(HttpSecurity http) throws Exception {
   http
       .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
       .and()
       .authorizeRequests()
       .antMatchers("/login").permitAll()
       .antMatchers("/haha").permitAll()
       .antMatchers("/sysUser/test").permitAll()
       .antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
       .anyRequest().authenticated()    // 剩下所有的验证都需要验证
       .and()
       .csrf().disable()           // 禁用 Spring Security 自带的跨域处理
       .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
     // 定制我们自己的 session 策略:调整为让 Spring Security 不创建和使用 session

http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

}

@Bean
 public PasswordEncoder passwordEncoderBean() {
   return new BCryptPasswordEncoder();
 }

@Bean
 @Override
 public AuthenticationManager authenticationManagerBean() throws Exception {
   return super.authenticationManagerBean();
 }

}

ok,下面娓娓道来。首先我们这个配置类继承了WebSecurityConfigurerAdapter,这里面有三个重要的方法需要我们重写一下:

configure(HttpSecurity http):这个方法是我们配置拦截的地方,exceptionHandling().authenticationEntryPoint(),这里面主要配置如果没有凭证,可以进行一些操作,这个后面会看jwtAuthenticationEntryPoint这个里面的代码。进行下一项配置,为了区分必须加入.and()。authorizeRequests()这个后边配置那些路径有需要什么权限,比如我配置的那几个url都是permitAll(),及不需要权限就可以访问。值得一提的是antMatchers(HttpMethod.OPTIONS, "/**"),是为了方便后面写前后端分离的时候前端过来的第一次验证请求,这样做,会减少这种请求的时间和资源使用。csrf().disable()是为了防止csdf攻击的,至于什么是csdf攻击,请自行百度。

另起一行,以示尊重。sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);因为我们要使用jwt托管安全信息,所以把Session禁止掉。看下SessionCreationPolicy枚举的几个参数:


public enum SessionCreationPolicy {
ALWAYS,//总是会新建一个Session。
NEVER,//不会新建HttpSession,但是如果有Session存在,就会使用它。
IF_REQUIRED,//如果有要求的话,会新建一个Session。
STATELESS;//这个是我们用的,不会新建,也不会使用一个HttpSession。

private SessionCreationPolicy() {
}
}

http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);这行代码主要是用于JWT验证,后面再说。

configure(WebSecurity web):这个方法我代码中没有用,这个方法主要用于访问一些静态的东西控制。其中ignoring()方法可以让访问跳过filter验证。configureGlobal(AuthenticationManagerBuilder auth):这个方法是主要进行验证的地方,其中jwtUserDetailsService代码待会会看,passwordEncoder(passwordEncoderBean())是密码的一种加密方式。

还有两个注解:@EnableWebSecurity,这个注解必须加,开启Security。
@EnableGlobalMethodSecurity(prePostEnabled = true),保证post之前的注解可以使用

以上,我们可以确定了哪些路径访问不需要任何权限了,至于哪些路径需要什么权限接着往下看。

SecurityUserDetails

Security 中也有类似于shiro中主体的概念,就是在内存中存了一个东西,方便程序判断当前请求的用户有什么权限,需要实现UserDetails这个接口,所以我写了这个类,并且继承了我自己的类SysUser。


public enum SessionCreationPolicy {
ALWAYS,//总是会新建一个Session。
NEVER,//不会新建HttpSession,但是如果有Session存在,就会使用它。
IF_REQUIRED,//如果有要求的话,会新建一个Session。
STATELESS;//这个是我们用的,不会新建,也不会使用一个HttpSession。

private SessionCreationPolicy() {
}
}

authorities就是我们的权限,构造方法中我手动把密码set进去了,这不合适,包括权限我也是手动传进去的。这些东西都应该从数据库搜出来,我现在只是体验一把Security,角色权限那一套都没写,所以说明一下就好了,这个构造方法就是传进来一个标志(我这里用的是username,或者应该用userId什么的都可以),然后给你一个完整的主体信息,供其他地方使用。ok,next。

JwtUserDetailsService

SecurityConfig配置里面不是有个方法是做真正的认证嘛,或者说从数据库拿信息,具体那认证信息的方法就是在这个方法里面。


@Service
public class JwtUserDetailsService implements UserDetailsService {

@Override
 public UserDetails loadUserByUsername(String user) throws UsernameNotFoundException {
   System.out.println("JwtUserDetailsService:" + user);
   List<GrantedAuthority> authorityList = new ArrayList<>();
   authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
   return new SecurityUserDetails(user,authorityList);
 }

}

继承了Security提供的UserDetailsService接口,实现loadUserByUsername这个方法,我们这里手动模拟从数据库搜出来一个叫USER的权限,通过刚才的构造方法,模拟生成当前user的信息,供后面jwt Filter一大堆验证。至于为什么USER权限要加上“ROLE_”前缀,待会会说。

ok,现在我们知道了怎么配置各种url是否需要权限才能访问,也知道了哪里可以拿到我们的主体信息,那么继续。

JwtAuthorizationTokenFilter

千呼万唤始出来,JWT终于可以上场了。至于怎么生成这个token凭证,待会会说,现在假设前端已经拿到了token凭证,要访问某个接口了,看看怎么进行jwt业务的拦截吧。


@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {

private final UserDetailsService userDetailsService;
 private final JwtTokenUtil jwtTokenUtil;
 private final String tokenHeader;

public JwtAuthorizationTokenFilter(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService,
                   JwtTokenUtil jwtTokenUtil, @Value("${jwt.token}") String tokenHeader) {
   this.userDetailsService = userDetailsService;
   this.jwtTokenUtil = jwtTokenUtil;
   this.tokenHeader = tokenHeader;
 }

@Override
 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
   final String requestHeader = request.getHeader(this.tokenHeader);
   String username = null;
   String authToken = null;
   if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
     authToken = requestHeader.substring(7);
     try {
       username = jwtTokenUtil.getUsernameFromToken(authToken);
     } catch (ExpiredJwtException e) {
     }
   }

if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (jwtTokenUtil.validateToken(authToken, userDetails)) {
       UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
       SecurityContextHolder.getContext().setAuthentication(authentication);
     }

}
   chain.doFilter(request, response);
 }
}

提前说一下,关于@Value注解参数开头写了。

doFilterInternal() 这个方法就是这个过滤器的精髓了。首先从header中获取凭证authToken,从中挖掘出来我们的username,然后看看上下文中是否有我们以这个username为标识的主体。没有,ok,去new一个(如果对象也可以new就好了。。。)。然后就是验证这个authToken 是否在有效期呢啊,验证token是否对啊等等吧。其实我们刚刚把我们SecurityUserDetails这个对象叫做主体,到这里我才发现有点自做多情了,因为生成Security承认的主体是通过UsernamePasswordAuthenticationToken类似与这种类去实现的,之前之所以叫SecurityUserDetails为主体,只是它存了一些关键信息。然后将主体信息————authentication,存入上下文环境,供后面使用。

我的很多工具类代码都放到了jwtTokenUtil,下面贴一下代码:


@Component
public class JwtTokenUtil implements Serializable {
 private static final long serialVersionUID = -3301605591108950415L;

@Value("${jwt.secret}")
 private String secret;

@Value("${jwt.expiration}")
 private Long expiration;

@Value("${jwt.token}")
 private String tokenHeader;

private Clock clock = DefaultClock.INSTANCE;

public String generateToken(UserDetails userDetails) {
   Map<String, Object> claims = new HashMap<>();
   return doGenerateToken(claims, userDetails.getUsername());
 }

private String doGenerateToken(Map<String, Object> claims, String subject) {
   final Date createdDate = clock.now();
   final Date expirationDate = calculateExpirationDate(createdDate);

return Jwts.builder()
       .setClaims(claims)
       .setSubject(subject)
       .setIssuedAt(createdDate)
       .setExpiration(expirationDate)
       .signWith(SignatureAlgorithm.HS512, secret)
       .compact();
 }

private Date calculateExpirationDate(Date createdDate) {
   return new Date(createdDate.getTime() + expiration);
 }

public Boolean validateToken(String token, UserDetails userDetails) {
   SecurityUserDetails user = (SecurityUserDetails) userDetails;
   final String username = getUsernameFromToken(token);
   return (username.equals(user.getUsername())
       && !isTokenExpired(token)
   );
 }

public String getUsernameFromToken(String token) {
   return getClaimFromToken(token, Claims::getSubject);
 }

public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
   final Claims claims = getAllClaimsFromToken(token);
   return claimsResolver.apply(claims);
 }

private Claims getAllClaimsFromToken(String token) {
   return Jwts.parser()
       .setSigningKey(secret)
       .parseClaimsJws(token)
       .getBody();
 }

private Boolean isTokenExpired(String token) {
   final Date expiration = getExpirationDateFromToken(token);
   return expiration.before(clock.now());
 }

public Date getExpirationDateFromToken(String token) {
   return getClaimFromToken(token, Claims::getExpiration);
 }

}

根据注释你能猜个大概吧,就不再说了,有些东西是jwt方面的东西,今天就不再多说了。

JwtAuthenticationEntryPoint

前面还说了一个发现没有凭证走一个方法,代码也贴一下。


@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
 @Override
 public void commence(HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException)
     throws IOException, ServletException {

System.out.println("JwtAuthenticationEntryPoint:"+authException.getMessage());
   response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"没有凭证");
 }
}

实现AuthenticationEntryPoint这个接口,发现没有凭证,往response中放些东西。

run code

下面跑一下几个接口,看看具体是怎么具体访问某个方法的吧,还有前面一点悬念一并解决。

登录

先登录一下,看看怎么生成token扔给前端的吧。


@RestController
public class LoginController {

@Autowired
 @Qualifier("jwtUserDetailsService")
 private UserDetailsService userDetailsService;

@Autowired
 private JwtTokenUtil jwtTokenUtil;

@PostMapping("/login")
 public String login(@RequestBody SysUser sysUser, HttpServletRequest request){
   final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername());
   final String token = jwtTokenUtil.generateToken(userDetails);
   return token;
 }

@PostMapping("haha")
 public String haha(){
   UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal();
   return "haha:"+userDetails.getUsername()+","+userDetails.getPassword();
 }
}

我们前面配置中已经把login设置为随便访问了,这边通过jwt生成一个token串,具体方法请看jwtTokenUtil.generateToken,已经写了。只要知道这里面存了username、加密规则、过期时间就好了。

然后跑下haha接口,发现没问题,正常打印,说明主体也在上下文中了。

需要权限

然后我们访问一个需要权限的接口吧。


@RestController
@RequestMapping("/sysUser")
public class SysUserController {

@GetMapping(value = "/test")
 public String test() {
   return "Hello Spring Security";
 }

@PreAuthorize("hasAnyRole('USER')")
 @PostMapping(value = "/testNeed")
 public String testNeed() {
   return "testNeed";
 }
}

访问testNeed接口,看到没,@PreAuthorize("hasAnyRole('USER')")这个说明需要USER权限!我们在刚刚生成SecurityUserDetails这个的时候已经模拟加入了USER权限了,所以可以访问。现在说说为什么加权限的时候需要加入前缀“ROLE_”.看hasAnyRole源码:


public final boolean hasAnyRole(String... roles) {
 return hasAnyAuthorityName(defaultRolePrefix, roles);
}

private boolean hasAnyAuthorityName(String prefix, String... roles) {
 Set<String> roleSet = getAuthoritySet();

for (String role : roles) {
   String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
   if (roleSet.contains(defaultedRole)) {
     return true;
   }
 }

return false;
}

private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
 if (role == null) {
   return role;
 }
 if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) {
   return role;
 }
 if (role.startsWith(defaultRolePrefix)) {
   return role;
 }
 return defaultRolePrefix + role;
}

关键是 defaultRolePrefix 看这个类最上面
private String defaultRolePrefix = "ROLE_";

人家源码这么干的,咱们就这么写呗,咱也不敢问。其实也有不需要前缀的方式,去看看SecurityExpressionRoot这个类吧,用的方法不一样,也就是@PreAuthorize里面有另外一个参数。

一个重要的问题

先说结论:Security上下文环境(里面有主体)生命周期只限于一次请求。

我做了一个测试:

把SecurityConfig里面configure(HttpSecurity http)这个方法里面


http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

这行代码注释掉,不走那个jwt filter。就是不每次都添加上下上下文环境。

然后loginController改成


@RestController
public class LoginController {

@Autowired
 @Qualifier("jwtUserDetailsService")
 private UserDetailsService userDetailsService;

@Autowired
 private JwtTokenUtil jwtTokenUtil;

@PostMapping("/login")
 public String login(@RequestBody SysUser sysUser, HttpServletRequest request){
   final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername());
   final String token = jwtTokenUtil.generateToken(userDetails);
   //添加 start
   UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
   SecurityContextHolder.getContext().setAuthentication(authentication);
   //添加 end
   return token;
 }

@PostMapping("haha")
 public String haha(){
   UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal();
   return "haha:"+userDetails.getUsername()+","+userDetails.getPassword();
 }
}

然后登陆,然后访问/haha,崩了,发现userDetails里面没数据。说明这会上下文环境中我们主体不存在。

为什么会这样呢?

SecurityContextPersistenceFilter 一次请求,filter链结束之后 会清除掉Context里面的东西。所说以,主体数据生命周期是一次请求。

源码如下:


public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
   throws IOException, ServletException {
 ...假装有一堆代码...
 try {
 }
 finally {
   SecurityContext contextAfterChainExecution = SecurityContextHolder
       .getContext();
   // Crucial removal of SecurityContextHolder contents - do this before anything
   // else.
   SecurityContextHolder.clearContext();
   repo.saveContext(contextAfterChainExecution, holder.getRequest(),
       holder.getResponse());
   request.removeAttribute(FILTER_APPLIED);
 }
}

关键就是finally里面 SecurityContextHolder.clearContext(); 这句话。这才体现了那句,把维护信息的事扔给了客户端,你不请求,我也不知道你有啥。

体验小结

配置起来感觉还可以吧,使用jwt方式,生成token.由于上下文环境的生命周期是一次请求,所以在不请求的情况下,服务端不清楚用户有那些权限,真正实现了客户端维护安全信息,所以项目中也没有登出接口,因为没必要。即使前端退出了,你有token,依然可以通过postman请求接口(token没有过期)。不同于shiro可以把信息维护在服务端,要是登出,clear主体信息,访问接口就需要在登录。不过Security这样也有好处,可以实现单点登陆了,也方便做分布式。(只要你不同子系统中验证那一套逻辑相同,或者在分布式的情况下有单独的验证系统)。

来源:https://segmentfault.com/a/1190000019360422

标签:SpringBoot,SpringSecurity,jwt
0
投稿

猜你喜欢

  • C#延时函数的使用说明

    2023-10-11 19:58:34
  • 详解AndroidStudio JNI +Gradle3.0以上JNI爬坑之旅

    2021-09-01 05:45:04
  • C#委托用法详解

    2023-06-04 22:46:43
  • Android 实现微信登录详解

    2022-07-14 06:34:11
  • 简单的观察者模式示例分享

    2023-02-11 12:52:12
  • C#用委托BeginInvoke做异步线程

    2023-04-22 07:23:05
  • Springboot整合Dozer实现深度复制的方法

    2023-11-12 17:18:11
  • C# 获取某个时间的0点0分和23点59分59秒

    2023-03-02 10:05:18
  • 简单讲解Java设计模式编程中的单一职责原则

    2022-05-02 03:09:24
  • Android仿QQ首页ListView左滑置顶、删除功能

    2022-02-16 05:15:48
  • Java生成PDF文件的实例代码

    2022-05-24 07:22:37
  • Android DataBinding类关系深入探究

    2022-08-17 19:23:51
  • IDEA设置背景为自定义照片的操作方法

    2022-12-28 09:13:08
  • android自定义波浪加载动画的实现代码

    2021-11-03 12:25:59
  • Fluent Mybatis实际开发中的优势对比

    2022-07-09 14:29:34
  • Android短信接收监听、自动回复短信操作例子

    2022-12-04 20:07:50
  • 图文并茂讲解RocketMQ消息类别

    2023-06-11 07:59:41
  • 如何基于JavaFX开发桌面程序

    2023-10-30 11:06:19
  • Android引用开源框架通过AsyncHttpClient实现文件上传

    2021-09-22 20:02:05
  • Java黑盒测试之nextDate函数测试

    2023-02-10 20:31:08
  • asp之家 软件编程 m.aspxhome.com