安全性掃描丟回來一張紅單

那次例行安全性掃描跑完,報告裡躺著一條 CSRF(Cross-Site Request Forgery)漏洞。專案是前後端分離:後端 .NET Core Web API,前端 Vue.js 跑在另一個來源(origin),用 Cookie 帶身分驗證。這種架構最容易踩到 CSRF,因為瀏覽器只要看到對應網域,就會自動把 Cookie 附上去,不管請求是誰發起的。

補這個洞花的時間比我預期久,主要卡在兩個地方:一是 ASP.NET Core 的 Antiforgery API 有不少網路文章寫錯,照抄根本編不過;二是 SameSite 設定跟前後端分離的架構互相打架,調了好幾次才搞懂。這篇把走過的彎路記下來,程式碼都實際跑過。

CSRF 到底在攻擊什麼

CSRF 利用的是「瀏覽器會自動帶 Cookie」這件事。攻擊不需要偷到你的帳密,它借用的是你「已經登入」的狀態。

典型流程是這樣:

  1. 使用者登入了網路銀行,瀏覽器存了驗證 Cookie
  2. 同個瀏覽器分頁打開了一個惡意網站
  3. 惡意網站偷偷送一個 POST 到網路銀行的轉帳端點
  4. 瀏覽器看到目標是網路銀行,就把驗證 Cookie 一起帶上,後端以為是本人操作

惡意網站的程式碼可以簡單到這種程度:

1
2
3
4
5
6
7
8
<!-- 惡意網站的 HTML -->
<form action="https://bank.example.com/transfer" method="POST" id="hack-form">
<input type="hidden" name="amount" value="1000000">
<input type="hidden" name="to" value="hacker-account">
</form>
<script>
document.getElementById('hack-form').submit(); // 自動提交
</script>

防禦的核心觀念:後端不能只看「Cookie 對不對」,還要驗證一個攻擊者拿不到的祕密。這個祕密就是 Antiforgery Token——它存在一個 JavaScript 讀得到的 Cookie 裡,前端每次發請求時把它放進自訂 HTTP 標頭送回去。攻擊者的網站受同源政策限制,讀不到那個 Cookie,也就沒辦法偽造這個標頭。這套就是常見的 Double Submit Cookie 模式。

一個我繞了很久的坑:AntiforgeryOptions 到底有哪些屬性

先講最會害人的地方。網路上很多範例會這樣寫:

1
2
3
4
5
builder.Services.AddAntiforgery(options => {
options.HeaderName = "X-XSRF-TOKEN";
options.ExpireTimeSpan = TimeSpan.FromHours(1); // ← 這個屬性不存在
options.RandomKey = RandomNumberGenerator.GetBytes(32); // ← 這個也不存在
});

ExpireTimeSpanRandomKey 都不是 AntiforgeryOptions 的成員,照抄會直接編譯失敗。查官方 API 文件確認過,AntiforgeryOptions 只有四個可設定屬性:

屬性 用途 預設值
Cookie 設定存放 token 的 Cookie(名稱、SameSite、Secure 等) HttpOnly=trueSameSite=Strict
FormFieldName 表單欄位名稱 __RequestVerificationToken
HeaderName 從哪個標頭讀 token null(只看表單)
SuppressXFrameOptionsHeader 是否抑制 X-Frame-Options 標頭 false

token 的有效期不是靠一個獨立的 ExpireTimeSpan 控制,而是綁在 Cookie 的存活期跟 Data Protection 金鑰的輪替週期上。金鑰也是由 ASP.NET Core 的 Data Protection 系統自動產生與管理,開發者不需要、也不能手動塞一把 RandomKey 進去。想控制 token 壽命,要去設定 Data Protection 的金鑰生命週期,或在寫入 JS-readable Cookie 時設 Expires,而不是去找 AntiforgeryOptions 上不存在的屬性。

正確的設定長這樣:

1
2
3
4
5
6
7
8
9
10
builder.Services.AddAntiforgery(options => {
// 這是「框架自己用」的 cookie(存 CookieToken),保持 HttpOnly
options.Cookie.Name = ".AspNetCore.Antiforgery.App";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.HttpOnly = true;
// SameSite 怎麼設取決於部署方式,下一節詳談
options.Cookie.SameSite = SameSiteMode.Strict;
// 前端會把 token 放進這個標頭送回來
options.HeaderName = "X-XSRF-TOKEN";
});

