首页
/ 基于 Expo + Supabase 构建 React Native 用户管理应用:从行级安全到头像存储的完整实践指南

基于 Expo + Supabase 构建 React Native 用户管理应用:从行级安全到头像存储的完整实践指南

2026-09-06 19:08:12作者:凤尚柏Louis

导读

本文围绕 examples/user-management/expo-user-management 示例项目展开,介绍如何用 Expo(React Native)快速搭建一套包含邮箱密码注册/登录、个人资料读取与更新、头像上传下载的移动端用户管理应用,并深入讲解其背后的 Supabase Auth、Postgres 行级安全(Row Level Security,RLS)与 Storage 对象存储策略。读完本文,你将掌握在移动端安全接入 Supabase 的完整链路,并能够理解为什么「客户端只用受限密钥 + 数据库强制 RLS」是这套示例安全的根本保障。

该示例完整托管在 Supabase 官方仓库中,核心文件包括应用入口 App.tsx、Supabase 客户端初始化 lib/supabase.ts、三个界面组件 components/Auth.tsxcomponents/Account.tsxcomponents/Avatar.tsx,以及可直接入库的 SQL 迁移 supabase/migrations/20240403090422_init.sql

一、环境准备与依赖概览

1.1 前置要求

运行该示例前需要准备:

  • Node.js 环境与包管理器(示例使用 npm,仓库中已提供 package.json);
  • Expo CLI:示例基于 Expo SDK ~55 开发,脚本通过 expo 命令驱动,因此需要按 Expo 官方安装流程全局安装或使用 npx expo 方式调用;
  • 一个 Supabase 项目:示例中的数据库表结构、存储桶与授权策略默认由 Supabase 云端项目承载。

package.json 可以看到示例的技术栈版本组合:expo ~55.0.5react-native 0.83.2react 19.2.0@supabase/supabase-js ^2(2.x 系列)、@react-native-async-storage/async-storage 2.2.0(用于会话持久化)、expo-image-picker(头像图库选取)以及 react-native-url-polyfill(补齐 React Native 环境的 URL 能力)。这些依赖共同构成了一条面向移动端的 Supabase 集成基线。

1.2 本地开发相关配置

示例目录下自带 supabase/config.toml,可用于本地 Supabase 开发环境。其中几个与本示例强相关的配置项值得注意:

  • [db] major_version = 15:声明数据库主版本为 Postgres 15,本地运行应与远端一致;
  • [auth] enable_signup = true[auth.email] enable_confirmations = false:默认允许注册且不做邮箱确认,便于本地调试;生产环境建议开启邮箱确认;
  • [storage] file_size_limit = "50MiB":单文件上传上限为 50 MiB,超出会拒绝;
  • [inbucket] enabled = true:本地邮箱测试服务,登录邮件可在 Inbucket Web 界面查看,实际不会真正外发;
  • [auth.email.template.*] 区块被注释保留,可按需自定义邮件模板路径。

需要说明的是,本文的注册、存储桶与策略等核心步骤同样以「Supabase 云项目 + SQL 编辑器」为准(与原文档一致),本地 config.toml 只是仓库提供的另一种可复现路径,属于锦上添花的补充。

二、从零起步:创建项目并初始化数据库

2.1 创建 Supabase 项目并获取连接凭据

在 Supabase Dashboard 中创建一个新项目,等待数据库启动完成。随后进入项目的 SQL 编辑器,找到并执行 User Management Starter 快速启动模板(也可直接执行本文第五节的完整 SQL,二者等价)。

启动模板会一次性完成三件事:

  1. 创建 profiles 数据表并开启行级安全;
  2. 通过触发器在用户注册时自动创建个人资料;
  3. 创建 avatars 存储桶并配置访问策略。

2.2 获取 API URL 与客户端密钥

进入 Project Settings → API,复制以下两项:

  • Project URL:形如 https://<project-ref>.supabase.co 的 API 端点;
  • publishable key / anon key:客户端专用密钥(不同版本控制台命名略有差异,仓库此版本以 publishable key 命名,见 .env.example)。

理解这两把密钥的职责边界是接入 Supabase 的必修课:

  • anon / publishable 密钥用于客户端(浏览器、移动端)。它允许用户登录前对数据库进行「匿名访问」,一旦用户登录成功,请求令牌会自动切换为该用户自己的登录 JWT,从而让数据库层面的行级安全策略真正生效;
  • secret / service_role 密钥拥有绕过所有安全策略的完全权限,只能保存在受信任的服务器环境中,绝不可打包进客户端或浏览器,否则任何人反编译 App 即可获得数据库全量读写能力。

2.3 配置环境变量

复制环境变量模板并填入上一步拿到的值:

