动态

详情 返回 返回

JWT 登錄實戰:Angular 18 + Spring Boot 3 手把手打造無狀態認證系統

一、為什麼我們需要 JWT?從一個真實的登錄場景説起

在我們開發前後端分離的項目時,"用户登錄狀態" 這個問題幾乎是繞不開的。假設您正在開發一個 Web 應用,前端用 Angular,後端用 Spring Boot,前端發起一個登錄請求,後端驗證賬號密碼之後,該怎麼“記住”這個用户呢?

傳統方案:Session + Cookie

這是最常見的做法:

  1. 用户登錄成功,服務器創建一個 Session;
  2. JSESSIONID 放進 Cookie 返回給瀏覽器;
  3. 之後每個請求,瀏覽器自動帶上這個 Cookie,後端就知道你是誰。

看起來不錯?但是等您部署到線上就不妙了:

  • Session 機制依賴服務端存儲會話數據(無論是內存、數據庫還是 Redis),這意味着服務器是“有狀態”的。用户量增加時,Session 同步與存儲都會成為負擔。
  • 多實例部署時需要實現 Session 共享,常見的方案是使用 Redis 集中存儲。雖然可行,但會增加額外的維護成本和系統耦合度。
  • 在移動端或跨域場景中,Cookie 的自動攜帶機制往往受瀏覽器安全策略(如 SameSite 限制、CORS 配置)影響,管理起來比自定義 Header 繁瑣得多。

所以,這一套在傳統 MVC 項目裏用得好好的方案,到了前後端徹底分離、服務橫向擴展的架構下,變得不再合適。

第二方案:自定義 Token(比如 X-Auth-Token)

於是有人想到:既然不想讓後端維護 Session,那我乾脆自己生成一個“憑證”吧。

比如登錄成功後,服務端隨機生成一個 token(UUID、雪花算法、甚至是數據庫自增 ID),返回給前端。
前端保存下來,每次請求時在 Header 中帶上,比如:

X-Auth-Token: 8ae0279abc123...

這時候,服務器就不再依賴 Session,而是根據這個 token 判斷用户身份,看起來似乎也能實現“無狀態登錄”。

但問題接踵而來:

  • token 怎麼生成? UUID?那別人偽造怎麼辦?
  • 是否加密簽名? 如果沒有,任何人都能造一個假的 token。
  • 如何判斷過期? 要加時間戳?那怎麼防篡改?
  • 如何跨語言通用? 不同系統、不同語言的服務要共享登錄狀態,靠自定義字段幾乎無法協同。

這些問題歸根結底是一個字——“亂”
每個團隊都在自己“定義規則”,沒有統一格式,也沒有被廣泛認可的安全標準。


所以,JWT 應運而生

JWT 不是某個框架的發明,而是由 IETF 制定的一個開放標準(RFC 7519),目標就是讓“token 認證”有章可循
它規定了 token 的結構、簽名機制和驗證流程,使得服務端可以“無狀態地”驗證用户身份,同時又具備防偽造、防篡改、可跨語言實現等特性。

換句話説:

JWT 是“自定義 token”方案的標準化、系統化、可驗證版本。

它既延續了無狀態認證的優點,又用簽名和規範彌補了安全性與通用性的缺陷。

那麼,什麼是JWT呢?

JSON Web Token(JSON網絡令牌)
友情提示:理解這個縮寫的英文原意有助於您記憶它的含義哦!
根據官方定義:

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

機器翻譯:

JSON Web令牌是一種開放的、符合行業標準的RFC 7519方法,用於安全地表示雙方之間的聲明。

我對JWT的理解是:它是一個由社區廣泛採用的統一標準的用來在客户端和服務器之間傳遞信息的一種令牌

這個 Token 是有結構、有簽名、可解析的,不僅可以“知道你是誰”,還可以保證“你沒有偽造”。

我們可以把用户的 ID、用户名、權限、過期時間,全部打包到這個 Token 裏,簽名後發給前端。前端保存這個 Token,發請求時帶上它。服務器拿到 Token 驗籤通過,就知道你是誰,安全且無需存儲狀態。

接下來,我們就從原理講起,一步步剖析 JWT 的結構、簽名機制,並通過 Angular + Spring Boot 實現一個完整的登錄認證流程。

二、入門知識

Token傳遞的是什麼信息呢?這裏我們就要剖析一下令牌的構成。

JWT的組成結構:

官方定義三部分構成:HEADER:ALGORITHM & TOKEN TYPE,PAYLOAD:DATA,VERIFY SIGNATURE.

事實上,JWT 令牌結構通常如下所示:xxxxx.yyyyy.zzzzz,本質是:

組成 本質
Header 聲明類型和算法
Payload 攜帶用户信息
Signature 用密鑰對前兩部分進行簽名