SameSite=Strict 跟前後端分離會打架

第二個坑更隱晦,因為它在開發機上「看起來正常」,上正式環境才爆。

很多文章(包括我一開始參考的)會說「SameSite 一律設 Strict,最安全」。這句話對單一網域的傳統網站成立,但對前後端分離的架構是錯的,會把整套防護弄到失效。

要先分清楚 site(站)origin(來源) 的差別,因為 SameSite 跟 CORS 各看一個:

  • SameSite 看的是 site:scheme + eTLD+1,連接埠不算。所以 http://localhost:8080http://localhost:5000 對 SameSite 來說是同一個 site,Strict 反而還會送 Cookie——這就是為什麼本機開發時用 Strict 常常「沒事」。
  • CORS 看的是 origin:scheme + 網域 + 連接埠。換了埠就是不同 origin,所以你還是得設 CORS。

問題出在正式環境。實務上前端通常掛在 app.example.com、API 在 api.example.com,或乾脆是兩個不同網域。這時前端對 API 發的請求屬於跨站,瀏覽器在 SameSite=Strict 下會完全不帶這個 Cookie,前端 axios 攔截器自然讀不到 token,每個 POST 都被擋下來。本機跑得好好的,一上線全爛。

所以正確的判斷是看部署拓撲:

  • 同站部署(前端與 API 在同一 eTLD+1,例如用反向代理把 /api 收到同網域底下):用 SameSite=LaxStrict 都可以,這是最省事也最安全的做法,能擋掉絕大多數 CSRF。
  • 真正的跨站部署(前端與 API 在不同 eTLD+1):JS-readable 的 token Cookie 必須設 SameSite=NoneSecure=true,瀏覽器才會在跨站請求帶上它;同時後端 CORS 要明確列白名單並開 AllowCredentials

我最後選了同站部署,用反向代理把 API 收進同一個網域,這樣 token Cookie 維持 SameSite=Lax 就夠,不必把保護降到 None。下面的 CORS 範例保留跨站情境的寫法,方便沒辦法同站部署的人參考。

另外提醒一個小陷阱:Secure=true(或 CookieSecurePolicy.Always)的 Cookie 在純 HTTP 下不會被瀏覽器寫入。本機若跑 http://localhost 又把 token Cookie 設成 Secure,會發現怎麼樣都讀不到。本機開發請用 HTTPS,或在開發環境把 SecurePolicy 放寬。

.NET Core 後端設定

1. Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var builder = WebApplication.CreateBuilder(args);

// Antiforgery:框架用的 cookie 保持 HttpOnly,token 透過自訂標頭驗證
builder.Services.AddAntiforgery(options => {
options.Cookie.Name = ".AspNetCore.Antiforgery.App";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax; // 同站部署用 Lax;跨站改 None
options.HeaderName = "X-XSRF-TOKEN";
});

// CORS:前後端分離一定要設,且 AllowCredentials 不能搭 AllowAnyOrigin
builder.Services.AddCors(options =>
{
options.AddPolicy("VueCorsPolicy", policy =>
{
policy.WithOrigins("http://localhost:8080")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod();
});
});

2. CSRF Token 中介軟體

