223 lines
6.2 KiB
Markdown
223 lines
6.2 KiB
Markdown
# 用户认证
|
||
|
||
> 仅在用户明确要求登录功能时才实现认证。默认设计应支持匿名访问。
|
||
|
||
## 认证方法
|
||
|
||
### 用户名登录(默认方式)
|
||
**需要登录功能时**,优先采用用户名登录,自动生成 `{username}@meoo.local` 虚拟邮箱以兼容 Supabase 认证系统。
|
||
|
||
|
||
|
||
### 手机号登录
|
||
把手机号当作用户名(非短信验证码),同样自动生成 `{username}@meoo.local` 虚拟邮箱 + 密码登录。
|
||
|
||
### 邮箱登录
|
||
仅在用户明确要求使用真实邮箱时才使用的标准邮箱/密码认证。
|
||
|
||
## ❌ 不支持的登录方式
|
||
|
||
以下登录方式 **不支持**,如果用户要求请明确告知无法实现:
|
||
|
||
- **手机验证码登录(短信 OTP)**:不支持短信验证码
|
||
|
||
- **第三方登录**:不支持微信、QQ、支付宝、GitHub 等第三方登录
|
||
|
||
|
||
- **扫码登录**:不支持二维码扫码登录
|
||
- **指纹/面部识别**:不支持生物识别登录
|
||
|
||
**如果用户要求以上登录方式,请明确说明 Meoo Cloud 目前仅支持用户名/密码登录。**
|
||
|
||
## 实现要点
|
||
|
||
### 1. 存储完整会话对象
|
||
```typescript
|
||
const [session, setSession] = useState<Session | null>(null);
|
||
```
|
||
|
||
### 2. 用户名认证示例
|
||
**实现登录注册时必须使用此方式**,除非用户明确要求使用真实邮箱:
|
||
```typescript
|
||
// 注册
|
||
await supabase.auth.signUp({
|
||
email: `${username}@meoo.local`,
|
||
password,
|
||
options: { data: { username } }
|
||
});
|
||
|
||
// 登录
|
||
await supabase.auth.signInWithPassword({
|
||
email: `${username}@meoo.local`,
|
||
password,
|
||
});
|
||
```
|
||
|
||
### 3. 认证状态监听
|
||
```typescript
|
||
// ✅ 正确:同步状态更新 + 在 effect cleanup 里 unsubscribe
|
||
useEffect(() => {
|
||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||
setSession(session);
|
||
setUser(session?.user ?? null);
|
||
|
||
// 延迟任何 Supabase 调用
|
||
if (session?.user) {
|
||
setTimeout(() => {
|
||
fetchUserProfile(session.user.id);
|
||
}, 0);
|
||
}
|
||
});
|
||
return () => subscription.unsubscribe();
|
||
}, []);
|
||
|
||
// ❌ 错误:会导致死锁
|
||
supabase.auth.onAuthStateChange(async (event, session) => {
|
||
const profile = await supabase.from('profiles').select()... // 死锁
|
||
});
|
||
```
|
||
|
||
## 用户数据表设计
|
||
|
||
|
||
### 用户配置文件表
|
||
|
||
**重要**:只有当用户明确要求注册和登录功能时,才创建 `public.profiles` 表与 `auth.users` 表关联。
|
||
|
||
```sql
|
||
CREATE TABLE public.profiles (
|
||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||
username TEXT UNIQUE NOT NULL,
|
||
avatar_url TEXT,
|
||
created_at TIMESTAMP DEFAULT NOW()
|
||
);
|
||
|
||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||
|
||
-- 用户只能查看和修改自己的配置文件
|
||
CREATE POLICY users_select_own_profile ON public.profiles
|
||
FOR SELECT USING (auth.uid() = id);
|
||
|
||
CREATE POLICY users_update_own_profile ON public.profiles
|
||
FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
|
||
```
|
||
|
||
### 自动创建配置文件触发器
|
||
|
||
设置触发器在用户注册时自动创建配置文件(依赖 `SECURITY DEFINER` 绕过 RLS 写入 profiles):
|
||
|
||
```sql
|
||
-- 插入 public.profiles 的函数
|
||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||
AS $$
|
||
BEGIN
|
||
INSERT INTO public.profiles (id, username, avatar_url)
|
||
VALUES (
|
||
NEW.id,
|
||
NEW.raw_user_meta_data ->> 'username',
|
||
NEW.raw_user_meta_data ->> 'avatar_url'
|
||
);
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
-- 创建触发器(DROP IF EXISTS 保证重复执行幂等)
|
||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||
CREATE TRIGGER on_auth_user_created
|
||
AFTER INSERT ON auth.users
|
||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||
```
|
||
|
||
> 不要忘记 `SECURITY DEFINER`!
|
||
|
||
|
||
|
||
|
||
### 业务表与用户关联
|
||
|
||
如果需要在业务表中引用用户信息,请引用 profiles 表的 `id`:
|
||
|
||
```sql
|
||
CREATE TABLE public.user_posts (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||
title TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
updated_at TIMESTAMP DEFAULT NOW()
|
||
);
|
||
|
||
ALTER TABLE public.user_posts ENABLE ROW LEVEL SECURITY;
|
||
|
||
-- 用户只能查看和操作自己的帖子
|
||
CREATE POLICY users_select_own_posts ON public.user_posts
|
||
FOR SELECT USING (user_id = auth.uid());
|
||
|
||
CREATE POLICY users_insert_own_posts ON public.user_posts
|
||
FOR INSERT WITH CHECK (user_id = auth.uid());
|
||
|
||
-- UPDATE 必须同时给 USING 和 WITH CHECK,否则用户能把别人的帖子改到自己名下后再修改
|
||
CREATE POLICY users_update_own_posts ON public.user_posts
|
||
FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
|
||
```
|
||
|
||
### 角色和权限设计
|
||
|
||
角色**必须**存储在独立表中,不要在 profiles 表中存储角色:
|
||
|
||
```sql
|
||
CREATE TYPE public.app_role AS ENUM ('admin', 'moderator', 'user');
|
||
|
||
CREATE TABLE public.user_roles (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
|
||
role app_role NOT NULL,
|
||
UNIQUE (user_id, role)
|
||
);
|
||
|
||
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||
|
||
-- 用户能读到自己的角色
|
||
CREATE POLICY users_select_own_roles ON public.user_roles
|
||
FOR SELECT USING (user_id = auth.uid());
|
||
|
||
-- 辅助函数检查用户角色
|
||
CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role app_role)
|
||
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||
AS $$
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM public.user_roles
|
||
WHERE user_id = _user_id AND role = _role
|
||
)
|
||
$$;
|
||
```
|
||
|
||
## 创建测试用户
|
||
|
||
```sql
|
||
INSERT INTO auth.users (
|
||
instance_id, id, aud, role, email,
|
||
encrypted_password, email_confirmed_at,
|
||
created_at, updated_at,
|
||
confirmation_token, recovery_token,
|
||
email_change_token_new, email_change,
|
||
raw_app_meta_data, raw_user_meta_data,
|
||
is_super_admin
|
||
) VALUES (
|
||
'00000000-0000-0000-0000-000000000000',
|
||
gen_random_uuid(), 'authenticated', 'authenticated',
|
||
'testuser@meoo.local',
|
||
crypt('password123', gen_salt('bf')),
|
||
NOW(), NOW(), NOW(),
|
||
'', '', '', '',
|
||
'{"provider":"email","providers":["email"]}'::jsonb,
|
||
'{"username":"testuser"}'::jsonb,
|
||
false
|
||
);
|
||
```
|
||
|
||
必需字段:`instance_id`、`::jsonb` 类型转换、空字符串令牌(非 NULL)
|
||
|
||
|