Spring Boot 2 + Spring Security 5 + JWT 的單頁(yè)應(yīng)用Restful解決方案 舊

準(zhǔn)備

項(xiàng)目GitHub:https://github.com/Smith-Cruise/Spring-Boot-Security-JWT-SPA

我之前寫過兩篇關(guān)于安全框架的問題笛求,大家可以大致看一看国瓮,打下基礎(chǔ)。

Shiro+JWT+Spring Boot Restful簡(jiǎn)易教程

Spring Boot+Spring Security+Thymeleaf 簡(jiǎn)單教程

在開始前你至少需要了解 Spring Security 的基本配置和 JWT 機(jī)制特占。

一些關(guān)于 Maven 的配置和 Controller 的編寫這里就不說了,自己看下源碼即可云茸。

本項(xiàng)目中 JWT 密鑰是使用用戶自己的登入密碼是目,這樣每一個(gè) token 的密鑰都不同,相對(duì)比較安全标捺。

改造思路

平常我們使用 Spring Security 會(huì)用到 UsernamePasswordAuthenticationFilterUsernamePasswordAuthenticationToken 這兩個(gè)類懊纳,但這兩個(gè)類初衷是為了解決表單登入,對(duì) JWT 這類 Token 鑒權(quán)的方式并不是很友好亡容。所以我們要開發(fā)屬于自己的 FilterAuthenticationToken 來替換掉 Spring Security 自帶的類长踊。

同時(shí)默認(rèn)的 Spring Security 鑒定用戶是使用了 ProviderManager 這個(gè)類進(jìn)行判斷,同時(shí) ProviderManager 會(huì)調(diào)用 AuthenticationUserDetailsService 這個(gè)接口中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException 來從數(shù)據(jù)庫(kù)中獲取用戶信息(這個(gè)方法需要用戶自己繼承實(shí)現(xiàn))萍倡。因?yàn)榭紤]到自帶的實(shí)現(xiàn)方式并不能很好的支持JWT身弊,例如 UsernamePasswordAuthenticationToken 中有 usernamepassword 字段進(jìn)行賦值,但是 JWT 是附帶在請(qǐng)求的 header 中列敲,只有一個(gè) token 阱佛,何來 usernamepassword 這種說法。

所以我對(duì)其進(jìn)行了大換血戴而,例如獲取用戶的方法并沒有在 AuthenticationUserDetailsService 中實(shí)現(xiàn)凑术,但這樣就可能不能完美的遵守 Spring Security 的官方設(shè)計(jì),如果有更好的方法請(qǐng)指正所意。

改造

改造 Authentication

AuthenticationSecurity 官方提供的一個(gè)接口淮逊,是保存在 SecurityContextHolder 供調(diào)用鑒權(quán)使用的核心催首。

這里主要說下三個(gè)方法

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

getPrincipal() 原本用于存放用戶信息泄鹏,現(xiàn)在我們繼續(xù)保留郎任。比如存儲(chǔ)一些用戶的 usernameid 等關(guān)鍵信息供 Controller 中使用

getDetails() 原本返回一些客戶端 IP 等雜項(xiàng)备籽,但是考慮到這里基本都是 restful 這類無(wú)狀態(tài)請(qǐng)求舶治,這個(gè)就顯的無(wú)關(guān)緊要 ,所以就被閹割了:happy:

默認(rèn)提供的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 车猬,注意兩個(gè)構(gòu)造方法的不同霉猛。 AbstractAuthenticationToken 是官方實(shí)現(xiàn) Authentication 的一個(gè)類。

