【手摸手,帶你搭建前后端分離商城系統】03 整合Spring Security token 實現方案,完成主業務登錄


【手摸手,帶你搭建前后端分離商城系統】03 整合Spring Security token 實現方案,完成主業務登錄

上節里面,我們已經將基本的前端 VUE + Element UI 整合到了一起。並且通過 axios 發送請求到后端API。

解決跨域問題后、成功從后端獲取到數據。

本小結,將和大家一起搭建 Spring-Security + token 的方式先完成登錄。權限將在后面講解。

引入

在之前,我們的 API 都是一種裸奔的方式。誰都可以訪問,肯定是不安全的。所以我們要引入安全校驗框架。

傳統 session 方案

傳統session 的方式是,通過一個 攔截器 攔截所有的請求,若 cookie 當中存儲的 session id 在服務端過期后、則要求前端重新登錄,進而獲取一個新的session

因為HTTP 是一種無狀態的協議。所以服務端不知道這個 請求是誰發過來的,有好多人訪問服務器,但是對於服務器來說,這些人我都不認識。就需要一種東西來給每個人加一個 ID

session(會話) 是一種客戶端發起請求后, 服務端用來識別用戶的東西,可以保存一些用戶的基本信息。比如ID什么的

cookie 是一種客戶端瀏覽器用來記錄和保存信息的東西。簡單理解,如圖所示。

image-20201015110133157

當然,默認的cookie 里面總會包含一串 JSESSIONID

image-20201015110702977

session認證所顯露的問題

Session: 每個用戶經過我們的應用認證之后,我們的應用都要在服務端做一次記錄,以方便用戶下次請求的鑒別,通常而言session都是保存在內存中,而隨着認證用戶的增多,服務端的開銷會明顯增大。

擴展性: 用戶認證之后,服務端做認證記錄,如果認證的記錄被保存在內存中的話,這意味着用戶下次請求還必須要請求在這台服務器上,這樣才能拿到授權的資源,這樣在分布式的應用上,相應的限制了負載均衡器的能力。這也意味着限制了應用的擴展能力。

CSRF: 因為是基於cookie來進行用戶識別的, cookie如果被截獲,用戶就會很容易受到跨站請求偽造的攻擊。

JWT

https://jwt.io/

肯定是原有的session認證的方式存在弊端、我們就需要采取一種新的方式來進行驗證。JWT

JWT token 由三部分構成:

  • 頭部(header)
  • 載荷(playload)
  • 簽證(signature)

具體的內容可以參考: https://www.jianshu.com/p/576dbf44b2ae

頭部 header

頭部一般包含加密算法和類型。例如

{
  "alg": "HS256",// 加密算法
  "typ": "JWT" // 聲明類型
}
負荷 playload

負載可以理解為存放信息的位置,例如:

{
   "iss":"mall-pro", // 簽發者
   "sub":"admin", // 面向的用戶
   "iat": 1602737566890,//簽發時間
   "exp": 1602739566890//過期時間,必須大於簽發時間
}
簽證(signature)

簽證一般是頭部和負荷組成內容的,一旦頭部和負荷內容被篡改,驗簽的時候也將無法通過。

//secret為加密算法的密鑰
String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

我們來參考一個生成的 JWT 實例

注意,我這里使用回車、一般三部分都是通過標點進行分割的。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

實現原理

  1. 用戶調用登錄接口后、驗證用戶名和密碼。驗證成功后、頒發給其token
  2. 前台獲得 token 后,將其存放到本地、每次的請求都將這個token 攜帶到請求頭里面。
  3. 后台收到請求后、驗證請求頭里面的 Authorization 是否正確、從而判斷是否可以調用這個接口。
  4. 通過解析 token 將賬號信息存入 userDetail 讓其順利調用接口信息、並可以在接口中獲得當前登錄人的賬號信息。

Spring Security

安全框架,我們這里考慮使用 Spring-Security ,使用全家桶系列,一般大家都會想到apache shiro 等權限框架、都是可以的。我們這里介紹如何加入 Spring-Security

引入到 mall-security 並且添加一個配置文件。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

添加一個登陸接口

我們首先從登陸接口開始,一個最基本的 controller 接受參數。當然,用戶名和密碼肯定是不能為空的,校驗完后交給 service

    @ApiOperation("用戶登錄接口")
    @RequestMapping("login")
    public CommonResult login(@RequestBody @Valid @ApiParam("用戶名密碼") UmsAdminLoginParam param) {

        UmsAdminTokenBO tokenBO = umsAdminService.umsAdminLogin(param);
        return CommonResult.success(tokenBO);
    }

具體的內容無非是:查詢數據庫、是否存在、密碼是否正確。正確就構造一個 token 返回給前端。這里主要說一些重要的點。

斷言與全局異常處理

斷言可以理解為:若當前行不符合判斷條件、則拋出異常。或者直接使用斷言來拋出一個異常。比如賬號不存在,直接拋出一個異常即可。

全局異常處理:全局異常處理,在全局統一攔截異常信息,並通過{code=500,message="error message"} 的方式返回給前端做出提示即可。

