Spring Boot 2 + Spring Security 5 + JWT 的單頁應用Restful解決方案

Smith發表於2019-04-01

準備

專案GitHub:github.com/Smith-Cruis…

我之前寫過兩篇關於安全框架的問題,大家可以大致看一看,打下基礎。

Shiro+JWT+Spring Boot Restful簡易教程

Spring Boot+Spring Security+Thymeleaf 簡單教程

在開始前你至少需要了解 Spring Security 的基本配置和 JWT 機制。

一些關於 Maven 的配置和 Controller 的編寫這裡就不說了,自己看下原始碼即可。

本專案中 JWT 金鑰是使用使用者自己的登入密碼,這樣每一個 token 的金鑰都不同,相對比較安全。

改造思路

平常我們使用 Spring Security 會用到 UsernamePasswordAuthenticationFilterUsernamePasswordAuthenticationToken 這兩個類,但這兩個類初衷是為了解決表單登入,對 JWT 這類 Token 鑑權的方式並不是很友好。所以我們要開發屬於自己的 FilterAuthenticationToken 來替換掉 Spring Security 自帶的類。

同時預設的 Spring Security 鑑定使用者是使用了 ProviderManager 這個類進行判斷,同時 ProviderManager 會呼叫 AuthenticationUserDetailsService 這個介面中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException 來從資料庫中獲取使用者資訊(這個方法需要使用者自己繼承實現)。因為考慮到自帶的實現方式並不能很好的支援JWT,例如 UsernamePasswordAuthenticationToken 中有 usernamepassword 欄位進行賦值,但是 JWT 是附帶在請求的 header 中,只有一個 token ,何來 usernamepassword 這種說法。

所以我對其進行了大換血,例如獲取使用者的方法並沒有在 AuthenticationUserDetailsService 中實現,但這樣就可能不能完美的遵守 Spring Security 的官方設計,如果有更好的方法請指正。

改造

改造 Authentication

AuthenticationSecurity 官方提供的一個介面,是儲存在 SecurityContextHolder 供呼叫鑑權使用的核心。

這裡主要說下三個方法

getCredentials() 原本是用於獲取密碼,現我們打算用其存放前端傳遞過來的 token

getPrincipal() 原本用於存放使用者資訊,現在我們繼續保留。比如儲存一些使用者的 usernameid 等關鍵資訊供 Controller 中使用

getDetails() 原本返回一些客戶端 IP 等雜項,但是考慮到這裡基本都是 restful 這類無狀態請求,這個就顯的無關緊要 ,所以就被閹割了:happy:

預設提供的Authentication介面

public interface Authentication extends Principal, Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	Object getCredentials();

	Object getDetails();

	Object getPrincipal();

	boolean isAuthenticated();

	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
複製程式碼

JWTAuthenticationToken

我們編寫屬於自己的 Authentication ,注意兩個構造方法的不同AbstractAuthenticationToken 是官方實現 Authentication 的一個類。

public class JWTAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;
    private final Object credentials;

    /**
     * 鑑定token前使用的方法,因為還沒有鑑定token是否合法,所以要setAuthenticated(false)
     * @param token JWT金鑰
     */
    public JWTAuthenticationToken(String token) {
        super(null);
        this.principal = null;
        this.credentials = token;
        setAuthenticated(false);
    }

    /**
     * 鑑定成功後呼叫的方法,返回的JWTAuthenticationToken供Controller裡面呼叫。
     * 因為已經鑑定成功,所以要setAuthenticated(true)
     * @param token JWT金鑰
     * @param userInfo 一些使用者的資訊,比如username, id等
     * @param authorities 所擁有的許可權
     */
    public JWTAuthenticationToken(String token, Object userInfo, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = userInfo;
        this.credentials = token;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}
複製程式碼

改造 AuthenticationManager

用於判斷使用者 token 是否合法

JWTAuthenticationManager

@Component
public class JWTAuthenticationManager implements AuthenticationManager {

