SpringBoot+SpringSecurity+jwt整合及初体验

原来一直使用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
可能用到代码,目录结构放出来一下
配置
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。
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class SecurityUserDetails extends SysUser implements UserDetails {
    private Collection<? extends GrantedAuthority> authorities;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    public SecurityUserDetails(String userName, Collection<? extends GrantedAuthority> authorities){
        this.authorities = authorities;
        this.setUsername(userName);
        String encode = new BCryptPasswordEncoder().encode("123456");
        this.setPassword(encode);
        this.setAuthorities(authorities);
    }
    /**
     * 账户是否过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    /**
     * 是否禁用
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    /**
     * 密码是否过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    /**
     * 是否启用
     * @return
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}
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这样也有好处,可以实现单点登陆了,也方便做分布式。(只要你不同子系统中验证那一套逻辑相同,或者在分布式的情况下有单独的验证系统)。
SpringBoot+SpringSecurity+jwt整合及初体验的更多相关文章
- 从零玩转SpringSecurity+JWT整合前后端分离
		
从零玩转SpringSecurity+JWT整合前后端分离 2021年4月9日 · 预计阅读时间: 50 分钟 一.什么是Jwt? Json web token (JWT), 是为了在网络应用环境间传 ...
 - 轻松上手SpringBoot+SpringSecurity+JWT实RESTfulAPI权限控制实战
		
前言 我们知道在项目开发中,后台开发权限认证是非常重要的,springboot 中常用熟悉的权限认证框架有,shiro,还有就是springboot 全家桶的 security当然他们各有各的好处,但 ...
 - SpringBoot + SpringSecurity + Mybatis-Plus + JWT + Redis 实现分布式系统认证和授权(刷新Token和Token黑名单)
		
1. 前提 本文在基于SpringBoot整合SpringSecurity实现JWT的前提中添加刷新Token以及添加Token黑名单.在浏览之前,请查看博客: SpringBoot + Sp ...
 - (一)SpringBoot基础篇- 介绍及HelloWorld初体验
		
1.SpringBoot介绍: 根据官方SpringBoot文档描述,BUILD ANYTHING WITH SPRING BOOT (用SPRING BOOT构建任何东西,很牛X呀!),下面是官方文 ...
 - SpringBoot初体验及原理解析
		
一.前言  上篇文章,我们聊到了SpringBoot得以实现的幕后推手,这次我们来用SpringBoot开始HelloWorld之旅.SpringBoot是Spring框架对“约定大于配置(Conv ...
 - 玩转 SpringBoot 2 之整合 JWT 下篇
		
前言 在<玩转 SpringBoot 2 之整合 JWT 上篇> 中介绍了关于 JWT 相关概念和JWT 基本使用的操作方式.本文为 SpringBoot 整合 JWT 的下篇,通过解决 ...
 - 玩转 SpringBoot 2 之整合 JWT 上篇
		
前言 该文主要带你了解什么是 JWT,以及JWT 定义和先关概念的介绍,并通过简单Demo 带你了解如何使用 SpringBoot 2 整合 JWT. 介绍前在这里我们来探讨一下如何学习一门新的技术, ...
 - springboot+支付宝完成秒杀项目的初体验
		
springboot+支付宝完成秒杀项目的初体验 思考的问题: 首先是秒杀的商品查询,考虑到是热点数据,所以写一个接口读取当日批次的秒杀商品到redis中(那么接下来对商品的操作都放入redis中). ...
 - SpringSecurity之整合JWT
		
SpringSecurity之整合JWT 目录 SpringSecurity之整合JWT 1. 写在前面的话 2. JWT依赖以及工具类的编写 3. JWT过滤器 4. 登录成功结果处理器 5. Sp ...
 
随机推荐
- 【转载】Asp .Net Web Api路由路径问题
			
原文章地址:https://www.cnblogs.com/devtester/p/8897302.html MVC也好,WebAPI也好,据我所知,有部分人是因为复杂的路由,而不想去学的.曾经见过一 ...
 - Solve Error: Could not find the certificate xxxx.com. at ServerlessCustomDomain.<anonymous>
			
When runs "serverless create_domain", we may get the following error: Could not find the c ...
 - 058-PHP中goto语句的使用
			
<?php for($i=1;$i<=5;$i++){ //使用for循环循环输出1~5 if($i==3) goto ECH; //当$i为3时候跳出for循环 echo "$ ...
 - plsql和navicat连接远程oracle(易错点)
			
plsql和navicat连接远程oracle,只需要安装oracle客户端即可.注意此处是oracle客户端(Instant Client),并不是oracle数据库. oracle客户端下载地址: ...
 - Swift 枚举enum
			
enum methodType{ case get case post case put case delete } 枚举赋值 enum methodType:String{ case get=&qu ...
 - JAVA中的指针
			
不同于CPP,JAVA中不需要程序员对指针进行操作.不过,这不代表JAVA没有指针,事实上,JAVA的指针操作都被底层代码封装了.笔者在初学Java时,虽然就了解了形参,实参,StringBuffer ...
 - bmp
			
function GetNumberBuffer(cZi: string; cwidth, cheight: Integer; FontName: string; bold, italic: Bool ...
 - opencv+python+dlib人脸关键点检测、实时检测
			
安装的是anaconde3.python3.7.3,3.7环境安装dlib太麻烦, 在anaconde3中新建环境python3.6.8, 在3.6环境下安装dlib-19.6.1-cp36-cp36 ...
 - 吴裕雄--天生自然C++语言学习笔记:C++ 数字
			
下面是一个 C++ 中定义各种类型数字的综合实例: #include <iostream> using namespace std; int main () { // 数字定义 short ...
 - Python MySQL 插入表
			
章节 Python MySQL 入门 Python MySQL 创建数据库 Python MySQL 创建表 Python MySQL 插入表 Python MySQL Select Python M ...