Springboot 對於全局異常的處理、簡直是簡單的不得了~

@RestControllerAdvice
@Slf4j
public class GlobalExControllerHandler {

    /**
     * <p>全局異常攔截器,攔截自定義ApiException
     * <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
     *
     * @param e 自定義異常
     * @return xyz.chaobei.common.api.CommonResult
     * @since 2020/10/20
     **/
    @ExceptionHandler(value = ApiException.class)
    public CommonResult exceptionHandler(ApiException e) {
        log.info("系統異常攔截器:異常信息:" + e.getMessage());
        if (Objects.nonNull(e.getErrorCode())) {
            return CommonResult.failed(e.getErrorCode());
        }
        return CommonResult.failed(e.getMessage());
    }
}

直接通過 return 的方式,就好像我們在 controller 里面給前端返回json 一樣簡單。

斷言則是,判斷某一條件是否成立、如果不成立則拋出異常的一種更加簡單的方式。就不用每次都寫throw new xxxException

簡而言之就是:一種非常優美的方式拋異常(偷懶的)

public class Asserts {
    /**
     * <p>斷言拋出一個異常
     * <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
     *
     * @param message 提示語
     * @return void
     * @since 2020/10/15
     **/
    public static void fail(String message) {
        throw new ApiException(message);
    }

    public static void fail(IErrorCode iErrorCode) {
        throw new ApiException(iErrorCode);
    }
}

Spring Security UserDetails

Spring UserDetails 作為一個接口、規定了一些需要的參數方法。我們必須要用自己的邏輯實現這個方法。並將username password 等重要信息通過其定義的方法進行返回。也是作為一種橋接、將我們的用戶名、密碼等信息交付給 SpringSecurity


public class UmsAdminUserDetails implements UserDetails {

    private final UmsAdminModel adminModel;

    public UmsAdminUserDetails(UmsAdminModel adminModel) {
        this.adminModel = adminModel;
    }
    // 省略,具體請查看源碼
}    

JWT 簽發服務

JWT 又稱作JsonWebToken ,我們需要一個依賴來生成token/登錄后需要將這個 token 返回給前端,讓前端保存,而后所有的請求都需要帶上這個 token 然后我們服務端就知道是哪個用戶在請求了。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>

生成token

我在上面的內容里面已經介紹了。我們的token 必須要包含:

  • sub 簽發給誰
  • iat 過期時間戳
  • iss 誰簽發的
    /**
     * 功能描述: 通過負載生成token
     *
     * @Param: claims 負載
     * @Return: java.lang.String
     * @Author: MRC
     * @Date: 2020/10/21 0:17
     */
    private String buildToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret())
                .compact();
    }

通過builder() 構造器、設置其負載內容、並且指定 過期時間setExpiration ,以及加入秘鑰進行加密 signWith

token 檢驗

token 檢驗包含:當前token 是否有效(能順利從token取出我們的sub)、以及檢驗其是否過期 無效等。

    /**
     * <p>從toKen中獲取負載信息
     * <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
     *
     * @param token 獲取的token
     * @return io.jsonwebtoken.Claims
     * @since 2020/10/22
     **/
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                    .setSigningKey(jwtConfig.getSecret())
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            log.info("JWT格式驗證失敗:{}", token);
        }
        return claims;
    }

該方法描述了如何從一個token 里面取出我們所需要的 Claims 信息。並且可以從負載里面取出 sub 以及 exp 等信息。我簡要介紹一個。其他的詳細內容請查看源碼。

    /**
     * <p>首先獲取token當中的負載、而后從負載中取出sub
     * <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
     *
     * @param token 被校驗的token
     * @return java.lang.String
     * @since 2020/10/22
     **/
    public String getUserNameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

如果你的token被篡改了,那么驗證的時候肯定會報錯、所以要捕獲一下異常。返回空即可。

login service

寫到這里,我們login 控制器的service 已經可以全部寫下去了。登錄成功,通過tokenService 返回一個token ,然后封裝返回給前端即可。

    @Override
    public UmsAdminTokenBO umsAdminLogin(UmsAdminLoginParam param) {
        
        // 通過用戶名獲取userDetail
        UserDetails userDetails = this.findUserDetailByUserName(param.getUsername());
        // 基本校驗用戶名和密碼
        if (!passwordEncoder.matches(param.getPassword(), userDetails.getPassword())) {
            Asserts.fail("用戶名密碼錯誤");
        }
        // 這里暫時不開啟權限,后面再修改
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null);
        // 將構建的用戶信息加入spring security context 上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String token = defaultTokenServer.generateToken(userDetails);

        return UmsAdminTokenBO.builder().token(token).tokenHeader(jwtConfig.getTokenHeader()).build();
    }

Security Config

接下來。就是配置一個全局的Security Config

public class SecurityConfig extends WebSecurityConfigurerAdapter {}

主要還是需要重寫configure() 方法。獲取一個 registry 實例。將我們的攔截信息加入到里面。

  • 配置開放的路徑
  • 配置需要驗證的路徑。
  • 添加一個JWT默認過濾器,在SpringSecurity 處理之前,將token 進行校驗后加入到context 上下文里面。
