SpringBoot + SpringSecurity + jwt integration and early experience

Shiro had been using to do security framework, the configuration is pretty easy, just have access under SpringSecurity, learn this. Take the opportunity to combine under jwt, the problem of security information management client throw,

ready

First, use the SpringBoot, eliminating the need to write a variety of xml time. Then it relies on the addition

<!--安全-->
<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 with a little configuration information will be used later

jwt:
  secret: secret
  expiration: 7200000
  token: Authorization

Code may be used, the directory structure to put out what

Configuration

SecurityConfig Configuration

First, the configuration SecurityConfig, the following code

@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, below his right. First, we configure this class inherits WebSecurityConfigurerAdapter, and there are three important ways we need to rewrite:

  1. configure (HttpSecurity http): This method is configured to intercept our place, exceptionHandling () authenticationEntryPoint (), the main configuration if there is no evidence, you can do something, this will look back jwtAuthenticationEntryPoint this code inside. Carried out under a configuration, it must be added in order to distinguish .and (). authorizeRequests () behind this configuration requires those paths have any authority, I configured such that several url is permitAll (), and do not need permission to access. It is worth mentioning that antMatchers (HttpMethod.OPTIONS, "/ **"), is written in order to facilitate later separation of the front and rear ends when the first authentication request over the front end, doing so will reduce the time and resources to such requests . csrf (). disable () is to prevent csdf attack, as to what is csdf attack, please Baidu own.

    另起一行,以示尊重。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验证,后面再说。

  2. configure(WebSecurity web):这个方法我代码中没有用,这个方法主要用于访问一些静态的东西控制。其中ignoring()方法可以让访问跳过filter验证。
  3. 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这样也有好处,可以实现单点登陆了,也方便做分布式。(只要你不同子系统中验证那一套逻辑相同,或者在分布式的情况下有单独的验证系统)。

Guess you like

Origin www.cnblogs.com/pjjlt/p/10960690.html