iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 13 篇

Day13 - Spring Security 入門 vs Laravel Sanctum(API 認證)

  • 分享至 

  • xImage
  •  

加上 Security 的瞬間:全部鎖死

在 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();
    }
}

一行一行看:

  1. csrf(...disable):關掉 CSRF 保護,原因下一節細講
  2. sessionManagement(STATELESS):不建立 Session。每個請求都自己帶 Token 證明身分,伺服器不記得「誰登入過」,跟 Sanctum 的 API Token 模式一樣
  3. httpBasic、formLogin 關掉:這是前面提到的預設登入方式,我們要用自己的 Token 認證,不需要它們
  4. authorizeHttpRequests:規則由上往下比對,第一條符合的生效。/api/** 要登入,其他全部放行
  5. exceptionHandling:沒登入時怎麼回應,後面接回 Day10 的錯誤格式
  6. addFilterBefore:把我們自己的 Token 驗證 Filter 插進 Security 的 Filter 鏈裡

跟 Laravel 比,最大的差異是保護規則寫在哪裡:Laravel 寫在路由旁邊,看路由檔就知道哪支 API 要登入;Spring 集中在 SecurityConfig,Controller 本身看不出來有沒有被保護。好處是所有安全規則一目了然,壞處是新增 Controller 時要記得回來確認規則有沒有涵蓋到。這裡把規則寫成「/api/** 一律要登入」,以後新增的管理 API 只要放在 /api 底下,就會自動受到保護,不會因為忘記加而漏掉。

兌現 Day04 的伏筆(順便更正)

Day04 講版本選擇時寫過「Spring Security 7 的預設行為改變,例如 API 端點的 CSRF 保護預設變成開啟」,說細節留到今天。實際寫到這裡、回頭查證才發現這句說法不正確,在這裡更正:

  • CSRF 保護不是 Security 7 才預設開啟的,Spring Security 很早以前的版本就已經預設開啟,一直都是這樣
  • Security 7 真正的破壞性變更之一,是移除了舊的 .and() 串接寫法。網路上很多舊教學長這樣:
// 舊寫法,Security 7 已經不能用
http.csrf().disable()
    .and()
    .authorizeRequests().antMatchers("/api/**").authenticated();

現在只剩上一節那種 lambda 寫法(csrf(csrf -> ...)),authorizeRequests、antMatchers 這些舊方法也都已經移除。搜尋到的教學如果出現 .and(),基本上可以判斷是舊版本的寫法,照抄會編譯不過。這也是 Day04 說「Spring Boot 4 跟網路上多數教學會有落差」最具體的例子。

為什麼可以關掉 CSRF

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 保護開回來。

Bearer Token 認證:接上 Day11 + Day12

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);
    }
}

幾個值得注意的地方:

  1. Token 不對也不直接擋:這個 Filter 只負責「認出是誰」,認不出來就什麼都不做、放行給下一站。要不要擋,由前面 authorizeHttpRequests 的規則決定——/api/** 沒有身分就擋,GET /{code} 沒身分也沒關係。「認證(你是誰)」跟「授權(你能做什麼)」分開處理,是 Spring Security 的核心設計
  2. MessageDigest.isEqual 而不是 equals:String.equals 比對到第一個不同的字元就會馬上回傳,攻擊者理論上可以從回應時間的微小差異,一個字元一個字元猜出 Token(Timing Attack)。MessageDigest.isEqual 不管哪裡不同,都會花一樣的時間比完,PHP 對應的是 hash_equals()
  3. SecurityContextHolder:存放「目前這個請求是誰」的地方,概念上對應 Laravel 的 Auth facade。跟 Day12 的 MDC 一樣是綁在執行緒上的,但不用自己在 finally 裡清掉,Spring Security 會在請求結束時自動清除
  4. 沒有 @Component:這點特別重要。Day12 說過,Spring Boot 看到 Filter 類型的 Bean 會自動註冊到所有請求上;如果這裡也加 @Component,這個 Filter 就會同時掛在「Servlet 的 Filter 鏈」跟「Spring Security 的 Filter 鏈」兩個地方,每個請求跑兩次驗證。所以這裡不註冊成 Bean,直接在 SecurityConfig 裡 new 出來交給 addFilterBefore()

401 也要是統一格式:接回 Day10

沒帶 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

還 Day07 的債: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 概念,但不做多使用者功能」想達到的效果。


上一篇
Day12 - Middleware/Interceptor/Filter vs Laravel Middleware
下一篇
Day 14 - 短網址雜湊策略與唯一性設計(base62 / 分散式 ID)
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言