public class JWTAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;
    private final Object credentials;

    /**
     * 鑒定token前使用的方法珠闰,因?yàn)檫€沒有鑒定token是否合法惜浅,所以要setAuthenticated(false)
     * @param token JWT密鑰
     */
    public JWTAuthenticationToken(String token) {
        super(null);
        this.principal = null;
        this.credentials = token;
        setAuthenticated(false);
    }

    /**
     * 鑒定成功后調(diào)用的方法,返回的JWTAuthenticationToken供Controller里面調(diào)用伏嗜。
     * 因?yàn)橐呀?jīng)鑒定成功坛悉,所以要setAuthenticated(true)
     * @param token JWT密鑰
     * @param userInfo 一些用戶的信息,比如username, id等
     * @param authorities 所擁有的權(quán)限
     */
    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;

    /**
     * 進(jìn)行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刃唐,大家可以根據(jù)自己業(yè)務(wù)的需要進(jìn)行定制
         * 詳情看AuthenticationManager的JavaDoc
         */
        boolean isAuthenticatedSuccess = JWTUtil.verify(token, username, userEntity.getPassword());
        if (! isAuthenticatedSuccess) {
            throw new BadCredentialsException("用戶名或密碼錯(cuò)誤");
        }

        JWTAuthenticationToken authenticatedAuth = new JWTAuthenticationToken(
                token, userEntity, AuthorityUtils.commaSeparatedStringToAuthorityList(userEntity.getRole())
        );
        return authenticatedAuth;
    }
}

開發(fā)屬于自己的 Filter

接下來我們要使用屬于自己的過濾器羞迷,考慮到 token 是附加在 header 中,這和 BasicAuthentication 認(rèn)證很像画饥,所以我們繼承 BasicAuthenticationFilter 進(jìn)行重寫核心方法改造衔瓮。

JWTAuthenticationFilter

public class JWTAuthenticationFilter extends BasicAuthenticationFilter {

