| name | lms-auth |
| description | LMS authentication system including SSO integration, useAuthReady hook patterns, four-layer security architecture, and permission checking. Use this skill when implementing auth flows, handling user sessions, fixing auth-related bugs, or understanding role-based access control. |
LMS Auth Skill
認證系統、SSO 整合、useAuthReady hook 標準模式、四層安全架構
Last Updated: 2025-12-29
四層安全架構(v1.66.0)
1. Authentication (Supabase Auth) - 必須登入
2. RLS (Database Layer) - 粗粒度:authenticated_read, admin_full_access
3. Application Layer (lib/api/permissions.ts) - 細粒度:角色過濾
4. Frontend (AuthGuard) - 頁面存取控制
Application Layer 使用模式
import { requireAuth, requireRole, filterByRole } from '@/lib/api/permissions';
export async function getData() {
const user = await requireAuth();
}
export async function adminOnly() {
const user = await requireRole(['admin']);
}
export async function getStudents() {
const user = await requireAuth();
const allStudents = await fetchAllStudents();
return filterByRole(allStudents, user, { gradeField: 'grade' });
}
關鍵函數
| 函數 | 說明 |
|---|
getCurrentUser() | 取得當前用戶,未登入返回 null |
requireAuth() | 要求登入,未登入 throw Error |
requireRole(roles) | 要求特定角色 |
filterByRole(data, user, opts) | 依角色過濾資料 |
canAccessGrade(user, grade) | 檢查是否可存取年級 |
canAccessCourseType(user, type) | 檢查是否可存取課程類型 |
useAuthReady Hook(標準模式)
核心規則
永遠使用 useAuthReady hook,不要直接使用 useAuth
正確模式
import { useAuthReady } from "@/hooks/useAuthReady";
const { userId, isReady, role } = useAuthReady();
useEffect(() => {
if (!isReady) return;
fetchData();
}, [userId]);
錯誤模式
const { user, loading } = useAuth();
useEffect(() => {
if (loading || !user) return;
fetchData();
}, [user]);
為什麼這很重要
user 是物件,React 比較參照而非值
- 每次 auth 事件都會觸發 useEffect(即使是同一用戶)
- Supabase 會觸發多個 auth 事件:
INITIAL_SESSION
SIGNED_IN
TOKEN_REFRESHED
useAuthReady 提取 userId 作為穩定的 primitive 值
Hook 提供的欄位
interface UseAuthReadyReturn {
userId: string | null;
role: string | null;
isReady: boolean;
isLoading: boolean;
permissions: UserPermissions | null;
grade: number | null;
track: string | null;
teacherType: string | null;
fullName: string | null;
}
AuthContext useRef 修復
問題描述
切換 macOS 桌面再切回來時,onAuthStateChange 會觸發 SIGNED_IN 事件,但 skip 邏輯無法正確判斷。
根本原因:React 閉包
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
if (userPermissions?.userId === session?.user?.id) {
return
}
})
}, [])
解決方案:使用 useRef
const userPermissionsRef = useRef<UserPermissions | null>(null);
useEffect(() => {
userPermissionsRef.current = userPermissions;
}, [userPermissions]);
supabase.auth.onAuthStateChange((event, session) => {
if (['TOKEN_REFRESHED', 'SIGNED_IN', 'INITIAL_SESSION'].includes(event)
&& userPermissionsRef.current?.userId === session?.user?.id) {
console.log('[AuthContext] Same user auth event, skipping permission refetch:', event)
return
}
})
效果
切換桌面回來時,console 會顯示:
[AuthContext] Same user auth event, skipping permission refetch: SIGNED_IN
不會重新 fetch 所有頁面資料。
SSO 整合概要
架構
User → Info Hub (Google OAuth) → Authorization Code →
LMS (Token Exchange) → Supabase User Sync → Session Creation → Dashboard
OAuth 2.0 + PKCE 流程
- User 點擊 "Login with Google" on LMS
- LMS 生成 PKCE challenge,重導向到 Info Hub
- Info Hub 認證用戶(Google OAuth)
- Info Hub 同步用戶到 Supabase via Webhook
- Info Hub 返回 Authorization Code to LMS
- LMS 交換 code 取得用戶資料(server-side)
- LMS 建立 Supabase session
- User 登入到 LMS Dashboard
角色映射
| Info Hub Role | LMS Role | Teacher Type | Track |
|---|
| admin | admin | null | null |
| office_member | office_member | null | null |
| head (LT) | head | null | LT |
| head (IT) | head | null | IT |
| head (KCFS) | head | null | KCFS |
| teacher (IT) | teacher | IT | international |
| teacher (LT) | teacher | LT | local |
| teacher (KCFS) | teacher | KCFS | null |
| viewer | Denied | - | - |
安全措施
- PKCE:防止 code 攔截
- CSRF State Token:防止跨站請求偽造
- Webhook Secret:驗證用戶同步請求
- Service Role Key 隔離:LMS 不分享憑證
權限檢查模式
頁面級權限
import { useAuthReady } from "@/hooks/useAuthReady";
import { redirect } from "next/navigation";
export default function AdminPage() {
const { isReady, role } = useAuthReady();
if (!isReady) return <Loading />;
if (role !== 'admin') redirect('/dashboard');
return <AdminContent />;
}
元件級權限
const { role, grade, track } = useAuthReady();
if (role === 'admin') {
return <AllData />;
}
if (role === 'head') {
const filteredData = data.filter(d =>
d.grade === grade && d.courseType === track
);
return <FilteredData data={filteredData} />;
}
if (role === 'teacher') {
return <OwnData userId={userId} />;
}
API 級權限(Server Actions)
import { createClient } from '@/lib/supabase/server';
export async function someAction() {
const supabase = createClient();
const { data, error } = await supabase
.from('some_table')
.select('*');
return data;
}
環境變數
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# SSO
INFOHUB_CLIENT_ID=lms-production
INFOHUB_CLIENT_SECRET=xxx
INFOHUB_WEBHOOK_SECRET=xxx
INFOHUB_BASE_URL=https://info-hub.example.com
常見問題
登入後重導向不正確
原因:SSO callback 後的 redirect 邏輯
解決:檢查 app/api/auth/callback/infohub/route.ts
權限載入延遲
原因:AuthContext 尚未完成載入
解決:使用 isReady 檢查,顯示 Loading 狀態
切換用戶後看到舊資料
原因:組件沒有正確監聽 userId 變化
解決:確保 useEffect 依賴包含 userId