JWT 登入驗證

📢 本文由 gemini-2.5-flash 翻譯

會話技術

會話:使用者開啟瀏覽器,造訪網頁伺服器的資源,會話建立,直到有一方中斷連線,會話結束。在一次會話中可以包含多次請求與回應。

會話追蹤:一種維護瀏覽器狀態的方法,伺服器需要辨識多次請求是否來自同一個瀏覽器,以便在同一次會話的多個請求之間共享資料。

會話追蹤方案:

  • 用戶端會話追蹤技術:Cookie
  • 伺服器端會話追蹤技術:Session
  • 權杖技術

Cookie 是 HTTP 協定支援的技術,在瀏覽器第一次造訪時,伺服器端請求標頭中設定 Cookie Set-Cookie: your_cookie,瀏覽器會自動將 Cookie 儲存在本機,並在下次造訪時自動在請求標頭中加入 Cookie Cookie: your_cookie

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class SessionController {
    // 設定 Cookie
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        response.addCookie(new Cookie("login_name", "yexca"));
        return Result.success();
    }

    // 取得 Cookie
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        for (Cookie c : cookies){
            if(c.getName().equals("login_name")){
                System.out.println("login_name:"+c.getValue());
            }
        }
        return Result.success();
    }
}

不過行動裝置不能使用 Cookie,並且 Cookie 無法跨網域。

相同網域:相同協定、IP/網域名稱、連接埠

Session

基於 Cookie 實作,在瀏覽器第一次請求時產生一個 Session,然後在回應標頭傳回 Session 的 ID Set-Cookie: JSESSIONID=session_id,然後瀏覽器在下次請求時自動帶上該 ID。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class SessionController {
    // 將值儲存到 HttpSession 中
    @GetMapping("/s1")
    public Result session1(HttpSession session){
        log.info("HttpSession_set:{}", session.hashCode());
        session.setAttribute("login_user","yexca");
        return Result.success();
    }

    // 從 HttpSession 中取得值
    @GetMapping("/s2")
    public Result session2(HttpServletRequest request){
        HttpSession session = request.getSession();
        log.info("HttpSession_get:{}", session.hashCode());

        Object loginUser = session.getAttribute("login_user");
        log.info("loginUser:{}", loginUser);
        return Result.success();
    }
}

Session 的問題在於,如果伺服器端使用負載平衡,亦即有多台伺服器端,如果第一次回應的是伺服器一,而第二次回應的是伺服器二,由於伺服器二沒有 Session,所以無法使用。

JWT 權杖

權杖技術支援 PC 端、行動裝置,可以解決叢集環境下的身分驗證問題,減輕伺服器端儲存壓力,但需要自行實作。

JWT 全稱 JSON Web Token,官方網站: https://jwt.io/ ,定義了一種簡潔、自包含的格式,用於在通訊雙方以 JSON 資料格式安全地傳輸資訊。由於數位簽章的存在,這些資訊是可靠的。

組成:

  • 第一部分:標頭(Header),記錄權杖類型、簽章演算法等
  • 第二部分:酬載(Payload,有效載荷),記錄一些自訂資訊、預設資訊等
  • 第三部分:簽章(Signature),防止權杖被篡改、確保安全性。將標頭(Header)、酬載(Payload)和指定金鑰,透過指定簽章演算法計算而來。

引入依賴項

1
2
3
4
5
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

產生與解析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class WebApplicationTests {
    // JWT 產生
    @Test
    public void jwtGenTest(){

        Map<String,Object> claims = new HashMap<>();
        claims.put("id", "233");
        claims.put("user", "yexca");
        String jwt = Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, "yexca") // 簽章演算法
                .setClaims(claims) //自訂內容(酬載)
                .setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) // 有效期為 1 小時
                .compact();
        System.out.println(jwt);
    }
    
// 輸出
// eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIzMyIsImV4cCI6MTcwMjc5NzA4OCwidXNlciI6InlleGNhIn0.BqZDxEddGN4g4GyyfvkOtKYv7DVlIF6cWY9PGW2RbUU
    
    // JWT 解析
    @Test
    public void jwtParseTest(){
        Claims claims = Jwts.parser()
                .setSigningKey("yexca") // 金鑰
                .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIzMyIsImV4cCI6MTcwMjc5NzA4OCwidXNlciI6InlleGNhIn0.BqZDxEddGN4g4GyyfvkOtKYv7DVlIF6cWY9PGW2RbUU")
                .getBody(); // 取得第二部分
        System.out.println(claims);
    }
    
// 輸出
// {id=233, exp=1702797088, user=yexca}
}

登入驗證

過濾器 (Filter)

過濾器是早期 JavaWeb 三大元件 (Servlet, Filter, Listener) 之一。過濾器可以將對資源的請求攔截下來,進而實現一些特殊的功能,例如登入驗證、統一編碼處理、敏感字元處理等。

快速入門

建立過濾器類別

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@WebFilter(urlPatterns = "/*") // 攔截路徑
public class DemoFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
//        Filter.super.init(filterConfig);
        System.out.println("過濾器初始化");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        System.out.println("過濾器攔截");

        // 放行
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {
//        Filter.super.destroy();
        System.out.println("過濾器銷毀");
    }
}

在 Application 上加上註解

1
2
@ServletComponentScan // 開啟了對 servlet 元件的支援
@SpringBootApplication

攔截路徑