@Override
    protected void configure(HttpSecurity http) throws Exception {

        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        // 添加開放的路徑
        for (String url : urlsConfig.getUrls()) {
            registry.antMatchers(url).permitAll();
        }
        // 允許跨域預請求
        registry.antMatchers(HttpMethod.OPTIONS).permitAll();

        // 所有的請求都需要身份認證
        registry.and()
                .authorizeRequests()
                .anyRequest().authenticated()
                // 關閉csrf 不使用session
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 自定義權限拒絕
                .and()
                .exceptionHandling()
                .accessDeniedHandler(this.customerAccessDenied())
                .authenticationEntryPoint(this.customerAuthentication())
                // 添加權限攔截器和JWT攔截器,注意,是before
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

自定義過濾器

@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtConfig jwtConfig;

    @Autowired
    private DefaultTokenServer defaultTokenServer;

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * <p> token 過濾器邏輯
     * 1、token 必須存在
     * 2、toKen 必須正確,未過期。
     * 3、若上下文不存在。則往上下文放一個userDetail
     * <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
     *
     * @param request     請求
     * @param response    響應
     * @param filterChain 過濾器
     * @return void
     * @since 2020/10/22
     **/
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader(jwtConfig.getTokenHeader());

        log.info("doFilterInternal request url={}", request.getRequestURL());
        log.info("doFilterInternal request token={}", token);

        // 請求攜帶token/則檢驗這個token是否正確和是否過期
        if (!StringUtils.isEmpty(token)) {
            // 攜帶的用戶名信息
            String username = defaultTokenServer.getUserNameFromToken(token);
            log.info("request token username={}", username);

            if (StringUtils.isEmpty(username)) {
                filterChain.doFilter(request, response);
            }
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            //校驗token是否有效
            if (defaultTokenServer.isTokenExpired(token)) {
                filterChain.doFilter(request, response);
            }
            //檢查當前上下文是否存在用戶信息,若沒有則添加
            if (SecurityContextHolder.getContext().getAuthentication() == null) {
                log.info("doFilterInternal getContext = null");
				
                // 將用戶信息添加到上下文。說明這個request 是通過的。
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                log.info("doFilterInternal user:{}", username);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        // 通過攔截器
        filterChain.doFilter(request, response);
    }
}

其實我們這里去掉session 以后,我們的客戶端對於前端的請求標識、只能通過攜帶token的方式。

然后我們每一個請求首先會進入JwtAuthenticationTokenFilter 也就是我們上面寫的這個。

檢查當前請求有沒有攜帶token 要是帶了 token 那就檢查它,檢查成功就從數據庫查出來這個人。把這個人注入到我們的SpringSecurity Context 里面。

SpringSecurity 的其他過濾器看到上下文有東西在,就放行~說明是登錄后的。

要是沒帶、或者驗證錯誤~。那上下文也就沒有這個用戶的信息了。所以這個請求只能返回403

密碼問題

這里使用的是:PasswordEncoder 接口實現類下的 BCryptPasswordEncoder ,當然,你肯定要在使用之前要用@Bean

	@Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

未來使用的時候、直接注入一個就行了。

  • matches 校驗
  • encode 加密

至於是怎么加密的。當然還得研究一下~

實際測試

在未登錄之前,我們訪問一個接口~

{
    "code": 401,
    "data": "Full authentication is required to access this resource",
    "message": "暫未登錄或token已經過期"
}

首先使用用戶名和密碼進行登錄,我們加入一條數據。admin,123456

INSERT INTO `mall-pro`.`ums_admin`(`id`, `username`, `password`, `icon`, `lock`, `email`, `nick_name`, `note`, `create_time`, `login_time`, `status`) VALUES (1, 'admin', '$2a$10$08arRlZRspTqMBK1N8NqW.9CQq7KWffa47MGelgJMuPK/uXtKX3O6', '#e', 1, 'maruichao@gmail.com', '管理員', '測試', '2020-10-22 16:14:33', '2020-10-22 16:14:36', 1);

請求登錄接口/auth/login ,驗證用戶名和密碼后、返回信息如下:

{
    "code": 200,
    "message": "操作成功",
    "data": {
        "tokenHeader": "Authorization",
        "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6Im1hbGwtcHJvIiwiZXhwIjoxNjAzNTAzNjU3LCJpYXQiOjE2MDM0MTcyNTc4MzJ9.5bX2gajbRebS9MyII3OlBKD4xc5uTgelvFprT8SHvBq_MnFa--CSn3ntkGteITt5lLRbAyxyzC8u8KZ1ZCdYjg"
    }
}

將登錄后,將指定頭和token帶入請求頭進行請求,成功請求到數據~

小結

已經好久沒更新這一篇文章了。希望我的讀者你們不要怪我,實在是太忙了。白天要上班,偶爾摸魚寫一寫,代碼調試完、而后我再整理這篇文章。現在已經是凌晨00:26 。加油吧~ 我努力更新完這個系列。

源碼地址

https://gitee.com/mrc1999/mall-pro

歡迎關注


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM