認證 API
概述
認證 API 提供用戶登入、Token 刷新、用戶資訊獲取和登出功能。採用 JWT Token 機制,支援帳號鎖定(Rate Limiting)和「記住我」功能。
端點列表
| 方法 | 路徑 | 說明 | 權限 |
|---|---|---|---|
| POST | /api/v1/auth/login | 用戶登入 | 公開 |
| POST | /api/v1/auth/refresh | 刷新 Access Token | 需要 Refresh Token |
| GET | /api/v1/auth/me | 獲取當前用戶資訊 | 需要認證 |
| POST | /api/v1/auth/logout | 用戶登出 | 需要認證 |
端點詳細規格
POST /api/v1/auth/login
描述: 用戶登入,返回 Access Token 和 Refresh Token
請求 Body:
{
"username": "staff@florist.com",
"password": "Password123!",
"remember": false
}
欄位說明:
| 欄位 | 類型 | 必填 | 說明 |
|---|---|---|---|
| username | string | Yes | 用戶名(通常為 email) |
| password | string | Yes | 密碼 |
| remember | boolean | No | 是否記住我(預設 false) |
響應範例 (200 OK):
店員登入:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "user-001",
"email": "staff@florist.com",
"name": "王小明",
"role": "ROLE_SALES",
"roles": ["ROLE_SALES"],
"tenantId": "tenant-abc"
}
}
店主登入(角色繼承扁平化):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "user-002",
"email": "owner@florist.com",
"name": "林老闆",
"role": "ROLE_OWNER",
"roles": ["ROLE_OWNER", "ROLE_MANAGER", "ROLE_SALES", "ROLE_ACCOUNTANT", "ROLE_PURCHASER", "ROLE_FLORIST", "ROLE_DELIVERY"],
"tenantId": "tenant-abc"
}
}
錯誤響應:
| 狀態碼 | 錯誤碼 | 說明 |
|---|---|---|
| 401 | AUTH_INVALID_CREDENTIALS | 用戶名或密碼錯誤 |
| 423 | AUTH_ACCOUNT_LOCKED | 帳號已被鎖定(連續失敗 5 次) |
錯誤響應範例 (401):
{
"message": "用戶名或密碼錯誤",
"error": "AUTH_INVALID_CREDENTIALS",
"path": "/api/v1/auth/login",
"timestamp": "2025-11-04T10:30:00Z"
}
業務規則:
- 連續登入失敗 5 次後,帳號鎖定 15 分鐘
- 密碼區分大小寫,用戶名不區分大小寫
remember=true時,Refresh Token 有效期為 30 天(否則 7 天)- Access Token 有效期固定 15 分鐘
POST /api/v1/auth/refresh
描述: 使用 Refresh Token 刷新 Access Token
請求 Body:
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
響應範例 (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900
}
錯誤響應:
| 狀態碼 | 錯誤碼 | 說明 |
|---|---|---|
| 401 | AUTH_TOKEN_INVALID | Refresh Token 無效或過期 |
GET /api/v1/auth/me
描述: 獲取當前已登入用戶的詳細資訊
請求 Headers:
Authorization: Bearer {access_token}
響應範例 (200 OK):
{
"id": "user-001",
"email": "staff@florist.com",
"name": "王小明",
"role": "ROLE_SALES",
"roles": ["ROLE_SALES"],
"tenantId": "tenant-abc",
"createdAt": "2025-10-01T08:00:00Z"
}
欄位說明:
| 欄位 | 類型 | 說明 |
|---|---|---|
| id | string | 用戶 ID |
| string | 電子郵件 | |
| name | string | 用戶名稱 |
| role | string | 主要角色(ROLE_XXX 格式,第一個角色) |
| roles | string[] | 扁平化角色陣列(包含繼承的角色,第一個為主要角色) |
| tenantId | string | 租戶 ID |
| createdAt | string | 帳號建立時間(ISO 8601) |
角色繼承說明:
後端 Spring Security 使用角色繼承機制,roles 欄位回傳扁平化的角色陣列:
| 主要角色 | roles 陣列 |
|---|---|
| ROLE_ADMIN | [ADMIN, OWNER, MANAGER, SALES, ACCOUNTANT, PURCHASER, FLORIST, DELIVERY] |
| ROLE_OWNER | [OWNER, MANAGER, SALES, ACCOUNTANT, PURCHASER, FLORIST, DELIVERY] |
| ROLE_MANAGER | [MANAGER, SALES, ACCOUNTANT] |
| ROLE_SALES | [SALES] |
| ROLE_FLORIST | [FLORIST] |
錯誤響應:
| 狀態碼 | 錯誤碼 | 說明 |
|---|---|---|
| 401 | AUTH_TOKEN_MISSING | 缺少 Authorization Header |
| 401 | AUTH_TOKEN_INVALID | Token 無效或過期 |
POST /api/v1/auth/logout
描述: 用戶登出(清除 Token)
請求 Headers:
Authorization: Bearer {access_token}
響應範例 (200 OK):
{
"message": "登出成功"
}
錯誤響應:
| 狀態碼 | 錯誤碼 | 說明 |
|---|---|---|
| 401 | AUTH_TOKEN_INVALID | Token 無效 |
備註:
- 前端應在登出後清除 localStorage 中的
access_token - Refresh Token 應從 Cookie 中清除(如使用 HttpOnly Cookie)
Token 管理
Access Token
- 有效期: 15 分鐘
- 儲存位置: Memory (Redux Store) 或 localStorage
- 用途: 所有 API 請求的身份驗證
- 格式: JWT (JSON Web Token)
- 包含資訊:
| Claim | 說明 | 範例 |
|---|---|---|
sub | 用戶 ID | "user-001" |
email | 電子郵件 | "staff@florist.com" |
tenantId | 租戶 ID | "tenant-abc" |
roles | 角色陣列(ROLE_XXX 格式) | ["ROLE_SALES"] |
iat | 簽發時間(Unix timestamp) | 1702987200 |
exp | 過期時間(Unix timestamp) | 1702988100 |
Refresh Token
- 有效期:
- 一般登入: 7 天
- 記住我: 30 天
- 儲存位置: HttpOnly Cookie(防止 XSS 攻擊)或 localStorage
- 用途: 刷新 Access Token
- 安全性:
- 無法透過 JavaScript 訪問(如使用 HttpOnly Cookie)
- 一次性使用(刷新後舊 Token 失效)
帳號鎖定機制 (Rate Limiting)
鎖定規則
- 連續登入失敗 5 次,觸發帳號鎖定
- 鎖定時間: 15 分鐘
- 鎖定期間: 即使輸入正確密碼也無法登入
- 鎖定解除: 15 分鐘後自動解除
實作細節
- 失敗次數計數器: 儲存在用戶記錄中(
loginFailedCount) - 鎖定時間戳:
lockedUntil(ISO 8601 格式) - 審計日誌: Console 記錄所有登入失敗與帳號鎖定事件
安全性考量
防止用戶枚舉攻擊
- 錯誤訊息統一: 用戶不存在和密碼錯誤返回相同訊息「用戶名或密碼錯誤」
- 避免洩漏: 不透露帳號是否存在
CSRF 防護
- Access Token 不儲存在 Cookie(如使用 localStorage)
- 使用 Custom Header (
Authorization: Bearer) 而非 Cookie 認證
XSS 防護
- Refresh Token 使用 HttpOnly Cookie(可選)
- Access Token 儲存在 Memory 或 localStorage(需前端防護)
使用範例
1. 登入流程
// 1. 登入
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'staff@florist.com',
password: 'Password123!',
remember: false
})
});
const { access_token, refresh_token, user } = await response.json();
// 2. 儲存 Token
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
// 3. 儲存用戶資訊
localStorage.setItem('user', JSON.stringify(user));
2. 攜帶 Token 訪問受保護 API
const response = await fetch('/api/v1/orders', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
3. 刷新 Token
// Access Token 過期時(收到 401 錯誤)
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: localStorage.getItem('refresh_token')
})
});
const { access_token } = await response.json();
localStorage.setItem('access_token', access_token);
// 重試原請求
4. 登出
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
// 清除本地儲存
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
// 重定向至登入頁
window.location.href = '/login';
相關 User Stories
最後更新: 2025-12-20