關鍵觀念:GetAndStoreTokens 已經會把框架自己的 CookieToken 寫進 options.Cookie.Name 那個 Cookie。我們不要再去 Append 同名 Cookie——重複寫同名 Cookie 行為未定義,而且多餘。要做的是把 RequestToken 另外寫進一個 JS 讀得到的 Cookie(慣例叫 XSRF-TOKEN),讓前端撈得到:

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
// Middleware/Csrf/CsrfTokenMiddleware.cs
public class CsrfTokenMiddleware
{
private readonly RequestDelegate _next;
private readonly IAntiforgery _antiforgery;
private readonly ILogger<CsrfTokenMiddleware> _logger;

public CsrfTokenMiddleware(
RequestDelegate next,
IAntiforgery antiforgery,
ILogger<CsrfTokenMiddleware> logger)
{
_next = next;
_antiforgery = antiforgery;
_logger = logger;
}

public async Task InvokeAsync(HttpContext context)
{
try
{
// GET 請求時刷新 token
if (HttpMethods.IsGet(context.Request.Method))
{
// 這一行就寫好了框架的 CookieToken(HttpOnly cookie)
var tokens = _antiforgery.GetAndStoreTokens(context);

// 另外寫一個給 JS 讀的 cookie,存 RequestToken
if (tokens.RequestToken is not null)
{
context.Response.Cookies.Append(
"XSRF-TOKEN",
tokens.RequestToken,
new CookieOptions
{
HttpOnly = false, // JS 要讀,不能 HttpOnly
Secure = true,
SameSite = SameSiteMode.Lax, // 與部署方式一致
Expires = DateTimeOffset.UtcNow.AddHours(1)
});
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "CSRF token generation failed");
throw; // 保留原始例外與堆疊,別吞掉重包成沒資訊的訊息
}

await _next(context);
}
}

注意這裡用 HttpMethods.IsGet(...) 取代手寫字串比對,避免大小寫問題;例外也直接 throw 而不是重包成 new Exception(...),後者會把堆疊資訊丟掉,正式環境的詳細錯誤本來就該交給全域例外處理器去過濾,不該在這裡硬塞。

3. 自訂驗證 Attribute

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
// Attributes/Csrf/ValidateCsrfTokenAttribute.cs
public class ValidateCsrfTokenAttribute : TypeFilterAttribute
{
public ValidateCsrfTokenAttribute() : base(typeof(CsrfTokenFilter))
{
}

private class CsrfTokenFilter : IAsyncActionFilter
{
private readonly IAntiforgery _antiforgery;
private readonly ILogger<CsrfTokenFilter> _logger;

public CsrfTokenFilter(
IAntiforgery antiforgery,
ILogger<CsrfTokenFilter> logger)
{
_antiforgery = antiforgery;
_logger = logger;
}

public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
try
{
await _antiforgery.ValidateRequestAsync(context.HttpContext);
await next();
}
catch (AntiforgeryValidationException ex)
{
_logger.LogWarning(ex, "CSRF validation failed");
context.Result = new BadRequestObjectResult(
new { error = "Invalid security token" });
}
}
}
}

[ValidateCsrfToken] 掛在會改變狀態的端點上(POST/PUT/DELETE),GET 不需要。

4. API Controller

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
// Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly ILogger<UsersController> _logger;

public UsersController(
IUserService userService,
ILogger<UsersController> logger)
{
_userService = userService;
_logger = logger;
}

[HttpPost]
[ValidateCsrfToken]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest request)
{
try
{
var result = await _userService.CreateUserAsync(request);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Create user failed");
return BadRequest(new { error = "Failed to create user" });
}
}

// 檔案上傳一樣要驗 CSRF
[HttpPost("upload")]
[ValidateCsrfToken]
[RequestSizeLimit(100_000_000)] // 100MB
public async Task<IActionResult> UploadFile(IFormFile file)
{
// 實作檔案上傳邏輯
return Ok();
}
}

Vue.js 前端設定

1. Axios 攔截器

前端做兩件事:請求送出前,從 XSRF-TOKEN Cookie 撈出 token 塞進 X-XSRF-TOKEN 標頭;收到 token 失效的回應時重新整理。withCredentials: true 必須開,否則跨來源請求不會帶 Cookie。

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
// src/api/axios.js
import axios from 'axios';
import { handleApiError } from '@/utils/errorHandler';

const api = axios.create({
baseURL: process.env.VUE_APP_API_URL,
withCredentials: true
});

function readCookie(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=')[1];
}

// 請求攔截器:帶上 CSRF token
api.interceptors.request.use(
config => {
const token = readCookie('XSRF-TOKEN');
if (token) {
config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token);
} else {
console.warn('CSRF token not found');
}
return config;
},
error => Promise.reject(error)
);

// 響應攔截器:token 失效時重整
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 400 &&
error.response?.data?.error?.includes('security token')) {
console.error('Security token validation failed');
window.location.reload();
}
return handleApiError(error);
}
);

export default api;

decodeURIComponent 別漏。RequestToken 含 base64 字元,寫進 Cookie 時會被 URL 編碼(+%2B 等),不解碼直接送回去,後端驗證會對不上,這個我也卡過。

2. Vue 元件

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
<!-- src/components/UserForm.vue -->
<template>
<form @submit.prevent="submitForm" class="user-form">
<div v-if="error" class="error-message">{{ error }}</div>
<input v-model="form.username" placeholder="使用者名稱" required />
<input v-model="form.email" type="email" placeholder="電子郵件" required />
<button type="submit" :disabled="loading">
{{ loading ? '處理中...' : '建立使用者' }}
</button>
</form>
</template>