這是我在jwt官網解碼示例令牌的結果:

jwt截圖

簽名原理

  • 簽名 = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)
  • 作用:防止令牌被篡改(任何對 Header/Payload 的修改都會導致簽名驗證失敗)
  • 密鑰 (secretKey) 必須僅服務端持有(客户端不可見)

三、Angular 18和Spring Boot的JWT簡單實現

login組件(安裝了Material框架):

使用Angular 18 推薦使用的信號量signal來創建變量:

  // 存儲用户名
  username = signal<string>('');
  // 存儲密碼
  password = signal<string>('');

注入服務(使用inject()函數注入):

// 注入 AuthService 實例,用於處理登錄邏輯
private authService = inject(AuthService);
// 注入 MatSnackBar 實例,用於顯示提示消息
private snackBar = inject(MatSnackBar);
// 注入 Router 實例,用於路由跳轉
private router = inject(Router);

發起請求:

this.authService.login(this.username(), this.password()).subscribe({
  // 登錄成功的回調函數
  next: () => {
    // 顯示登錄成功的提示消息
    this.snackBar.open('登錄成功!', '關閉', {
      duration: 3000,
      panelClass: ['success-snackbar']
    });
    this.router.navigate(['/profile']);
    // 設置正在加載中狀態為 false
    this.isLoading.set(false);
  },
  // 登錄失敗的回調函數
  error: (error) => {
    // 設置錯誤消息
    this.errorMessage.set('用户名或密碼錯誤');
    // 顯示登錄失敗的提示消息
    this.snackBar.open('登錄失敗: ' + error.message, '關閉', {
      duration: 3000,
      panelClass: ['error-snackbar']
    });
    // 設置正在加載中狀態為 false
    this.isLoading.set(false);
  }
});

AuthService:

認證服務:

login(username: string, password: string): Observable<any> {
  return this.http.post(this.apiUrl, { username, password }).pipe(
    tap((response: any) => {
      if (response.token) {
        localStorage.setItem('auth_token', response.token);
        this._isAuthenticated.set(true);
      } else {
        throw new Error('未收到令牌');
      }
    }),
    catchError(error => {
      // 處理HTTP錯誤
      let errorMessage = '登錄失敗';
      if (error.status === 401) {
        errorMessage = '用户名或密碼錯誤';
      } else if (error.status === 0) {
        errorMessage = '無法連接到服務器';
      }
      return throwError(() => new Error(errorMessage));
    })
  );
}
您也可以封裝一個通用 HttpErrorHandler 攔截器來集中處理這些錯誤。

SpringBoot 3.5示例代碼:

AuthController(控制層):

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;


    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody AuthDTO.LoginRequest request) {
        String token = authService.login(request.getUsername(), request.getPassword());
        // 將 token 封裝到一個 Map 中返回,鍵為 "token"
        return Map.of("token", token);
    }
}

AuthService(服務層):

@Service
public class AuthService {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final UserDetailsService userDetailsService;

    public AuthService(JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserRepository userRepository, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
        this.userDetailsService = userDetailsService;
    }

    public UserDetails loadUserByUsername(String username) {
        return userDetailsService.loadUserByUsername(username);
    }

    public String login(String username, String password) {
        // 通過用户名查詢用户信息
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new BadCredentialsException("用户不存在"));

        // 檢查密碼是否匹配
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("密碼錯誤");
        }
        return jwtUtil.generateToken(user);
    }

}

JwtUtil(實現jwt關鍵的工具類,主要是生成jwt令牌):

@Component
public class JwtUtil {
    // HMAC-SHA加密算法使用的密鑰
    private final SecretKey secretKey;
    // JWT令牌的有效時長(小時)
    private final long expirationHours;

    /**
     * 構造函數,通過依賴注入獲取JWT配置屬性
     *
     * @param jwtConfigProperties JWT配置屬性對象,包含密鑰和過期時間
     */
    public JwtUtil(JwtConfigProperties jwtConfigProperties) {
        // 基於配置文件中的密鑰字符串生成加密密鑰
        // 使用HMAC-SHA算法,要求密鑰長度至少為256位
        this.secretKey = Keys.hmacShaKeyFor(jwtConfigProperties.getSecret().getBytes(StandardCharsets.UTF_8));
        // 從配置中獲取JWT的有效時長(小時)
        this.expirationHours = jwtConfigProperties.getExpirationHours();
    }

    /**
     * 根據用户信息生成JWT令牌
     *
     * @param user 包含用户信息的實體對象
     * @return 生成的JWT字符串
     */
    public String generateToken(User user) {
        return Jwts.builder()
                // 設置JWT的主題(Subject)為用户名,作為用户標識
                .setSubject(user.getUsername())
                // 設置JWT的簽發時間(Issued At)為當前系統時間
                .setIssuedAt(new Date())
                // 設置JWT的過期時間(Expiration)
                // 通過當前時間加上配置的小時數(轉換為毫秒)計算
                .setExpiration(new Date(System.currentTimeMillis() + expirationHours * 3600_000))
                // 使用之前生成的密鑰對JWT進行簽名,確保令牌完整性
                .signWith(secretKey)
                // 構建並返回最終的JWT字符串
                .compact();
    }

    /**
     * 從JWT令牌中提取用户名
     *
     * @param token JWT字符串
     * @return 提取的用户名
     */
    public String extractUsername(String token) {
        // 解析JWT並獲取Claims對象,然後從中提取主題(用户名)
        return getClaims(token).getSubject();
    }

    /**
     * 驗證JWT令牌的有效性
     * 驗證內容包括:簽名是否有效、令牌是否過期、格式是否正確
     *
     * @param token JWT字符串
     * @return 如果令牌有效返回true,否則返回false
     */
    public boolean validateToken(String token) {
        try {
            // 嘗試解析令牌,如果成功則表示簽名和格式有效
            // 解析過程中會自動檢查令牌的過期時間
            getClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 捕獲JWT解析異常(如簽名無效、格式錯誤)或非法參數異常
            // 這些情況都表明令牌無效
            return false;
        }
    }

    /**
     * 解析JWT令牌並獲取其中的Claims(聲明)對象
     * Claims包含了JWT中存儲的所有用户信息和元數據
     *
     * @param token JWT字符串
     * @return 包含JWT聲明的Claims對象
     * @throws JwtException 如果令牌無效或已過期
     */
    private Claims getClaims(String token) {
        return Jwts.parserBuilder()
                // 設置用於驗證簽名的密鑰,必須與生成令牌時使用的密鑰相同
                .setSigningKey(secretKey)
                // 構建JWT解析器
                .build()
                // 解析JWT並獲取JWS(JSON Web Signature)對象
                .parseClaimsJws(token)
                // 從JWS中獲取Claims(聲明)部分
                .getBody();
    }

}
簽名原理基於 HMAC-SHA 算法對 Header+Payload 進行簽名,服務端通過密鑰校驗是否篡改。如果您感興趣其中的實現原理,我推薦您閲讀博客:JWT 是什麼?一次搞懂 JWT 的組成和運作原理

SecurityConfig(提供的核心安全功能,避免大量手動實現配置):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(@Lazy JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

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

    @Bean
    // 配置安全過濾鏈,用於定義請求的訪問規則和安全策略
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 CSRF 保護,因為在 RESTful 應用中通常不需要 CSRF 保護
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        // 允許所有用户訪問 /api/auth/login 的 POST 請求
                        .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
                        // 其他所有請求都需要進行身份驗證
                        .anyRequest().authenticated()
                )
                .sessionManagement(session -> session
                        // 設置會話創建策略為無狀態,即不使用會話管理
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling(exception -> exception
                        // 當用户未經過身份驗證時,返回 401 狀態碼
                        .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

JwtAuthenticationFilter(JWT過濾器):

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final AuthService authService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil, AuthService authService) {
        this.jwtUtil = jwtUtil;
        this.authService = authService;
    }

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws IOException {

        String token = extractToken(request);

        if (token != null && jwtUtil.validateToken(token)) {
            String username = jwtUtil.extractUsername(token);
            UserDetails userDetails = authService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());

            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        try {
            filterChain.doFilter(request, response);
        } catch (ServletException e) {
            throw new RuntimeException(e);
        }
    }

    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }
}
前後端分離場景下通常禁用 CSRF,但如果是傳統表單應用,需謹慎處理。

至此,一個基礎的 JWT 登錄功能就搭建完成啦!記得導入相應的依賴哦!不懂某處代碼的含義和作用可以問AI,此處提供代碼只是便於您參考或者快速搭建出一個登錄原型,不至於和AI鬥智鬥勇好久得不出一個完美的實現。如有謬誤,望您指正。

四、流程圖

這是一個簡單的JWT流程圖:
jwt流程圖

五、注意

  • JWT 內容僅 Base64 編碼,非加密!敏感數據(如密碼)不應存入 Payload
  • 令牌泄露 = 身份被盜用,務必使用 HTTPS 傳輸
  • 令牌過期時間(expirationHours)不宜過長

六、結語

限於學識淺薄,思考難免有不周之處,若有任何錯漏,還望您能費心指出,我定當虛心修正。

最後,感謝潘老師開啓我編程的道路,成為我學習編程的引路人。

希望這篇文章可以對您有所幫助!

user avatar
0 用户, 点赞了这篇动态!

发布 评论

Some HTML is okay.