FX Online — Report Access Token (Anonymous Post-Submit)
Design: anonymous applicant ดู application report ได้หลัง submit ผ่าน HMAC signed short-lived token — ไม่ต้อง login, ไม่ expose instanceId เป็น secret, stateless
อัปเดต: 2026-06-30
Anonymous applicant ดู/download application report ได้ทันทีหลัง submit onboarding — โดยไม่ต้อง login — ผ่าน signed short-lived token ที่ผูกกับ instanceId และมี TTL 30 นาที
ปัญหา
Flow เดิม (broken)
user submit
→ ExecuteStepActionCommand: ClearCookie = true
→ cookie ถูก clear ใน HTTP response
→ FE navigate → /application-result?ref=FX-...&id={instanceId}
→ FE เรียก GET /onboarding/instances/{id}/application-report
→ 401 ❌ (OnboardingReportController มี [Authorize] class-level)
ทำไม cookie ไม่ได้:
SameSite=Lax บน SIT/UAT/Prod — fetch cross-site (pages.dev → exim.go.th) ไม่ส่ง cookie เลย
และ clear cookie หลัง submit เป็น intentional design ไม่ใช่ bug
ทำไม GUID ใน URL ไม่พอ:
| ค่า | ลักษณะ | ปัญหา |
|---|---|---|
ref=FX-202606-00046 | sequential | enumerable → ไม่ใช่ secret |
instanceId | ใน URL | browser history, referrer header, access log |
Solution: Signed Short-Lived Report Token
หลักการ: issue a secret at submit time, require it at read time
submit → server ออก ReportAccessToken
└─ FE เก็บใน memory (ไม่ใส่ URL)
└─ FE เรียก GET /application-report + header: X-Report-Token: <token>
└─ Backend verify signature + expiry + instanceId → serve
Token Format
Base64Url(
expiresAtUnixSeconds [8 bytes] ← big-endian, ดึงออกโดยไม่ต้องทำ crypto ก่อน
HMAC-SHA256(secret, instanceId[16] || expiresAt[8]) [32 bytes]
)
= 40 bytes raw → 54 Base64Url chars
Security properties:
| Property | ทำได้อย่างไร |
|---|---|
| instanceId binding | HMAC input รวม instanceId bytes — token ของ instance A ใช้กับ B ไม่ได้ |
| Expiry | expiresAt embed อยู่ใน token, decode ออกมา check ก่อนทำ HMAC |
| Timing-safe | CryptographicOperations.FixedTimeEquals ป้องกัน timing attack |
| Stateless | ไม่มี Redis/DB lookup — verify ด้วย HMAC เท่านั้น |
| ไม่ leak | token ส่งทาง X-Report-Token header ไม่อยู่ใน URL |
Authorization Matrix
| Caller | JWT | X-Report-Token | ผลลัพธ์ |
|---|---|---|---|
| Anonymous post-submit | ❌ | ✅ valid + not expired | ✅ 200 |
| Anonymous post-submit | ❌ | ✅ expired | ❌ 403 “ลิงก์หมดอายุแล้ว” |
| Anonymous post-submit | ❌ | ❌ | ❌ 403 |
| Logged-in owner | ✅ owner | ❌ | ✅ 200 |
| Logged-in non-owner | ✅ non-owner | ❌ | ❌ 403 |
| Admin | ✅ admin role | ❌ | ✅ 200 (admin controller แยก) |
Implementation
ไฟล์ที่สร้าง/แก้ (9 ไฟล์ — ไม่มี schema change)
| # | ไฟล์ | Action | Layer |
|---|---|---|---|
| 1 | UserService04.Domain/Configuration/ReportTokenSettings.cs | NEW | Domain |
| 2 | UserService04.Domain/Ports/Security/IReportTokenService.cs | NEW | Domain |
| 3 | UserService02.Infrastructure/Security/ReportTokenService.cs | NEW | Infrastructure |
| 4 | UserService01.API/Program.cs | MODIFY +2 lines | API |
| 5 | appsettings.json / appsettings.Development.json | MODIFY +section | Config |
| 6 | ExecuteStepActionCommand.cs (Result record) | MODIFY +1 field | Application |
| 7 | ExecuteStepActionCommand.cs (Handler) | MODIFY token issuance | Application |
| 8 | GetApplicationReportQuery.cs | MODIFY +anonymous path | Application |
| 9 | OnboardingReportController.cs | MODIFY [AllowAnonymous] + header read | API |
:::details Domain layer (ไฟล์ใหม่)
ReportTokenSettings.cs
public sealed class ReportTokenSettings
{
public string SecretBase64 { get; init; } = "";
public int TtlMinutes { get; init; } = 30;
}
IReportTokenService.cs
public interface IReportTokenService
{
string Generate(Guid instanceId, DateTimeOffset expiresAt);
ReportTokenValidationResult Validate(string token, Guid instanceId);
}
public sealed record ReportTokenValidationResult(bool IsValid, bool IsExpired);
:::
:::details Infrastructure — ReportTokenService
Generate:
expiresAtBytes[8] = Unix seconds (big-endian)
hmac = HMAC-SHA256(secret, instanceId_bytes || expiresAt_bytes)
token = Base64Url(expiresAtBytes || hmac)
Validate:
- Base64Url decode → check length == 40
- Extract
expiresAt→ ถ้าเลยUtcNow→IsExpired=true, IsValid=false - Compute expected HMAC →
CryptographicOperations.FixedTimeEquals(expected, actual):::
:::details Application layer — token issuance ใน handler
ใน ExecuteStepActionCommand Handler เพิ่มที่ submit block:
if (cmd.ActionCode is StepActionCode.Submit or StepActionCode.Resubmit)
{
if (active is not null) await editSessionRepo.DeactivateAsync(active.SessionId, ct);
clearCookie = true;
// เพิ่ม: ออก report token พร้อม submit
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_reportTokenSettings.TtlMinutes);
reportAccessToken = _reportTokenService.Generate(cmd.InstanceId, expiresAt);
}
Result record เพิ่ม optional field:
public sealed record ExecuteStepActionResult(
StepNavigationDto Nav,
string? CookieSessionId,
int CookieTtlSeconds,
bool ClearCookie = false,
string? ReportAccessToken = null); // ← เพิ่ม
:::
:::details Application layer — anonymous path ใน GetApplicationReportQuery
Handler แยก 3 path ตาม caller:
if (q.IsAdmin)
{
// admin path — ไม่เปลี่ยน
}
else if (q.ReportToken is not null)
{
// anonymous post-submit path
var validation = _reportTokenService.Validate(q.ReportToken, q.InstanceId);
if (!validation.IsValid)
return Result.Failure<ApplicationReportDto>(
validation.IsExpired
? Error.Forbidden("Report.TokenExpired", "ลิงก์หมดอายุแล้ว กรุณาสมัครใหม่หรือเข้าสู่ระบบ")
: Error.Forbidden("Report.TokenInvalid", "ไม่มีสิทธิ์เข้าถึงรายงานนี้"));
}
else
{
// JWT owner path — restore owner-check (เดิม comment ออกใน TESTING PHASE)
if (q.RequestingUserId is null || instance.OwnerUserId != q.RequestingUserId)
return Result.Failure<ApplicationReportDto>(
Error.Forbidden("Application.Forbidden", "ไม่มีสิทธิ์เข้าถึงใบสมัครนี้"));
}
:::
:::details Controller — [AllowAnonymous] + read header
[AllowAnonymous] // override class-level [Authorize] สำหรับ anonymous post-submit
[HttpGet("onboarding/instances/{id:guid}/application-report")]
public async Task<IActionResult> GetOwnerReport(Guid id, CancellationToken ct)
{
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
Guid.TryParse(userIdStr, out var userId);
var reportToken = Request.Headers["X-Report-Token"].FirstOrDefault();
return ApiMatch(await mediator.Send(
new GetApplicationReportQuery(
id,
userId == Guid.Empty ? null : userId,
IsAdmin: false,
ReportToken: reportToken),
ct));
}
:::
IaC & Config
appsettings.json (base — ทุก env inherit):
"ReportToken": {
"SecretBase64": "",
"TtlMinutes": 30
}
Key Vault secret name pattern: {env}--user-service--ReportToken--SecretBase64
ค่าเป็น Base64 ของ random 32 bytes — ต้องต่างกันทุก env
| Env | Secret |
|---|---|
| DEV | dev--user-service--ReportToken--SecretBase64 |
| SIT | sit--user-service--ReportToken--SecretBase64 |
| UAT | uat--user-service--ReportToken--SecretBase64 |
| PROD | prod--user-service--ReportToken--SecretBase64 (เข้าผ่าน prod account) |
Frontend (ไม่อยู่ใน Backend PR)
FE ต้องทำ 2 อย่าง:
- อ่าน
reportAccessTokenจาก submit response body แล้ว เก็บใน memory (ไม่ใส่ URL/localStorage) - ส่ง เป็น
X-Report-Tokenheader ตอนเรียก/application-report
Artifacts (ณ 2026-06-30)
| Repo | PR / Branch | Target | สถานะ |
|---|---|---|---|
| Backend_UserService | PR #984 feature/sprint9/SA-1557-report-token | development | open |
| Backend_Iac | PR #982 feature/user-service-report-token | development | open |
| sa-onboarding-demo | branch feature/report-access-token | main | PR ยังไม่สร้าง |