    /**
     * 使用我們自己開發(fā)的JWTAuthenticationManager
     * @param authenticationManager 我們自己開發(fā)的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);
            // 鑒定權(quán)限,如果鑒定失敗抖甘,AuthenticationManager會(huì)拋出異常被我們捕獲
            Authentication authResult = getAuthenticationManager().authenticate(JWToken);
            // 將鑒定成功后的Authentication寫入SecurityContextHolder中供后序使用
            SecurityContextHolder.getContext().setAuthentication(authResult);
        } catch (AuthenticationException failed) {
            SecurityContextHolder.clearContext();
            // 返回鑒權(quán)失敗
            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攻擊热鞍,所以關(guān)閉這功能
        http.csrf().disable()
                // 默認(rèn)允許所有的請(qǐng)求通過,后序我們通過方法注解的方式來粒度化控制權(quán)限
                .authorizeRequests().anyRequest().permitAll()
                .and()
                // 添加屬于我們自己的過濾器衔彻,注意因?yàn)槲覀儧]有開啟formLogin()薇宠,所以UsernamePasswordAuthenticationFilter根本不會(huì)被調(diào)用
                .addFilterAt(new JWTAuthenticationFilter(jwtAuthenticationManager), UsernamePasswordAuthenticationFilter.class)
                // 前后端分離本身就是無(wú)狀態(tài)的,所以我們不需要cookie和session這類東西艰额。所有的信息都保存在一個(gè)token之中澄港。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

}

關(guān)于方法注解鑒權(quán) 這塊有很多奇淫巧技,可以看看 Spring Boot+Spring Security+Thymeleaf 簡(jiǎn)單教程 這篇文章

統(tǒng)一全局異常

一個(gè) restful 最后的異常拋出肯定是要格式統(tǒng)一的柄沮,這樣才方便前端的調(diào)用回梧。

我們平常會(huì)使用 RestControllerAdvice 來統(tǒng)一異常废岂,但是他只能管理我們自己拋出的異常,而管不住框架本身的異常狱意,比如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

如果直接去實(shí)現(xiàn) ErrorController 這個(gè)接口,有很多現(xiàn)成方法都沒有髓涯,不好用袒啼,所以我們選擇 AbstractErrorController

@RestController
public class CustomErrorController extends AbstractErrorController {

    // 異常路徑網(wǎng)址
    private final String PATH = "/error";

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

    @RequestMapping("/error")
    public ResponseEntity<ResponseBean> error(HttpServletRequest request) {
        // 獲取request中的異常信息,里面有好多纬纪,比如時(shí)間蚓再、路徑啥的,大家可以自行遍歷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;
    }
}

測(cè)試

寫個(gè)控制器試試包各,大家也可以參考我控制器里面獲取用戶信息的方式摘仅,推薦使用 @AuthenticationPrincipal 這個(gè)方法!N食娃属!

@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);
    }

}

其他

這里簡(jiǎn)單解答下一些常見問題矾端。

鑒定Token是否合法是每次請(qǐng)求數(shù)據(jù)庫(kù)過于耗費(fèi)資源

我們不可能每一次鑒定都去數(shù)據(jù)庫(kù)拿一次數(shù)據(jù)來判斷 token 是否合法,這樣非常浪費(fèi)資源還影響效率卵皂。

我們可以在 JWTAuthenticationManager 使用緩存秩铆。

當(dāng)用戶第一次訪問,我們查詢數(shù)據(jù)庫(kù)判斷 token 是否合法灯变,如果合法將其放入緩存(緩存過期時(shí)間和token過期時(shí)間一致)殴玛,此后每個(gè)請(qǐng)求先去緩存中尋找,如果存在則跳過請(qǐng)求數(shù)據(jù)庫(kù)環(huán)節(jié)添祸,直接當(dāng)做該 token 合法滚粟。

如何解決JWT過期問題

JWTAuthenticationManager 中編寫方法,當(dāng) token 即將過期時(shí)拋出一個(gè)特定的異常刃泌,例如 ReAuthenticateException凡壤,然后我們?cè)?JWTAuthenticationFilter 中單獨(dú)捕獲這個(gè)異常,返回一個(gè)特定的 http 狀態(tài)碼耙替,然后前端去單獨(dú)另外訪問 GET /re_authentication 獲取一個(gè)新的token來替代掉原本的亚侠,同時(shí)從緩存中刪除老的 token

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末林艘,一起剝皮案震驚了整個(gè)濱河市盖奈,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌狐援,老刑警劉巖钢坦,帶你破解...
    沈念sama閱讀 219,589評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件究孕,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡爹凹,警方通過查閱死者的電腦和手機(jī)厨诸,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,615評(píng)論 3 396
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來禾酱,“玉大人微酬,你說我怎么就攤上這事〔眨” “怎么了颗管?”我有些...
    開封第一講書人閱讀 165,933評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)滓走。 經(jīng)常有香客問我垦江,道長(zhǎng),這世上最難降的妖魔是什么搅方? 我笑而不...
    開封第一講書人閱讀 58,976評(píng)論 1 295
  • 正文 為了忘掉前任比吭,我火速辦了婚禮,結(jié)果婚禮上姨涡,老公的妹妹穿的比我還像新娘衩藤。我一直安慰自己,他們只是感情好涛漂,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,999評(píng)論 6 393
  • 文/花漫 我一把揭開白布赏表。 她就那樣靜靜地躺著,像睡著了一般怖喻。 火紅的嫁衣襯著肌膚如雪底哗。 梳的紋絲不亂的頭發(fā)上岁诉,一...
    開封第一講書人閱讀 51,775評(píng)論 1 307
  • 那天锚沸,我揣著相機(jī)與錄音,去河邊找鬼涕癣。 笑死哗蜈,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的坠韩。 我是一名探鬼主播距潘,決...
    沈念sama閱讀 40,474評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼只搁!你這毒婦竟也來了音比?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,359評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤氢惋,失蹤者是張志新(化名)和其女友劉穎洞翩,沒想到半個(gè)月后稽犁,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,854評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡骚亿,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,007評(píng)論 3 338
  • 正文 我和宋清朗相戀三年已亥,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片来屠。...
    茶點(diǎn)故事閱讀 40,146評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡虑椎,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出俱笛,到底是詐尸還是另有隱情捆姜,我是刑警寧澤,帶...
    沈念sama閱讀 35,826評(píng)論 5 346
  • 正文 年R本政府宣布迎膜,位于F島的核電站娇未,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏星虹。R本人自食惡果不足惜零抬,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,484評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望宽涌。 院中可真熱鬧平夜,春花似錦、人聲如沸卸亮。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,029評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)兼贸。三九已至段直,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間溶诞,已是汗流浹背鸯檬。 一陣腳步聲響...
    開封第一講書人閱讀 33,153評(píng)論 1 272
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留螺垢,地道東北人喧务。 一個(gè)月前我還...
    沈念sama閱讀 48,420評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像枉圃,于是被迫代替她去往敵國(guó)和親功茴。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,107評(píng)論 2 356

推薦閱讀更多精彩內(nèi)容