多裝置登入與指定裝置登出任務規劃

更新時間:2026-07-03

目標

讓使用者可以看到自己目前有效登入裝置,並可登出指定裝置或登出其他所有裝置。

本任務核心不是新增另一套 token,而是管理既有 api_sessions

  • 同一個 user_id 可以有多筆 api_sessions
  • 不同 device_id 通常會有不同 session_id
  • 同一個 user_id + device_id 再登入時,legacy/new 都會優先重用既有 api_sessions.id,並刷新 dataexpiresapi_key
  • 登出某台裝置就是刪掉該裝置對應的 api_sessions row。

目前 Session 機制

DB

主要資料表:api_sessions

欄位用途
idsession token,也就是 response 的 session_id / request 的 X-PRO360-User-Session-Token / cookie pro360_u_s_token
user_idsession 屬於哪個 user
device_id登入時傳入的裝置識別
data登入 IP,通常來自 HTTP_X_FORWARDED_FOR
expiressession 到期 unix timestamp
api_key登入時使用的 X-PRO360-Rest-Api-Key

查詢目前帳號 session:

SELECT
  id AS session_id,
  user_id,
  device_id,
  api_key,
  FROM_UNIXTIME(expires) AS expires_at
FROM api_sessions
WHERE user_id = :current_user_id
ORDER BY expires DESC;

只列有效 session:

SELECT
  id AS session_id,
  device_id,
  api_key,
  data AS ip,
  FROM_UNIXTIME(expires) AS expires_at
FROM api_sessions
WHERE user_id = :current_user_id
  AND expires > UNIX_TIMESTAMP()
ORDER BY expires DESC;

Legacy 對照

session_id 產生與 upsert

legacy 主要方法:

/Users/mattsu/Documents/Site/get-lancer-php56/app/Model/ApiSession.php
ApiSession::upsert($user_id, $device_id)

行為:

  1. 呼叫 session_regenerate_id()
  2. session_id() 取得新的候選 session id。
  3. api_sessions 是否已有相同 user_id + device_id
  4. 如果已有,回傳舊的 ApiSession.id,只更新:
    • data
    • expires = time() + 86400
    • api_key
  5. 如果沒有,新增一筆:
    • id = session_id()
    • user_id
    • device_id
    • data
    • expires
    • api_key
  6. api_session log,context 為 existing/newdevice_idsession_id

因此 legacy 下:

  • 不同 device_id 登入會有不同 session。
  • 相同 device_id 再登入會重用同一個 session_id

legacy login 入口

legacy /users/login.json 成功後會呼叫:

/Users/mattsu/Documents/Site/get-lancer-php56/app/Plugin/MobileApp/Event/MobileAppEventHandler.php
ClassRegistry::init('ApiSession')->upsert($user_id, $device_id)

成功 response 會包含:

{
  "error": 0,
  "message": "Success",
  "user_id": "...",
  "session_id": "..."
}

同時呼叫:

/Users/mattsu/Documents/Site/get-lancer-php56/app/Model/User.php
User::setCookieLogin($session_id)

會設定 cookie:

pro360_u_s_token=<session_id>

cookie 屬性:

  • Expires = time() + 31536000
  • Path=/
  • Domain=<site.api_url host>
  • Secure
  • HttpOnly
  • SameSite=None

legacy session 驗證

legacy 主要方法:

/Users/mattsu/Documents/Site/get-lancer-php56/app/Controller/RestApiHelper.php
RestApiHelper::hasValidApiSession($controller)

流程:

  1. 先驗 X-PRO360-Rest-Api-Key
  2. 取 session id:
    • 優先 pro360_u_s_token cookie
    • 沒 cookie 才讀 X-PRO360-User-Session-Token
  3. 用 session id 查 api_sessions
  4. api_sessions.user_idusers
  5. blocked user 直接回 HTTP 403。
  6. 更新 users.iphone_last_accesslast_access_client
  7. 通過後把 user id 寫入 request params。

注意:RestApiHelper::getUserId($request) 不是純 getter,還會寫 full request info log。

legacy 既有刪 session 參考

legacy 有 admin/CS 工具:

/Users/mattsu/Documents/Site/get-lancer-php56/app/Lib/CsModifyUtil.php
CsModifyUtil::logoutAllSessionToken($user_id)

行為:

  1. 查該 user 所有 api_sessions
  2. 寫操作 log。
  3. 逐筆 DELETE FROM api_sessions WHERE id = :id

這是「登出所有裝置」概念,不是使用者自行登出指定裝置。

New 對照

session_id 產生與 upsert

new 主要方法:

Lib/Model/ApiSession.php
\PRO360\Model\ApiSession::upsert($user_id, $device_id)

行為對齊 legacy:

  1. 可啟動 PHP session 時先 session_start()
  2. api_sessions 是否已有相同 user_id + device_id
  3. 如果已有,回傳既有 id,更新:
    • data
    • expires = time() + 86400
    • api_key
  4. 如果沒有,產生 session id:
    • web runtime:session_regenerate_id() + session_id()
    • CLI/test:bin2hex(random_bytes(16))
  5. 新增 api_sessions row。
  6. api_session log。

new login 入口