cp .env.example .env

.env.example 内容如下,这正是需要填充的完整变量清单:

# Get these from your API settings
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

两个变量均以 EXPO_PUBLIC_ 为前缀,这是 Expo 官方约定的暴露给客户端代码的环境变量命名规范——只有带此前缀的变量才会在打包时被内联进 App。与之对应,lib/supabase.ts 中正是通过 process.env.EXPO_PUBLIC_SUPABASE_URLprocess.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY 读取这两项配置,因此在 npm start 之前必须完成 .env 填充,否则会因变量为空导致客户端初始化失败。

三、安装依赖并运行应用

3.1 安装依赖

在示例目录下执行:

npm install

该命令会依据 package.json 安装全部依赖(锁定于仓库提交的版本范围)。若使用 pnpm workspace 管理整个 monorepo,也可在仓库根目录用 workspace 方式安装。

3.2 头像选择器需要先预构建

示例的头像上传依赖 expo-image-picker,其中涉及原生相机胶卷权限模块。在 React Native 原生能力参与的流程中,直接运行 Expo Go 无法覆盖全部能力,因此首次运行前必须先执行预构建以生成本地原生工程:

npm run prebuild

package.json 中该脚本对应 expo prebuild,会生成 android/ios/ 原生目录。此后如需重新打包到真机,可再通过 npm run android / npm run ios(对应 expo run:android / expo run:ios)进行原生编译运行。

3.3 启动应用

npm start

对应脚本为 expo start --dev-client,即以开发客户端模式启动。为什么此处不是默认的 Expo Go 模式?原因正是 3.2 节提到的文件选择器原生依赖——--dev-client 会加载预构建出的原生模块,从而保证 ImagePicker 在开发阶段即可正常工作。启动后按终端提示在模拟器或真机上打开应用即可。

启动后流程立即衔接核心功能:应用先展示登录/注册界面(Auth 组件),登录成功后自动切换到资料编辑界面(Account 组件)。

四、应用源码拆解:登录、资料与头像的完整数据流

4.1 客户端初始化:会话持久化到本地存储

lib/supabase.ts 是整个 App 与 Supabase 的唯一通道:

import { createClient } from '@supabase/supabase-js'
import AsyncStorage from '@react-native-async-storage/async-storage'

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!

export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
  auth: {
    storage: AsyncStorage as any,   // 用 AsyncStorage 持久化会话
    autoRefreshToken: true,          // 自动刷新过期 access token
    persistSession: true,            // 重启 App 后恢复登录态
    detectSessionInUrl: false,       // 原生环境无 URL 重定向,关闭探测
  },
})

四个 auth 选项都是移动端场景的关键开关:storage 把 JWT 与刷新令牌存进 @react-native-async-storage/async-storagepersistSession 保证用户杀掉 App 再打开仍处于登录状态;autoRefreshToken 让 SDK 在令牌临近过期时用刷新令牌自动续期;detectSessionInUrl: false 则告知 SDK 不需要监听 URL 中的会话参数(那是 Web 端 OAuth 回调的场景)。

4.2 会话状态驱动界面切换

App.tsx 采用了一个轻量但清晰的「单状态切界面」方案:

  • 组件挂载后调用 supabase.auth.getClaims() 读取当前 JWT 中的声明(claims),取出 claims.sub(即 auth.users 中的用户 UUID)与 claims.email
  • 通过 supabase.auth.onAuthStateChange 订阅登录态变化事件:每次 SIGNED_IN / SIGNED_OUT 等事件触发时重新 getClaims()
  • 渲染逻辑一行决定界面:userId ? <Account/> : <Auth/>

因此整套 UI 无需任何全局状态管理库,登录态即 UI 态。注意这里读取用户身份用的是 JWT claims 而非再次请求数据库,属于一次本地解密操作,开销极小;只有当真正需要读取 profiles 数据时才发起网络请求(见 Account 组件)。

4.3 登录与注册界面(Auth 组件)

components/Auth.tsx 提供邮箱 + 密码两套动作:

// 登录:校验密码并换取会话
const { error } = await supabase.auth.signInWithPassword({ email, password })

// 注册:创建用户并触发 handle_new_user 触发器
const { error } = await supabase.auth.signUp({ email, password })

signUp 成功后会返回一个会话,而服务端同时会在 auth.users 表插入新行,该行会立即触发数据库中的 on_auth_user_created 触发器,自动为该用户创建一条 profiles 记录(详见第五节 SQL)。错误统一通过 Alert.alert(error.message) 呈现,SDK 返回的错误消息已经人类可读,无需二次映射。

4.4 资料展示与编辑(Account 组件)

components/Account.tsx 接收上层传入的 userIdemail,挂载后读取资料:

