在 pom.xml 加上依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
什麼程式碼都還沒寫,重新啟動後就會發現:所有 API 都回 401,包括短網址重定向。啟動 log 裡還會多一行:
Using generated security password: 3f6c2a1e-...
這是 Spring Boot 的「預設安全」策略:只要偵測到 Spring Security 在 classpath 上,就先把所有路徑鎖起來,自動建立一個帳號 user、每次啟動隨機產生一組密碼,並開啟表單登入跟 HTTP Basic 認證。意思是「你還沒告訴我要怎麼保護,那我先全部保護起來」。
Laravel 裝 Sanctum 則是反過來:裝完什麼都不會被保護,要自己在路由上加 auth:sanctum 才會生效。一個預設全關、自己開放;一個預設全開、自己上鎖。從 Laravel 過來,第一次看到自己剛寫好的 API 全部變成 401,還以為是哪裡寫壞了。
SecurityFilterChain:集中宣告誰能進縮網址服務的需求很單純:
GET /{code}(短網址重定向):任何人都能用,不需要登入/api/**(建立、列出、刪除短網址):只有自己能用Laravel 的寫法是在路由檔裡把要保護的路由包起來:
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/links', [LinkController::class, 'store']);
Route::get('/links', [LinkController::class, 'index']);
});
Spring Security 則是寫一個設定類別,註冊一個 SecurityFilterChain Bean,用路徑規則集中宣告:
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ShortenerProperties properties) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.exceptionHandling(ex -> ex.authenticationEntryPoint(new JsonAuthenticationEntryPoint()))
.addFilterBefore(new ApiTokenFilter(properties.apiToken()),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
一行一行看:
csrf(...disable):關掉 CSRF 保護,原因下一節細講sessionManagement(STATELESS):不建立 Session。每個請求都自己帶 Token 證明身分,伺服器不記得「誰登入過」,跟 Sanctum 的 API Token 模式一樣httpBasic、formLogin 關掉:這是前面提到的預設登入方式,我們要用自己的 Token 認證,不需要它們authorizeHttpRequests:規則由上往下比對,第一條符合的生效。/api/** 要登入,其他全部放行exceptionHandling:沒登入時怎麼回應,後面接回 Day10 的錯誤格式addFilterBefore:把我們自己的 Token 驗證 Filter 插進 Security 的 Filter 鏈裡跟 Laravel 比,最大的差異是保護規則寫在哪裡:Laravel 寫在路由旁邊,看路由檔就知道哪支 API 要登入;Spring 集中在 SecurityConfig,Controller 本身看不出來有沒有被保護。好處是所有安全規則一目了然,壞處是新增 Controller 時要記得回來確認規則有沒有涵蓋到。這裡把規則寫成「/api/** 一律要登入」,以後新增的管理 API 只要放在 /api 底下,就會自動受到保護,不會因為忘記加而漏掉。
Day04 講版本選擇時寫過「Spring Security 7 的預設行為改變,例如 API 端點的 CSRF 保護預設變成開啟」,說細節留到今天。實際寫到這裡、回頭查證才發現這句說法不正確,在這裡更正:
.and() 串接寫法。網路上很多舊教學長這樣:// 舊寫法,Security 7 已經不能用
http.csrf().disable()
.and()
.authorizeRequests().antMatchers("/api/**").authenticated();
現在只剩上一節那種 lambda 寫法(csrf(csrf -> ...)),authorizeRequests、antMatchers 這些舊方法也都已經移除。搜尋到的教學如果出現 .and(),基本上可以判斷是舊版本的寫法,照抄會編譯不過。這也是 Day04 說「Spring Boot 4 跟網路上多數教學會有落差」最具體的例子。
CSRF(跨站請求偽造)攻擊的原理,是利用「瀏覽器會自動帶上 Cookie」這個行為:使用者登入了 A 網站,Session Cookie 存在瀏覽器裡,這時候打開惡意網站 B,B 偷偷對 A 發出請求,瀏覽器會自動附上 A 的 Cookie,A 就以為是使用者本人操作。
Laravel 對這件事的處理很直觀:web 路由群組(用 Session 登入、會有 Cookie)套用 CSRF 保護,所以 Blade 表單要加 @csrf;api 路由群組預設不套用,因為 API 通常用 Token 認證,不靠 Cookie。
Spring 沒有 web/api 群組的區分,CSRF 保護預設套在所有會修改資料的請求上(POST、PUT、DELETE⋯)。所以就算把認證設定好了,POST /api/links 還是會被擋下來回 403,因為請求裡沒有 CSRF Token。
我們的 API 用 Authorization: Bearer xxx header 認證,瀏覽器不會自動幫你帶上 header,惡意網站沒辦法偷用使用者的身分,CSRF 攻擊的前提就不存在了,所以可以放心關掉。反過來說,如果之後做了用 Session + Cookie 登入的網頁介面(例如外傳提過的 Thymeleaf 表單),那部分就一定要把 CSRF 保護開回來。
Sanctum 的 Token 機制是:呼叫 $user->createToken('name') 產生 Token,雜湊後存進 personal_access_tokens 資料表;請求進來時,auth:sanctum 從 header 取出 Token,雜湊後查資料表找出對應的使用者。
Spring Security 沒有內建對應 Sanctum 的東西。內建的選項是 HTTP Basic(每次請求都傳帳號密碼)或 OAuth2 Resource Server(搭配 JWT,需要另外架一個簽發 Token 的服務)。前者不適合 API,後者對「30 天內只有自己一個使用者」(見專案的架構筆記)來說太重。
所以這裡選擇最單純的做法:一組固定的 API Token,放在環境變數裡,自己寫 Filter 驗證。
先把 Token 加進 Day11 的 ShortenerProperties:
# application.yml
shortener:
base-url: ${SHORTENER_BASE_URL:http://localhost:8080}
code-length: 6
api-token: ${SHORTENER_API_TOKEN} # 刻意不給預設值,沒設定就啟動失敗
@ConfigurationProperties(prefix = "shortener")
@Validated
record ShortenerProperties(
@NotBlank String baseUrl,
@Min(4) @Max(16) int codeLength,
@NotBlank String apiToken
) {}
Token 本身用夠長的亂數產生就好,例如 openssl rand -base64 32。這裡又用到 Day11 的兩個重點:機密放環境變數、不進版控;明確寫 ${SHORTENER_API_TOKEN} 佔位符,避開 Relaxed Binding 的命名雷。
接著是驗證 Token 的 Filter,寫法跟 Day12 的 RequestIdFilter 幾乎一樣,同樣繼承 OncePerRequestFilter:
class ApiTokenFilter extends OncePerRequestFilter {
private static final Long OWNER_ID = 1L; // 30 天內只有單一使用者
private static final String PREFIX = "Bearer ";
private final byte[] expectedToken;
ApiTokenFilter(String apiToken) {
this.expectedToken = apiToken.getBytes(StandardCharsets.UTF_8);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith(PREFIX)) {
byte[] givenToken = header.substring(PREFIX.length()).getBytes(StandardCharsets.UTF_8);
if (MessageDigest.isEqual(expectedToken, givenToken)) {
var authentication = new UsernamePasswordAuthenticationToken(OWNER_ID, null, List.of());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
}
chain.doFilter(request, response);
}
}
幾個值得注意的地方:
authorizeHttpRequests 的規則決定——/api/** 沒有身分就擋,GET /{code} 沒身分也沒關係。「認證(你是誰)」跟「授權(你能做什麼)」分開處理,是 Spring Security 的核心設計MessageDigest.isEqual 而不是 equals:String.equals 比對到第一個不同的字元就會馬上回傳,攻擊者理論上可以從回應時間的微小差異,一個字元一個字元猜出 Token(Timing Attack)。MessageDigest.isEqual 不管哪裡不同,都會花一樣的時間比完,PHP 對應的是 hash_equals()
SecurityContextHolder:存放「目前這個請求是誰」的地方,概念上對應 Laravel 的 Auth facade。跟 Day12 的 MDC 一樣是綁在執行緒上的,但不用自己在 finally 裡清掉,Spring Security 會在請求結束時自動清除@Component:這點特別重要。Day12 說過,Spring Boot 看到 Filter 類型的 Bean 會自動註冊到所有請求上;如果這裡也加 @Component,這個 Filter 就會同時掛在「Servlet 的 Filter 鏈」跟「Spring Security 的 Filter 鏈」兩個地方,每個請求跑兩次驗證。所以這裡不註冊成 Bean,直接在 SecurityConfig 裡 new 出來交給 addFilterBefore()
沒帶 Token 或 Token 錯誤時,Spring Security 預設回一個空內容的 401,跟 Day10 整理好的 { message, errors } 格式不一樣。
直覺會想在 GlobalExceptionHandler 裡加一個 @ExceptionHandler 處理,但這行不通——Day12 的雷二說過:Filter 裡發生的事,@RestControllerAdvice 管不到,而 Spring Security 整個都是 Filter。正確的地方是 AuthenticationEntryPoint,專門決定「沒登入時要回什麼」:
class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"未授權\",\"errors\":[]}");
}
}
這就是 Day12 說的「在 Filter 裡要擋請求,就自己直接寫回應」,只是 Spring Security 幫我們把這件事整理成一個固定的擴充點。測試一下:
curl -i -X POST http://localhost:8080/api/links \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
# HTTP/1.1 401
# {"message":"未授權","errors":[]}
curl -i -X POST http://localhost:8080/api/links \
-H "Authorization: Bearer $SHORTENER_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
# HTTP/1.1 200
ownerId 從登入身分來Day07 的 LinkService.create() 裡有一行寫死的:
link.setOwnerId(1L); // 30 天內固定是單一使用者
現在有了登入機制,身分應該從認證結果來,而不是寫死在商業邏輯裡。Laravel 會這樣寫:
public function store(CreateLinkRequest $request)
{
$link = $this->linkService->create($request->validated(), $request->user()->id);
...
}
Spring 的對應是 @AuthenticationPrincipal,直接把 ApiTokenFilter 放進 SecurityContext 的身分注入成參數:
@PostMapping
ResponseEntity<LinkResponse> store(@Valid @RequestBody CreateLinkRequest request,
@AuthenticationPrincipal Long ownerId) {
return ResponseEntity.ok(linkService.create(request, ownerId));
}
// LinkService
LinkResponse create(CreateLinkRequest request, Long ownerId) {
Link link = new Link();
link.setOwnerId(ownerId);
...
}
1L 這個值還是存在,只是從 LinkService 搬到了 ApiTokenFilter,看起來好像只是換個地方寫死。但意義不一樣:商業邏輯不再知道「現在只有一個使用者」這件事,它只管「呼叫的人是誰就記成誰」。之後真的要開放多使用者時,只需要把 ApiTokenFilter 改成「拿 Token 去資料表查出使用者 ID」(也就是 Sanctum 的做法),LinkService 跟 Controller 一行都不用改。這正是專案架構筆記裡「現在就設計 owner 概念,但不做多使用者功能」想達到的效果。