new /users/login.json

Endpoint/V1/Users.php
Users::login()

會呼叫:

Lib/Util/Pro360LoginUtil.php
Pro360LoginUtil::validate_user()

成功後:

  1. ApiSession::upsert($user_id, $device_id) 建立或重用 session。
  2. response 回 session_id
  3. 設定 cookie pro360_u_s_token=<session_id>
  4. user_loginslogin_logsuser_login_infos 等 login side effect。

new session 驗證

new 主要方法:

Lib/Validation/ApiValidation.php
ApiValidation::assertApiSession($request, $usersDO)
ApiValidation::hasValidApiSession($request, ...)

流程:

  1. 先驗 X-PRO360-Rest-Api-Key
  2. 取 session id:
    • 優先 pro360_u_s_token cookie
    • 沒 cookie 才讀 X-PRO360-User-Session-Token
  3. 用 session id 查 api_sessions
  4. api_sessions.user_idusers
  5. 檢查 blocked / inactive。
  6. 更新 legacy access side effect,例如 iphone_last_accesslast_access_client

指定裝置登出設計

建議 API

以 users endpoint 實作,保持和既有 session 管理一致:

GET  /users/sessions.json
POST /users/sessions/delete.json
POST /users/sessions/delete_others.json

如果 routing 不適合巢狀 path,也可用:

GET  /users/list_sessions.json
POST /users/delete_session.json
POST /users/delete_other_sessions.json

GET /users/sessions.json

認證:

  • valid API key
  • valid user session

查詢:

SELECT
  id AS session_id,
  device_id,
  api_key,
  data AS ip,
  FROM_UNIXTIME(expires) AS expires_at
FROM api_sessions
WHERE user_id = :current_user_id
  AND expires > UNIX_TIMESTAMP()
ORDER BY expires DESC;

response 建議:

{
  "error": 0,
  "sessions": [
    {
      "session_id": "...",
      "device_id": "...",
      "expires_at": "...",
      "is_current": true
    }
  ]
}

注意:

  • api_sessions 沒有 created / modified,不能直接顯示登入時間。
  • data 是 IP,但可能是 CloudFront / proxy chain;前端顯示前要確認產品需求。
  • api_key 不建議原樣回前端;若要顯示來源,用 ApiKey::getPlatform() 或後端 mapping 成 platform。

POST /users/sessions/delete.json

request:

{
  "session_id": "target_session_id"
}

認證:

  • valid API key
  • valid current user session

刪除 SQL:

DELETE FROM api_sessions
WHERE id = :target_session_id
  AND user_id = :current_user_id;

重點:

  • 一定要加 user_id = :current_user_id,避免刪到別人的 session。
  • 不建議只用 device_id 刪,因為 device id 可能重複、空值或 my-device-id
  • target_session_id === current_session_id,刪除後要清 pro360_u_s_token cookie,前端也要執行本機 logout。
  • 若刪的是其他裝置,當前裝置不應被登出。

建議 log:

api_session_logs.action = delete_session
api_session_logs.user_id = current_user_id
api_session_logs.params = deleted api_session row json
api_session_logs.admin_user_id = 0

POST /users/sessions/delete_others.json

認證:

  • valid API key
  • valid current user session

刪除 SQL:

DELETE FROM api_sessions
WHERE user_id = :current_user_id
  AND id <> :current_session_id;

這等同「登出所有其他裝置」,保留目前裝置。

new 目前已有可參考方法:

Endpoint/V1/Users.php
Users::clear_login()

既有行為:

  • keep_current=1 時保留目前 session,刪其他 session。
  • 刪除前寫 api_session_logs

可評估是否直接補 route / 文件讓前端使用,或新增更明確的 user-facing API。

實作檢查清單

  1. 確認前端要顯示哪些欄位:device id、平台、IP、到期時間、目前裝置。
  2. 新增 list endpoint,僅查目前 user 的有效 sessions。
  3. 新增 delete target endpoint,用 session_id + current_user_id 刪除。
  4. 刪目前 session 時清 cookie;刪其他 session 時不影響目前 session。
  5. api_session_logs,保存刪除前 row。
  6. 補 PHPUnit:
    • list 只回目前 user sessions
    • 不回過期 sessions
    • 刪其他 device session 成功
    • 刪別人的 session 不成功
    • 刪目前 session 後 cookie 清除
    • delete_others 保留目前 session
  7. staging 驗證:
    • A/B device login 產生不同 session
    • A 刪 B session 後,B 下一次 API invalid session
    • A 刪自己 session 後,A 前端登出

風險與決策點

  • cookie 優先於 header:測試時如果同時帶 cookie 和 header,實際使用的是 cookie。
  • device_id 不是安全邊界,只能當顯示資訊;刪除目標應使用 session_id
  • 既有 api_sessions.expires 過期 row 不會自動消失;列表應預設只顯示 expires > UNIX_TIMESTAMP()
  • 若要顯示「登入時間」,目前 api_sessions schema 不足,需要另外查 user_logins 或新增欄位;這是產品/DB migration decision。
  • legacy/new 都會在相同 user_id + device_id 重用 session id;若前端產生固定 device id,使用者可能看不到多筆同裝置 session。