SpringBoot系列——SpringSecurity+jwt整合

原來一直使用shiro作安全框架,配置起來至關方便,正好有機會接觸下SpringSecurity,學習下這個。順道結合下jwt,把安全信息管理的問題扔給客戶端,前端

準備

首先用的是SpringBoot,省去寫各類xml的時間。而後把依賴加入一下web

<!--安全-->
<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加上一點配置信息,後面會用spring

jwt:
  secret: secret
  expiration: 7200000
  token: Authorization
複製代碼

可能用到代碼,目錄結構放出來一下數據庫

配置

SecurityConfig配置

首先是配置SecurityConfig,代碼以下json

@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,這裏面有三個重要的方法須要咱們重寫一下:後端

  1. 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驗證,後面再說。bash

  2. configure(WebSecurity web):這個方法我代碼中沒有用,這個方法主要用於訪問一些靜態的東西控制。其中ignoring()方法可讓訪問跳過filter驗證。session

  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這樣也有好處,能夠實現單點登錄了,也方便作分佈式。(只要你不一樣子系統中驗證那一套邏輯相同,或者在分佈式的狀況下有單獨的驗證系統)。

相關文章
相關標籤/搜索