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

準備

項目GitHub:github.com/Smith-Cruis…前端

我以前寫過兩篇關於安全框架的問題,你們能夠大體看一看,打下基礎。java

Shiro+JWT+Spring Boot Restful簡易教程git

Spring Boot+Spring Security+Thymeleaf 簡單教程github

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

一些關於 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 的官方設計,若是有更好的方法請指正。restful

改造

改造 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

相關文章
相關標籤/搜索