const { data, error, status } = await supabase
  .from('profiles')
  .select(`username, website, avatar_url`)
  .eq('id', userId)
  .single()

if (error && status !== 406) throw error

这里对 406 状态做了特判:当用户尚无 profile 行时 PostgREST 会返回 406,代码将其视为「正常空态」而非错误。保存时使用 upsert

const updates = { id: userId, username, website, avatar_url, updated_at: new Date() }
const { error } = await supabase.from('profiles').upsert(updates)

upsert 同时覆盖了「首次写入」与「再次更新」两种场景,省去先查后写的分支。注意写入对象必须携带 id = userId,因为数据库侧的 RLS 更新策略要求 auth.uid() = id,用户只能写自己的行。Email 输入框被设为只读,邮箱归属 auth 系统而非 profiles,符合最小权限原则。

4.5 头像上传与下载(Avatar 组件)

components/Avatar.tsx 演示了与 Supabase Storage 交互的完整模式:

下载:通过公开策略可读的存储桶对象,无需签名 URL 即可取回:

const { data, error } = await supabase.storage.from('avatars').download(path)
const fr = new FileReader()
fr.readAsDataURL(data)   // 转为 data URL 供 <Image> 显示

上传:先用 expo-image-picker 打开系统图库并允许裁剪旋转,再把图片转为 ArrayBuffer 上传:

const result = await ImagePicker.launchImageLibraryAsync({
  mediaTypes: ImagePicker.MediaTypeOptions.Images,
  allowsMultipleSelection: false,
  allowsEditing: true,
  quality: 1,
  exif: false,   // 丢弃 EXIF 元数据以保护隐私
})

const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())
const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'
const path = `${Date.now()}.${fileExt}`   // 时间戳命名避免冲突

const { data, error: uploadError } = await supabase.storage
  .from('avatars')
  .upload(path, arraybuffer, { contentType: image.mimeType ?? 'image/jpeg' })

onUpload(data.path)  // 把返回的文件路径回传给 Account 持久化

文件路径由时间戳加扩展名构成,天然唯一,避免多人上传同名覆盖。上传成功后将路径回传给 Account.updateProfile 写入 profiles.avatar_url,下次进入页面时即可直接 download 该路径渲染头像。这就是一条「选图 → 上传 Storage → 路径入库 → 读库取路径 → 下载渲染」的闭环。

五、安全基石:Postgres 行级安全与完整数据库脚本

原文档明确指出:该项目使用 Postgres 行级安全(RLS)实现了高水准的授权。当你在 Supabase 启动一个 Postgres 数据库时,系统会自动注入 auth schema 与若干辅助函数;用户登录后携带的 JWT 包含角色 authenticated 与用户 UUID,数据库正是依据这些信息对每行数据的读写施加细粒度控制。

仓库内的 supabase/migrations/20240403090422_init.sql 是官方 Quickstart SQL 的等价物,且比原文档中的「精简版」更完整(多出 full_name 字段、自动建档触发器与更新策略)。下面按段解析其安全设计。

5.1 profiles 表结构与 RLS 开启

create table profiles (
  id uuid references auth.users not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,
  constraint username_length check (char_length(username) >= 3)
);

alter table profiles
  enable row level security;

设计要点:

  • id 既作主键又外键引用 auth.users.id,把业务资料与认证用户强绑定,同时继承 auth 用户的级联生命周期;
  • usernameunique 约束,数据库层保证昵称唯一,杜绝并发注册下的竞态;
  • username_length 检查约束强制用户名至少 3 个字符,把校验下沉到数据库,任何客户端都绕不过;
  • alter table ... enable row level security 之后,未显式授予策略的操作默认全部拒绝——这是 Postgres 的默认拒绝模型,也是安全的前提。

5.2 三条 RLS 策略

create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

create policy "Users can insert their own profile." on profiles
  for insert with check (auth.uid() = id);

create policy "Users can update own profile." on profiles
  for update using (auth.uid() = id);

逐条解读:

策略 语句 判定表达式 语义
公开可读 select using (true) 所有人的资料都可被读取(个人主页、列表页场景)
仅自建 insert with check (auth.uid() = id) 只能插入 id 等于自己 UUID 的行,无法伪造他人资料
仅自改 update using (auth.uid() = id) 只能更新属于自己的行,using 限定可被修改的行范围

auth.uid() 是 Supabase 注入的辅助函数,其返回值来自当前 JWT 的 sub 声明。using 决定「哪些行可操作」,with check 决定「写入后的新行是否允许存在」,二者结合构成了对 select / insert / update 的完整约束。原文档特别强调:anon 密钥在用户未登录时以 anon 角色访问,此时 auth.uid() 为空,上述 insert/update 策略自然全部拒绝——安全不是靠客户端藏起按钮,而是靠数据库强制执行

