今天是一個非常重要的日子,就是讓JWT正是組裝到我們的Spring Security框架中。雖然套件都已經做好,要使用也非常簡單,但整個原理我們已經融會貫通,相信到這裡,我們能夠真正的去理解,並且實作我們需要的內容。
把「登入 → 拿到 Token → 用 Token 存取受保護 API」這條路打通。重點是 把 JWT 驗證接到 Spring Security 的 Filter 鏈上,做到 真正的無狀態(Stateless) 後端 API。
/login
帳密 → 後端回一個 Access Token(JWT)
Authorization: Bearer <token>
/hello
這種需要登入才看的 APIPOST /login
(帶帳密) → 成功則回傳 accessToken
客戶端在之後的請求 Header 帶上:
Authorization: Bearer <accessToken>
每次請求先經過 JwtAuthenticationFilter
→ 驗證 Token
→ 驗證通過就把「使用者身分與角色」放進 SecurityContextHolder
Controller 內就能透過 SecurityContext
取得登入使用者
重點
/login
:用最簡單的假資料帳密(admin/password
)示範,成功就回 Access Token。/hello
:展示如何從 SecurityContext
拿到當前使用者(代表 Token 驗證成功才會拿得到)。/login-with-refresh
與 /refresh
(今天可以先忽略)。package com.ansathsean.security;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import java.util.Map;
@RestController
public class AuthController {
// Day12: 保留舊的登入方式(只回 Access Token)
@PostMapping("/login")
public String login(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
if ("admin".equals(username) && "password".equals(password)) {
return JwtUtil.generateToken(username, "ROLE_ADMIN");
}
return "帳號或密碼錯誤";
}
@GetMapping("/hello")
public String hello() {
return "Hello, " +
(org.springframework.security.core.context.SecurityContextHolder.getContext()
.getAuthentication().getName());
}
// Day13: 新增支援 Refresh Token 的登入方式
@PostMapping("/login-with-refresh")
public ResponseEntity<?> loginWithRefresh(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
if ("admin".equals(username) && "password".equals(password)) {
String accessToken = JwtUtil.generateAccessToken(username, "ROLE_ADMIN");
String refreshToken = JwtUtil.generateRefreshToken(username);
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "帳號或密碼錯誤"));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
try {
// 驗證 Refresh Token
Jws<Claims> claims = JwtUtil.validateToken(refreshToken);
String username = claims.getBody().getSubject();
// 這裡可以加上角色資訊,或直接重新查資料庫
String newAccessToken = JwtUtil.generateAccessToken(username, "ROLE_ADMIN");
//回傳新的 Access Token(可選擇是否更新 Refresh Token)
return ResponseEntity.ok(Map.of(
"accessToken", newAccessToken,
"refreshToken", refreshToken // 可以重複使用原本的 refresh,或換新
));
} catch (ExpiredJwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Refresh Token 過期,請重新登入"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Refresh Token 無效"));
}
}
}
你會看到:
/login
直接回傳字串(就是 JWT),簡單好測。/hello
會回傳 Hello, <username>
,如果沒帶 Token 或 Token 無效,進不到這裡。重點
Authorization
,必須以 Bearer
開頭。UsernamePasswordAuthenticationToken
,並把「角色」也放進去。SecurityContextHolder
,後面 Controller 就能知道「你是誰」。package com.ansathsean.security;
import java.io.IOException;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
// 驗證 Token
Jws<Claims> claimsJws = JwtUtil.validateToken(token);
String username = claimsJws.getBody().getSubject();
String role = claimsJws.getBody().get("role", String.class);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, List.of(() -> role));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 成功驗證 → 注入 Security Context
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
// Token 過期
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Token Expired");
return;
} catch (Exception e) {
// 其他驗證錯誤
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid Token");
return;
}
}
// 繼續後續 Filter / Controller
filterChain.doFilter(request, response);
}
}
重點
io.jsonwebtoken
(jjwt)生成與解析 Token。SECRET
至少 32 bytes(示範用,正式上線要改成環境變數)。generateToken
(Day12 用,20 秒有效,方便測試過期)generateAccessToken
(1 分鐘有效,Day13 用)generateRefreshToken
(7 天有效,Day13 用)package com.ansathsean.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
private static final String SECRET = "mySecretKeymySecretKeymySecretKey"; // 至少 32 bytes
private static final Key key = Keys.hmacShaKeyFor(SECRET.getBytes());
public static String generateToken(String username, String role) {
return Jwts.builder()
.setSubject(username)
.addClaims(Map.of("role", role))
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 20000)) // 20 秒過期
//.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 1 小時過期
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public static Jws<Claims> validateToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
}
public static String generateAccessToken(String username, String role) {
return Jwts.builder()
.setSubject(username)
.addClaims(Map.of("role", role))
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 60 * 1000)) // 1 分鐘有效
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public static String generateRefreshToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) // 7 天有效
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
}
重點
SessionCreationPolicy.STATELESS
:不建立/不使用 SessionJwtAuthenticationFilter
加到 UsernamePasswordAuthenticationFilter
之前package com.ansathsean.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter();
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.requestMatchers("/login-with-refresh").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"password"}'
成功會回你一串字串(就是 JWT)。Day12 版本有效期只有 20 秒,方便你馬上測試過期情境。
curl http://localhost:8080/hello \
-H "Authorization: Bearer <把剛剛拿到的 token 貼上來>"
預期回應:
Hello, admin
/hello
→ 預期回 401
與 Token Expired
。Postman的部分先前已經教過了,這次想要讓大家使用不同的方法,就是用curl的方式,基本上跟Postman很像,但是是用cmd,在windows跟mac上都可以使用,歡迎大家可以手動來測試看看,直接複製上面的指令貼到cmd/終端機上面試試!
Authorization: Bearer <token>
,注意 Bearer 後面有空白。SECRET
(簽發與驗證必須用同一把金鑰)。JwtAuthenticationFilter
有被註冊進 Security Filter Chain(addFilterBefore
)。SecurityContextHolder.getContext().getAuthentication()
不是 null
。List.of(() -> role)
。若要支援 hasRole('ADMIN')
等等,建議把角色前綴一致(如 ROLE_ADMIN
),並加上 @EnableMethodSecurity
與 @PreAuthorize
(之後章節可擴充)。SecurityContext
。/login
,後續請求帶 Bearer Token
,能進 /hello
就代表流程成功。今天的程式看起來跟之前差不多,但是整個技術的實力完全提升了一級,到這裡,我相信大家就可以更加了解整個Security的內容,到這裡,也算是把當代的所有防護都放了上了,非常恭喜大家到了這個地步。但這個其實只是開始,接下來就是要深入更多的知識,讓我們的安全網更上一層。
大家今天辛苦了,我們明天見!