在 Spring MVC 裡面做 redirect 再常見不過,但怎麼安全地傳遞參數,就有點講究了。之前有個專案因為 redirect 時沒處理好敏感資訊,差點出事。今天就完整介紹幾種傳遞參數的方式。

問題背景

假設用戶提交了一個表單,你的 controller 處理完後想要 redirect 到另一個頁面。但有時候你需要傳一些資料給下一個頁面,比如成功/失敗的訊息,或者使用者 ID。

最簡單的方式就是加到 URL query string:

1
redirect:/order/confirm?orderId=12345&status=success

但這樣有問題:

  1. 敏感資訊會暴露在 URL 上
  2. 用戶可以直接改 URL query string 作弊
  3. 如果參數很多,URL 會很長很醜

更好的方式是用 RedirectAttributes

RedirectAttributes 的兩種方式

方式一:addAttribute(加到 URL query string)

1
2
3
4
5
6
7
@PostMapping("/order/create")
public String createOrder(Order order, RedirectAttributes redirectAttributes) {
int orderId = orderService.save(order);
redirectAttributes.addAttribute("orderId", orderId);
redirectAttributes.addAttribute("status", "success");
return "redirect:/order/confirm";
}

這樣會 redirect 到 /order/confirm?orderId=12345&status=success

用途:傳遞不敏感的、可以被使用者看到的資訊。

方式二:addFlashAttribute(存在 session,一次性)

1
2
3
4
5
6
7
8
9
10
@PostMapping("/order/create")
public String createOrder(Order order, RedirectAttributes redirectAttributes) {
int orderId = orderService.save(order);
Order createdOrder = orderService.getOrderById(orderId);

redirectAttributes.addFlashAttribute("message", "訂單建立成功!");
redirectAttributes.addFlashAttribute("order", createdOrder);

return "redirect:/order/confirm";
}

redirect 之後,下一個 request 可以直接在 Model 裡拿到:

1
2
3
4
5
6
7
8
9
10
@GetMapping("/order/confirm")
public String confirm(Model model) {
String message = (String) model.asMap().get("message");
Order order = (Order) model.asMap().get("order");

System.out.println(message); // 訂單建立成功!
System.out.println(order);

return "order/confirm";
}

或者在 Thymeleaf template 裡面直接用:

1
2
3
<div th:if="${message}" class="alert alert-success">
<span th:text="${message}"></span>
</div>

addAttribute vs addFlashAttribute

特性 addAttribute addFlashAttribute
存放位置 URL query string HTTP session
可見性 在 URL 上可見 不在 URL 上
生命週期 永久(直到手動改) 一次性(redirect 後就消失)
使用場景 篩選、分頁等參數 訊息、通知提示
敏感資訊 不適合 適合

實務例子

假設你有個刪除訂單的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("/order/{id}/delete")
public String deleteOrder(@PathVariable int id, RedirectAttributes redirectAttributes) {
try {
orderService.delete(id);
redirectAttributes.addFlashAttribute("message", "訂單已刪除");
redirectAttributes.addFlashAttribute("type", "success");
} catch (OrderNotFoundException e) {
redirectAttributes.addFlashAttribute("message", "訂單不存在");
redirectAttributes.addFlashAttribute("type", "error");
}

return "redirect:/orders";
}

@GetMapping("/orders")
public String listOrders(Model model) {
model.addAttribute("orders", orderService.findAll());
// Flash attribute 會自動加到 model 裡
return "orders/list";
}

Template 裡面:

1
2
3
4
5
6
7
8
9
10
<div th:if="${message}" th:class="'alert alert-' + ${type}">
<span th:text="${message}"></span>
</div>

<table>
<tr th:each="order : ${orders}">
<td th:text="${order.id}"></td>
<td th:text="${order.amount}"></td>
</tr>
</table>

四種 Redirect 的方式

除了 addAttributeaddFlashAttribute,Spring MVC 還提供了其他 redirect 的方式。

方式一:String 方式(最常用)

1
2
3
4
5
@PostMapping("/save")
public String save(User user) {
userService.save(user);
return "redirect:/users";
}

方式二:RedirectView