<script>
import api from '@/api/axios';

export default {
name: 'UserForm',
data() {
return {
form: { username: '', email: '' },
loading: false,
error: null
};
},
methods: {
async submitForm() {
this.loading = true;
this.error = null;
try {
const response = await api.post('/api/users', this.form);
this.$emit('user-created', response.data);
this.resetForm();
} catch (error) {
this.error = error.response?.data?.error || '建立使用者失敗';
} finally {
this.loading = false;
}
},
resetForm() {
this.form.username = '';
this.form.email = '';
}
}
};
</script>

3. 統一錯誤處理

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
// src/utils/errorHandler.js
import router from '@/router';

export const handleApiError = (error) => {
if (error.response) {
switch (error.response.status) {
case 400:
if (error.response.data?.error?.includes('security token')) {
console.error('Security token validation failed');
window.location.reload();
}
break;
case 401:
router.push('/login');
break;
case 403:
// 權限不足
break;
default:
console.error('API Error:', error.response.data);
break;
}
} else if (error.request) {
console.error('No response received:', error.request);
} else {
console.error('Request error:', error.message);
}
throw error;
};

測試

這段測試有兩個容易踩的雷。第一,建構子第一個參數型別是 RequestDelegate,直接丟一個沒型別標註的 lambda,C# 推不出來會報 cannot convert lambda to RequestDelegate,要明確轉型。第二,Mock.Of<IAntiforgery>()GetAndStoreTokens 預設回傳 null,中介軟體裡讀 tokens.RequestToken 會 NRE,所以要把回傳值 setup 好:

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
// Tests/CsrfTokenMiddlewareTests.cs
public class CsrfTokenMiddlewareTests
{
[Fact]
public async Task Should_Write_Xsrf_Cookie_For_Get_Request()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = "GET";

var tokenSet = new AntiforgeryTokenSet(
requestToken: "test-request-token",
cookieToken: "test-cookie-token",
formFieldName: "__RequestVerificationToken",
headerName: "X-XSRF-TOKEN");

var antiforgery = new Mock<IAntiforgery>();
antiforgery
.Setup(a => a.GetAndStoreTokens(It.IsAny<HttpContext>()))
.Returns(tokenSet);

var logger = Mock.Of<ILogger<CsrfTokenMiddleware>>();

// lambda 需明確轉成 RequestDelegate
var middleware = new CsrfTokenMiddleware(
next: (RequestDelegate)(_ => Task.CompletedTask),
antiforgery: antiforgery.Object,
logger: logger);

// Act
await middleware.InvokeAsync(context);

// Assert
Assert.Contains(
context.Response.Headers.SetCookie,
cookie => cookie.StartsWith("XSRF-TOKEN"));
}
}

整合測試:沒帶 token 的請求要被擋

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
// Tests/Integration/UserControllerTests.cs
public class UserControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public UserControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}