    @Autowired
    private UserService userService;

    /**
     * 進行token鑑定
     * @param authentication 待鑑定的JWTAuthenticationToken
     * @return 鑑定完成的JWTAuthenticationToken,供Controller使用
     * @throws AuthenticationException 如果鑑定失敗,丟擲
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String token = authentication.getCredentials().toString();
        String username = JWTUtil.getUsername(token);

        UserEntity userEntity = userService.getUser(username);
        if (userEntity == null) {
            throw new UsernameNotFoundException("該使用者不存在");
        }

        /*
         * 官方推薦在本方法中必須要處理三種異常,
         * DisabledException、LockedException、BadCredentialsException
         * 這裡為了方便就只處理了BadCredentialsException,大家可以根據自己業務的需要進行定製
         * 詳情看AuthenticationManager的JavaDoc
         */
        boolean isAuthenticatedSuccess = JWTUtil.verify(token, username, userEntity.getPassword());
        if (! isAuthenticatedSuccess) {
            throw new BadCredentialsException("使用者名稱或密碼錯誤");
        }

        JWTAuthenticationToken authenticatedAuth = new JWTAuthenticationToken(
                token, userEntity, AuthorityUtils.commaSeparatedStringToAuthorityList(userEntity.getRole())
        );
        return authenticatedAuth;
    }
}
複製程式碼

開發屬於自己的 Filter

接下來我們要使用屬於自己的過濾器,考慮到 token 是附加在 header 中,這和 BasicAuthentication 認證很像,所以我們繼承 BasicAuthenticationFilter 進行重寫核心方法改造。

JWTAuthenticationFilter

public class JWTAuthenticationFilter extends BasicAuthenticationFilter {

    /**
     * 使用我們自己開發的JWTAuthenticationManager
     * @param authenticationManager 我們自己開發的JWTAuthenticationManager
     */
    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        if (header == null || !header.toLowerCase().startsWith("bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        try {
            String token = header.split(" ")[1];
            JWTAuthenticationToken JWToken = new JWTAuthenticationToken(token);
            // 鑑定許可權,如果鑑定失敗,AuthenticationManager會丟擲異常被我們捕獲
            Authentication authResult = getAuthenticationManager().authenticate(JWToken);
            // 將鑑定成功後的Authentication寫入SecurityContextHolder中供後序使用
            SecurityContextHolder.getContext().setAuthentication(authResult);
        } catch (AuthenticationException failed) {
            SecurityContextHolder.clearContext();
            // 返回鑑權失敗
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, failed.getMessage());
            return;
        }
        chain.doFilter(request, response);
    }
}
複製程式碼

配置

SecurityConfig

// 開啟方法註解功能
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JWTAuthenticationManager jwtAuthenticationManager;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // restful具有先天的防範csrf攻擊,所以關閉這功能
        http.csrf().disable()
                // 預設允許所有的請求通過,後序我們通過方法註解的方式來粒度化控制許可權
                .authorizeRequests().anyRequest().permitAll()
                .and()
                // 新增屬於我們自己的過濾器,注意因為我們沒有開啟formLogin(),所以UsernamePasswordAuthenticationFilter根本不會被呼叫
                .addFilterAt(new JWTAuthenticationFilter(jwtAuthenticationManager), UsernamePasswordAuthenticationFilter.class)
                // 前後端分離本身就是無狀態的,所以我們不需要cookie和session這類東西。所有的資訊都儲存在一個token之中。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

}
複製程式碼

關於方法註解鑑權 這塊有很多奇淫巧技,可以看看 Spring Boot+Spring Security+Thymeleaf 簡單教程 這篇文章

統一全域性異常

一個 restful 最後的異常丟擲肯定是要格式統一的,這樣才方便前端的呼叫。

我們平常會使用 RestControllerAdvice 來統一異常,但是他只能管理我們自己丟擲的異常,而管不住框架本身的異常,比如404啥的,所以我們還要改造 ErrorController

ExceptionController

@RestControllerAdvice
public class ExceptionController {

    // 捕捉控制器裡面自己丟擲的所有異常
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ResponseBean> globalException(Exception ex) {
        return new ResponseEntity<>(
                new ResponseBean(
                        HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage(), null), HttpStatus.INTERNAL_SERVER_ERROR
        );
    }
}
複製程式碼

CustomErrorController

如果直接去實現 ErrorController 這個介面,有很多現成方法都沒有,不好用,所以我們選擇 AbstractErrorController

@RestController
public class CustomErrorController extends AbstractErrorController {

    // 異常路徑網址
    private final String PATH = "/error";

    public CustomErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes);
    }

    @RequestMapping("/error")
    public ResponseEntity<ResponseBean> error(HttpServletRequest request) {
        // 獲取request中的異常資訊,裡面有好多,比如時間、路徑啥的,大家可以自行遍歷map檢視
        Map<String, Object> attributes = getErrorAttributes(request, true);
        // 這裡只選擇返回message欄位
        return new ResponseEntity<>(
                new ResponseBean(
                       getStatus(request).value() , (String) attributes.get("message"), null), getStatus(request)
        );
    }

    @Override
    public String getErrorPath() {
        return PATH;
    }
}
複製程式碼

測試

寫個控制器試試,大家也可以參考我控制器裡面獲取使用者資訊的方式,推薦使用 @AuthenticationPrincipal 這個方法!!!

@RestController
public class MainController {

    @Autowired
    private UserService userService;

    // 登入,獲取token
    @PostMapping("login")
    public ResponseEntity<ResponseBean> login(@RequestParam String username, @RequestParam String password) {
        UserEntity userEntity = userService.getUser(username);
        if (userEntity==null || !userEntity.getPassword().equals(password)) {
            return new ResponseEntity<>(new ResponseBean(HttpStatus.BAD_REQUEST.value(), "login fail", null), HttpStatus.BAD_REQUEST);
        }

        // JWT簽名
        String token = JWTUtil.sign(username, password);
        return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "login success", token), HttpStatus.OK);
    }

    // 任何人都可以訪問,在方法中判斷使用者是否合法
    @GetMapping("everyone")
    public ResponseEntity<ResponseBean> everyone() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication.isAuthenticated()) {
            // 登入使用者
            return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are already login", authentication.getPrincipal()), HttpStatus.OK);
        } else {
            return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are anonymous", null), HttpStatus.OK);
        }
    }
    
    @GetMapping("user")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public ResponseEntity<ResponseBean> user(@AuthenticationPrincipal UserEntity userEntity) {
        return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are user", userEntity), HttpStatus.OK);
    }

    @GetMapping("admin")
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public ResponseEntity<ResponseBean> admin(@AuthenticationPrincipal UserEntity userEntity) {
        return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are admin", userEntity), HttpStatus.OK);
    }

}
複製程式碼

其他

這裡簡單解答下一些常見問題。

鑑定Token是否合法是每次請求資料庫過於耗費資源

我們不可能每一次鑑定都去資料庫拿一次資料來判斷 token 是否合法,這樣非常浪費資源還影響效率。

我們可以在 JWTAuthenticationManager 使用快取。

當使用者第一次訪問,我們查詢資料庫判斷 token 是否合法,如果合法將其放入快取(快取過期時間和token過期時間一致),此後每個請求先去快取中尋找,如果存在則跳過請求資料庫環節,直接當做該 token 合法。

如何解決JWT過期問題

JWTAuthenticationManager 中編寫方法,當 token 即將過期時丟擲一個特定的異常,例如 ReAuthenticateException,然後我們在 JWTAuthenticationFilter 中單獨捕獲這個異常,返回一個特定的 http 狀態碼,然後前端去單獨另外訪問 GET /re_authentication 獲取一個新的token來替代掉原本的,同時從快取中刪除老的 token

相關文章