1
2
3
4
5
6
7
@PostMapping("/save")
public RedirectView save(User user) {
userService.save(user);
RedirectView redirectView = new RedirectView("/users");
redirectView.setContextRelative(true); // 相對於 context path
return redirectView;
}

setContextRelative 的作用是把 redirect 的路徑當成相對於應用的 context path。如果你的應用的 context path 是 /myappcontextRelative(true) 就會 redirect 到 /myapp/userscontextRelative(false)(預設)則會 redirect 到 /users

方式三:ModelAndView

1
2
3
4
5
6
7
8
9
@PostMapping("/save")
public ModelAndView save(User user) {
userService.save(user);

ModelAndView modelAndView = new ModelAndView("redirect:/users");
modelAndView.addObject("message", "使用者已儲存");

return modelAndView;
}

方式四:HttpServletResponse

有時候你想要更細粒度的控制,可以直接用 HttpServletResponse:

1
2
3
4
5
@PostMapping("/save")
public void save(User user, HttpServletResponse response) throws IOException {
userService.save(user);
response.sendRedirect("/users");
}

用這種方式的時候,Spring 不會再處理 view,你要自己負責整個 response。

常見的坑

1. Flash attribute 被 redirect 吞掉

Flash attribute 會自動在 redirect 後消失。如果你不小心做了多次 redirect,會丟掉資料:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 錯誤:訊息會在第二次 redirect 時丟掉
@PostMapping("/step1")
public String step1(RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("message", "Step 1 done");
return "redirect:/step2";
}

@GetMapping("/step2")
public String step2(RedirectAttributes redirectAttributes) {
// message 已經在上一個 redirect 時被消費了,這裡拿不到
redirectAttributes.addFlashAttribute("message", "Step 2 done");
return "redirect:/result";
}

解法就是只在最後一個 step 前加 flash attribute。

2. addAttribute 的參數沒有被正確編碼

如果參數含有特殊字元,可能會被破壞:

1
2
3
// 可能有問題
redirectAttributes.addAttribute("name", "小王&小李");
// 最後可能變成 /users?name=小王&小李(& 被當成參數分隔符了)

Spring 會自動幫你編碼,所以通常不是問題。但如果你用 String.format 手動組 URL,要小心:

1
2
3
4
5
6
// 危險!
return String.format("redirect:/users?name=%s", name);

// 安全的做法
redirectAttributes.addAttribute("name", name);
return "redirect:/users";

3. POST-Redirect-GET 模式

如果表單提交之後直接 forward(不 redirect),使用者按 F5 重新整理會重複提交表單。正確的做法是 POST 之後 redirect,然後 GET 新頁面:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 正確的流程:POST -> Redirect -> GET
@PostMapping("/order/create")
public String createOrder(Order order, RedirectAttributes redirectAttributes) {
int orderId = orderService.save(order);
return "redirect:/order/" + orderId;
}

@GetMapping("/order/{id}")
public String getOrder(@PathVariable int id, Model model) {
Order order = orderService.getOrderById(id);
model.addAttribute("order", order);
return "order/detail";
}

URI Template 變數

有時候你想要 redirect 到有 path variable 的 URL,可以這樣做:

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/order")
public String createOrder(Order order, RedirectAttributes redirectAttributes) {
int orderId = orderService.save(order);
return String.format("redirect:/order/%d", orderId);
}

// 或者用 addAttribute + URI template
@PostMapping("/order")
public String createOrder(Order order, RedirectAttributes redirectAttributes) {
int orderId = orderService.save(order);
redirectAttributes.addAttribute("id", orderId);
return "redirect:/order/{id}";
}

重點整理

  • addAttribute - 加到 URL query string,適合不敏感的參數
  • addFlashAttribute - 存在 session,一次性,適合訊息提示
  • Flash attribute 只活一次 redirect,刷新頁面就消失了
  • 四種 redirect 方式:String、RedirectView、ModelAndView、HttpServletResponse
  • POST 之後一定要 redirect,不要 forward,防止表單重複提交
  • 參數編碼交給 Spring 處理,不要手動組 URL

用 RedirectAttributes 搞定 redirect 的參數傳遞,代碼會更乾淨更安全。