可以根據需求調整,以下為範例

攔截類型urlPatterns涵義
攔截特定路徑/login只有造訪 /login 才會被攔截
目錄攔截/emps/*造訪 /emps 下所有資源都會被攔截,/emps 也會被攔截
攔截所有/*造訪所有資源都會被攔截

單個過濾器執行邏輯

瀏覽器發送請求 -> 請求被攔截 -> 執行放行前邏輯 -> 放行 -> 執行放行後邏輯 -> 瀏覽器收到回應

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@WebFilter(urlPatterns = "/*") // 攔截路徑
public class DemoFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
//        Filter.super.init(filterConfig);
        System.out.println("過濾器初始化");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        System.out.println("過濾器攔截,放行前邏輯");

        // 放行
        filterChain.doFilter(servletRequest, servletResponse);

        System.out.println("過濾器攔截,放行後邏輯");
    }

    @Override
    public void destroy() {
//        Filter.super.destroy();
        System.out.println("過濾器銷毀");
    }
}

過濾器鏈

一個網頁應用程式中,可以設定多個過濾器,這些過濾器就形成了一個過濾器鏈。

順序:註解設定的過濾器優先級是透過過濾器類別名稱的自然排序。

邏輯:瀏覽器發送請求 -> 請求被 A 攔截 -> 執行 A 放行前邏輯 -> A 放行 -> 請求被 B 攔截 -> 執行 B 放行前邏輯 -> B 放行 -> 執行 B 放行後邏輯 -> 執行 A 放行後邏輯 -> 瀏覽器收到回應

登入驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Slf4j
@WebFilter(urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        // 取得請求 URL
        String requestURI = request.getRequestURI();
        log.info("請求 URL:{}",requestURI);

        // 判斷是否有login
        if(requestURI.contains("login")){
            log.info("登入操作,放行");
            filterChain.doFilter(servletRequest, servletResponse);
            return; // 因為登入操作不需要以下邏輯
        }

        // 非登入操作,取得權杖
        String jwt = request.getHeader("token");

        // 判斷權杖是否有效
        if(!StringUtils.hasLength(jwt)){
            log.info("請求標頭 token 為空");
            // 將物件轉換為 JSON
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            response.getWriter().write(notLogin);
            return;
        }

        // 驗證權杖
        try {
            JwtUtils.parseJWT(jwt);
        }catch (Exception e){
            e.printStackTrace();
            log.info("解析權杖失敗");
            // 將物件轉換為 JSON
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            response.getWriter().write(notLogin);
            return;
        }

        // 放行
        log.info("權杖合法,放行");
        filterChain.doFilter(servletRequest,servletResponse);
    }
}

攔截器 (Interceptor)

快速入門

建立攔截器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override // 目標方法執行前執行,傳回 true 放行,false 不放行
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//        return HandlerInterceptor.super.preHandle(request, response, handler);
        System.out.println("preHandle");
        return true;
    }

    @Override // 目標方法執行後執行
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
        System.out.println("postHandle");
    }


    @Override // 檢視彩現完成後執行,最後執行
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
        System.out.println("afterCompletion");
    }
}

建立設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Configuration // 設定類別
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginCheckInterceptor loginCheckInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
//        WebMvcConfigurer.super.addInterceptors(registry);
        registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/login");

    }

}

攔截路徑

路徑涵義例子
/*一層路徑/emps, /login 不能匹配 /emps/1
/**任意層級路徑/emps, /emps/1, /emps/1/2
/emps/*/emps 下的一層路徑/emps/1 不能匹配 /emps, /emps/1/2
/emps/**/emps 下的任意層級路徑/emps, /emps/1, /emps/1/2

攔截流程

如果同時存在過濾器和攔截器

瀏覽器造訪 -> 過濾器放行前邏輯 -> 過濾器放行 -> DispatcherServlet -> Interceptor preHandle -> Controller -> postHandle -> afterCompletion -> DispatcherServlet -> 過濾器放行後邏輯 -> 回應瀏覽器

過濾器會攔截所有的請求,而攔截器只會攔截 Spring 環境中的資源。

登入驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override // 目標方法執行前執行,傳回 true 放行,false 不放行
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//        return HandlerInterceptor.super.preHandle(request, response, handler);
        System.out.println("preHandle");

        // 取得請求 URL
        String requestURI = request.getRequestURI();
        log.info("請求 URL:{}",requestURI);

        // 判斷是否有login
        if(requestURI.contains("login")){
            log.info("登入操作,放行");
            return true;
        }

        // 非登入操作,取得權杖
        String jwt = request.getHeader("token");

        // 判斷權杖是否有效
        if(!StringUtils.hasLength(jwt)){
            log.info("請求標頭 token 為空");
            // 將物件轉換為 JSON
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            response.getWriter().write(notLogin);
            return false;
        }

        // 驗證權杖
        try {
            JwtUtils.parseJWT(jwt);
        }catch (Exception e){
            e.printStackTrace();
            log.info("解析權杖失敗");
            // 將物件轉換為 JSON
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            response.getWriter().write(notLogin);
            return false;
        }

        // 放行
        log.info("權杖合法,放行");
        return true;
    }

    @Override // 目標方法執行後執行
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
        System.out.println("postHandle");
    }


    @Override // 檢視彩現完成後執行,最後執行
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
        System.out.println("afterCompletion");
    }
}