[Fact]
public async Task Should_Reject_Request_Without_Csrf_Token()
{
var client = _factory.CreateClient();
var request = new CreateUserRequest
{
Username = "test",
Email = "test@example.com"
};

var response = await client.PostAsJsonAsync("/api/users", request);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

常見排錯

token 驗證一直失敗

我自己卡過的幾個原因,從高頻到低頻:

  • Cookie 沒被寫入:在純 HTTP 下設了 Secure=true,瀏覽器拒寫。改用 HTTPS 或開發環境放寬 SecurePolicy。
  • SameSite 跟部署不符:跨站部署卻設了 Strict,跨站請求不帶 Cookie。改 None + Secure,或改成同站部署。
  • **前端沒 decodeURIComponent**:URL 編碼過的 token 直接送回去對不上。
  • withCredentials 沒開:跨來源請求不帶 Cookie,後端收不到。
  • **CORS 沒開 AllowCredentials**:瀏覽器擋下回應。注意 AllowCredentials() 不能跟 AllowAnyOrigin() 並用,必須明列來源。

診斷時先在瀏覽器主控台確認 Cookie 跟標頭到底有沒有送出去:

1
2
3
4
5
6
7
8
9
10
// 看 XSRF-TOKEN cookie 在不在
console.log('XSRF cookie:', document.cookie
.split('; ')
.find(c => c.startsWith('XSRF-TOKEN=')));

// 確認請求標頭有帶 token
api.interceptors.request.use(req => {
console.log('X-XSRF-TOKEN:', req.headers['X-XSRF-TOKEN']);
return req;
});

跨域(CORS)設定

跨站部署時 CORS 要列白名單,正式環境別用 SetIsOriginAllowed(_ => true)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
builder.Services.AddCors(options =>
{
options.AddPolicy("VueCorsPolicy", policy =>
{
if (builder.Environment.IsDevelopment())
{
policy.WithOrigins("http://localhost:8080")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod();
}
else
{
policy.WithOrigins("https://app.yoursite.com")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});

檔案上傳

FormData 走的是 multipart/form-data,攔截器照樣會帶上 X-XSRF-TOKEN 標頭,後端的 [ValidateCsrfToken] 一樣驗得到,不需要特別處理:

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
<!-- src/components/FileUpload.vue -->
<template>
<div class="file-upload">
<input type="file" @change="handleFileChange" :accept="acceptTypes" />
<div v-if="progress" class="progress">{{ progress }}%</div>
</div>
</template>

<script>
import api from '@/api/axios';

export default {
name: 'FileUpload',
props: {
acceptTypes: { type: String, default: '*/*' }
},
data() {
return { progress: 0 };
},
methods: {
async handleFileChange(event) {
const file = event.target.files[0];
if (!file) return;

const formData = new FormData();
formData.append('file', file);

try {
await api.post('/api/users/upload', formData, {
onUploadProgress: (e) => {
this.progress = Math.round((e.loaded * 100) / e.total);
}
});
this.$emit('upload-complete');
} catch (error) {
console.error('Upload failed:', error);
this.$emit('upload-error', error);
}
}
}
};
</script>

順手把安全標頭也補上

掃描器除了 CSRF,通常也會抱怨缺安全標頭。加一個中介軟體統一處理。這裡有一個常被照抄的過時建議要避開:X-XSS-Protection

1
2
3
4
5
6
7
8
9
10
11
12
13
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
// X-XSS-Protection 已被現代瀏覽器棄用,OWASP 建議設 0(關閉)或乾脆不設,
// 真正的 XSS 防護改用 Content-Security-Policy
headers["X-XSS-Protection"] = "0";
headers["Content-Security-Policy"] = "default-src 'self'";

await next();
});

X-XSS-Protection: 1; mode=block 是很多舊文章還在貼的寫法,但 Chrome 早就移除了 XSS Auditor、Firefox 從沒實作、Edge 也退役了它的 XSS 過濾器。更糟的是這個標頭在某些情境反而會引入新的漏洞,所以 OWASP 跟 MDN 現在的建議是設成 0 把它關掉,把真正的防護交給 CSP。這裡用 headers["X"] = "Y" 賦值而不是 Headers.Add(...),後者在 key 已存在時某些版本會丟例外。

如果要更嚴謹地管理金鑰輪替與 token 壽命,可以另外設定 Data Protection 的金鑰生命週期,例如把金鑰存進 Redis 並設輪替週期:

1
2
3
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
.SetDefaultKeyLifetime(TimeSpan.FromDays(14));

需要產生其他用途的隨機字串時,直接用 RandomNumberGenerator,別再用已被標記過時(SYSLIB0023)的 RNGCryptoServiceProvider

1
2
3
// .NET 6+ 寫法,舊的 RNGCryptoServiceProvider 會出 SYSLIB0023 警告
var randomBytes = RandomNumberGenerator.GetBytes(32);
var token = Convert.ToBase64String(randomBytes);

收尾

這套補完之後重跑掃描,CSRF 那條紅單消失了。回頭看,最該記住的不是哪段程式碼,而是三個判斷:

  1. API 要照官方文件確認,不要照網路範例抄AntiforgeryOptions.ExpireTimeSpanRandomKey 這類不存在的屬性在中文教學裡到處都是,抄了直接編不過,浪費的是你 debug 的時間。
  2. SameSite 要看部署拓撲,不是一律 Strict。同站部署 Lax 就夠;真跨站才需要 None + Secure,而且本機跑得起來不代表正式環境沒事。
  3. 安全建議會過期X-XSS-ProtectionRNGCryptoServiceProvider 都是當年正確、現在已被棄用的例子,掃描器的舊規則也可能跟著一起過時。

CSRF 防護不是設一次就一勞永逸的東西,但只要把 token 流向跟 Cookie 屬性這兩件事弄清楚,剩下的就是照部署環境把參數調對而已。