5.3 注册自动建档触发器

迁移文件比原文档精简版多出的关键部分,是用户注册时的自动建档机制:

create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

security definer 使触发器以函数所有者(超级用户)权限执行,从而能安全写入开启了 RLS 的 profiles 表;new.id 直接取新注册用户的 UUID,new.raw_user_meta_data 则把注册时携带的元数据(如昵称)带入 profile。这也解释了 4.4 节中「为何新用户首次加载时 profile 已存在」——是数据库触发器而非客户端代码完成了建档。

5.4 头像存储桶与访问策略

insert into storage.buckets (id, name)
  values ('avatars', 'avatars');

-- 头像可被任何人下载
create policy "Avatar images are publicly accessible." on storage.objects
  for select using (bucket_id = 'avatars' and storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated']));

-- 任何人可上传(注意上传方需持有合法 JWT 才算 authenticated)
create policy "Anyone can upload an avatar." on storage.objects
  for insert with check (bucket_id = 'avatars');

-- 上传者只能改自己的头像
create policy "Anyone can update their own avatar." on storage.objects
  for update using (auth.uid() = owner) with check (bucket_id = 'avatars');

这里呈现了 Storage 与数据库 RLS 的同构安全模型:select 公开(所有人可看图),insert 放开到 avatars 桶但以 with check 锁定桶名,update 额外用 auth.uid() = owner 把修改权收敛到文件属主。Storage 的文件元数据同样存在数据库(storage.objects),因此可以复用同一套 RLS 语法做对象级授权。

六、补充机制:Realtime 与本地开发注意事项

6.1 打开 Realtime 发布通道

原文档 SQL 末尾通过创建/清空 supabase_realtime 发布并追加 profiles 表,为后续接入实时订阅(例如好友资料变化即时刷新)预留通道:

begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;

从源码结构看,当前示例 UI 尚未消费 realtime 事件,但保留该发布意味着你可以通过 supabase.channel('profiles').on('postgres_changes', ...) 一行接入,不必再回头补迁移。而在仓库本地配置 supabase/config.toml 中同样有 [realtime] enabled = true 与之呼应。

6.2 本地跑通整套迁移

若希望脱离云端、完全本地验证,仓库为示例附带了同款 SQL 迁移 supabase/migrations/20240403090422_init.sqlsupabase/config.toml。结合根目录的 Supabase CLI 工作流(docker/dev/docker-compose.dev.ymldocker/README.md 等提供自托管基础设施),可在本地完成 supabase start → 迁移应用 → npm start 的全链路验证。此路径下邮箱验证可借助 [inbucket] 本地收信界面完成,避免污染真实邮箱。作为对照,仓库还提供了同一「用户管理」主题下的 Web/其他移动端实现,例如 examples/user-management/nextjs-user-managementexamples/user-management/flutter-user-management,其共享同一套 profiles 表结构与 RLS 策略,方便跨端横向对照学习。

七、常见问题与安全红线小结

  • 启动即报错提示 URL 为空:多为未执行 cp .env.example .env,或变量未以 EXPO_PUBLIC_ 前缀命名导致未被打包内联,检查 .env 后需重启 npm start
  • npm start 无法唤起系统相册:未执行 npm run prebuild 并用 Expo Go 直接运行导致,原生模块需经 expo prebuild--dev-client 模式加载;
  • 用户登录后看不到自己的资料:检查 profiles 是否有该用户的行——正常由 handle_new_user 触发器自动创建;若在关闭触发器的情况下手动用 SQL 编辑器插行,编辑器会话以 service 角色执行,不受 RLS 限制;
  • 上传失败返回 403/row-level security violation:Storage insert 需要当前请求携带合法 JWT(即已登录的 authenticated 角色),未登录的匿名请求会被策略拒绝,属预期行为;
  • 务必区分三类密钥:客户端只能用 anon/publishable key;service_role/secret key 拥有绕过全部 RLS 的完全权限,一旦泄露等同数据库裸奔,只允许出现在后端服务环境。

总而言之,这个示例最大的教学价值在于其「薄客户端 + 厚数据库」的授权范式:客户端只保留 anon 密钥与会话 JWT,全部数据边界由 RLS 策略、外键约束、检查约束与触发器在 Postgres 内部强制闭环。你可以在阅读 数据库迁移文件 与四个 核心组件 源码的基础上,将此模式迁移到自己的业务表,例如给 postscomments 等表设计各自的 auth.uid() = author_id 策略,即可快速扩展为任意「用户拥有自己数据」的移动应用。

登录后查看全文
热门项目推荐
相关项目推荐