
Epic Stack 认证架构演进自管用户名密码认证、保留 remix-auth 做 GitHub OAuth【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack本文以 Epic Stack 的架构决策文档 029-remix-auth.md 为骨架结合仓库内 auth.server.ts、github.server.ts、callback.ts、schema.prisma 等源码完整还原用户名密码认证自行实现、第三方 OAuth 继续依托 remix-auth这一决策的来龙去脉与落地细节帮助读者理解一套生产级 React RouterRemix全栈应用的认证分层方案并能在自己的项目中复刻同样的拆分思路。决策背景为什么放弃 remix-auth-formEpic Stack 项目早期使用remix-auth-formremix-auth 生态中专用于处理用户名/密码表单登录的策略来承载账号密码认证。该方案本身可以正常工作但决策文档 029 明确指出一个核心观察it really didnt give us any value over handling the auth song-and-dance ourselves.也就是说用户名/密码认证本质上就是一套固定的流程——校验表单、比对密码哈希、创建会话、写会话 Cookie。用remix-auth-form只是把这套流程封装进策略框架里对项目而言并没有带来额外的架构收益反而引入了一层间接封装。从 docs/authentication.md 可以看到Epic Stack 的定位是使用 Web 标准与成熟库自管认证manages its own authentication using web standards and established libraries and tools默认提供三种认证机制用户名 密码认证第三方 Provider 认证SSOPasskeyWebAuthn认证其中前两种正是本文决策涉及的核心用户名密码认证完全自研第三方登录保留 remix-auth 生态。决策内容登录表单认证自己写remix-auth 只留给 OAuth决策文档 029 给出的结论非常明确不再依赖 remix-auth 处理用户登录表单提交的认证而是由应用自己管理。这让我们能删除一部分代码但 remix-auth 会被保留下来用于 GitHub 认证。这个拆分带来两条清晰的技术路线密码认证路线直接调用自研的login/signup/verifyUserPassword等函数读写 Prisma 数据库、用 bcrypt 校验密码、创建 Session——全程不经过 remix-auth 的 Strategy 机制Provider 认证路线继续使用 remix-auth 的Authenticator与各类 Strategy如remix-auth-github、web-oidc把第三方 OAuth/OIDC 的握手协议交给成熟库处理。配套的决策文档 030-github-auth.md 进一步阐述了为何保留 remix-authGitHub 不支持 OpenID Connect但通过 remix-auth 可以轻松内置 GitHub 实现并且未来可以替换为任意 OAuth2 或 OIDC ProviderOIDC 场景可配合web-oidc。也就是说remix-auth 存在的意义是协议适配层而不是业务认证层。自研密码认证的源码级实现认证核心模块 auth.server.ts密码认证的全部业务逻辑集中在 app/utils/auth.server.ts它不依赖任何 remix-auth 策略直接面向 Prisma 与 bcrypt 编程login({ username, password })调用verifyUserPassword校验凭据成功后创建数据库 Session返回{ id, expirationDate, userId }signup({ email, username, password, name })用bcrypt.hash(password, 10)生成密码哈希同时创建用户、默认角色roles: { connect: { name: user } }与 SessionverifyUserPassword(where, password)按 username 或 id 查出用户及其密码哈希用bcrypt.compare比对失败一律返回null避免泄露用户是否存在logout(...)删除数据库中的 Session 记录并销毁浏览器 Cookie其中void prisma.session.deleteMany(...).catch(() {})的.catch是刻意保留的用于触发 PrismaPromise 实际执行查询。该文件还定义了 Session 过期策略export const SESSION_EXPIRATION_TIME 1000 * 60 * 60 * 24 * 30 // 30 天 export const getSessionExpirationDate () new Date(Date.now() SESSION_EXPIRATION_TIME) export const sessionKey sessionId会话与会话读取getUserId(request)从authSessionStorage见 session.server.ts读取sessionId再到数据库校验 Session 是否存在且未过期若 Session 无效会抛出 redirect 并销毁 Cookie。requireUserId/requireAnonymous则基于它实现路由级守卫——登录后才可访问的页面用requireUserId登录后不应访问的页面如登录页、注册页用requireAnonymous。密码安全增强除了 bcrypt 哈希checkIsCommonPassword会调用 Pwned Passwords APIk-anonymity 方案只上传 SHA-1 前缀拦截常见弱密码详见 043-pwnedpasswords.md。remix-auth 的保留用法Provider 认证架构Authenticator 与多策略注册在 auth.server.ts 中remix-auth 只承担 Provider 认证职责export const authenticator new AuthenticatorProviderUser() for (const [providerName, provider] of Object.entries(providers)) { const strategy provider.getAuthStrategy() if (strategy) { authenticator.use(strategy, providerName) } }providers注册表定义在 connections.server.ts目前只有github: new GitHubProvider()。每个 Provider 实现统一的 AuthProvider 接口export interface AuthProvider { getAuthStrategy(): StrategyProviderUser, any | null handleMockAction(request: Request): Promisevoid resolveConnectionData(providerId: string, options?: { timings?: Timings }): Promise{ displayName: string link?: string | null } }getAuthStrategy()返回null表示该 Provider 未配置这是没有 GitHub 配置应用也能跑的关键设计详见下文authenticator.use循环会自然跳过。GitHubStrategy 的封装github.server.ts 内的GitHubProvider.getAuthStrategy()展示了 remix-auth-github 的典型用法当GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET、GITHUB_REDIRECT_URI任一缺失时返回null并打印提示日志配置齐全时构造GitHubStrategy在其 verify 回调中用 access token 分别请求https://api.github.com/user与/user/emails新版 remix-auth-github 拆分了这两个请求并用 Zod 校验响应后归一化为ProviderUserreturn { id: user.id, email, name: user.name, username: user.login, imageUrl: user.avatar_url, }ProviderUser类型定义在 provider.ts{ id, email, username?, name?, imageUrl? }它是连接第三方身份与本地用户的标准载体。发起登录的 Action 路由/auth/:provider路由auth.$provider/index.ts只负责发起 OAuthexport async function action({ request, params }: Route.ActionArgs) { const providerName ProviderNameSchema.parse(params.provider) try { await handleMockAction(providerName, request) return await authenticator.authenticate(providerName, request) } catch (error: unknown) { // 捕获重定向响应追加 redirectTo Cookie 后继续抛出 ... } }authenticator.authenticate抛出的是 Response重定向到 GitHub 授权页React Router 会把它当作正常响应处理catch分支只在 Response 上补充redirectToCookie用于登录成功后回到原页面。登录表单组件 ProviderConnectionForm 会渲染Login/Connect/Signup with GitHub按钮提交到这个 Action。Connection 模型多 Provider 的数据基石决策文档 030 中规划的Connection模型已在 prisma/schema.prisma 落地与决策文档的初稿略有演进去掉了unique([providerId, userId])model Connection { id String id default(cuid()) providerName String providerId String createdAt DateTime default(now()) updatedAt DateTime updatedAt user User relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String unique([providerName, providerId]) }要点unique([providerName, providerId])保证同一个第三方账号只能绑定到一个本地用户onDelete: Cascade保证用户删除时其第三方连接一并清理providerName字段使系统天然支持多个 Provider 并存未来加 Google、GitHub 之外的 Provider 只需新增一行注册。Callback 路由一张流程图理清七种状态第三方认证的回调是所有状态逻辑最集中的地方。决策文档 030 明确要求callback 中用户可能处于多种状态全部需要被测试覆盖。这些状态在 callback.ts 的 loader 中逐一实现并在 callback.test.ts 中有对应用例状态处理逻辑对应测试OAuth 认证失败记录错误并带 error toast 重定向到/loginwhen auth fails, send the user to login with a toast已登录且连接已存在同账号提示 Already Connected重定向到连接管理页when a user is logged in and has already connected...已登录且连接已被其他账号占用提示已被其他账号连接gives an error if the account is already connected to another user已登录但无此连接创建 Connection提示 Connectedwhen a user is logged in, it creates the connection未登录但连接已存在直接为该连接所属用户创建新 Sessionif a user is not logged in, but the connection exists, make a session未登录、连接不存在但邮箱匹配现有用户创建连接并建 Session提示 Connectedwhen a user exists with the same email, create connection and make session完全的新用户把 profile 写入 verifySession跳转/onboarding/{provider}a new user goes to onboarding此外还有一条测试if a user is not logged in, but the connection exists and they have enabled 2FA, send them to verify their 2FA and do not make a session对应开启 2FA 的用户通过第三方连接登录时需要二次验证的链路。回调 loader 开头还有一句await ensurePrimary()见 litefs.server.ts因为该 loader 会写库必须确保命中主实例而非只读副本。无密码用户与 Onboarding第三方登录的连带设计决策文档 030 的 Consequences 部分提出了两个必须处理的连带问题第三方用户可能没有密码——需要允许无密码完成注册禁止用户在未创建密码前删除全部连接避免账号失去所有登录途径。无密码注册由/onboarding/:provider路由onboarding/$provider.tsx承担回调把email、prefilledProfile、providerId写入 verifySession 后跳转至此页面用 Conform Zod 校验 username、name、服务条款勾选等字段最终调用signupWithConnection定义于 auth.server.ts创建用户 Connection Session若用户提供了imageUrl还会下载头像并上传到对象存储。export async function signupWithConnection({ email, username, name, providerId, providerName, imageUrl }) { // 创建 User 并内联创建 Connection const user await prisma.user.create({ data: { email: email.toLowerCase(), username: username.toLowerCase(), name, roles: { connect: { name: user } }, connections: { create: { providerId, providerName } }, }, select: { id: true }, }) // 可选下载并上传头像 // 创建 Session }注意这里刻意不创建 Password 记录——connections是用户登录凭据password是可选的。用户名归一化normalizeUsername会把 GitHub 用户名中的非法字符替换为下划线并转小写邮箱统一toLowerCase()保证数据一致性。连接管理 UI 在 settings/profile/connections.tsx用户可在设置页连接/断开 Provider、创建密码。没有 GitHub 配置也能跑Mock 机制与优雅降级决策文档 030 的最后一条 Consequences 强调应用在未配置 GitHub 登录时也必须正常运行对应 Epic Stack 的 Minimize Setup Friction 设计原则。仓库中有三层保障策略级降级GitHubProvider.getAuthStrategy()在环境变量缺失时返回nullAuthenticator不会注册该策略登录页只是不显示 GitHub 按钮应用其余功能完全不受影响Mock 模式shouldMock判定GITHUB_CLIENT_ID以MOCK_开头或NODE_ENV test。此时handleMockAction会直接构造一个假的 OAuth 回调内置预设 code state模拟 GitHub 授权流程配合 tests/mocks/github.ts 中的 MSW 拦截https://github.com/login/oauth/access_token让开发者在零配置下体验完整登录链路e2e 支持测试可通过MOCK_CODE_GITHUB_HEADER请求头注入自定义 code见 github.server.ts 与 constants.tsPlaywright 测试据此稳定驱动 GitHub 登录。而在真实部署环境process.env.MOCKS未设为true见 server/index.ts 与 index.ts请求会真正到达 GitHub 服务器此时必须配置真实的 OAuth App。实战接入真实 GitHub OAuth完整步骤记录在 docs/authentication.md登录 GitHub进入Settings → Developer settings → OAuth Apps点击Register a new applicationHomepage URL 填http://localhost:3000Authorization callback URL 填http://localhost:3000/auth/github/callbackApplication name 取对用户有意义的名字如MY_EPIC_APPLICATION_DEVELOPMENT注册后把 Client ID 复制到.env的GITHUB_CLIENT_ID点击Generate client secret生成密钥并复制到GITHUB_CLIENT_SECRET最后点Update application启动应用在登录页选择Login with GitHub授权后跳回本地应用并进入 onboarding 流程此时刷新 GitHub OAuth App 页面可看到用户数从 0 变为 1每个部署环境staging、production都应注册独立的 OAuth App分别配置对应的 homepage 与 redirect URL。需要提醒的是OAuth App 的创建者与授权用户是两个不同实体App 完全可以注册在组织或其他人的账号下这在设计多环境授权时要留意。另外.env.example中的GITHUB_CLIENT_ID默认为MOCK_...这是启动 Mock GitHub Server 的前置条件基于 MSW。结论两条路线各司其职的分层范式回顾决策 029它确立的其实是一种**业务认证自研 协议适配外购的分层范式**与自有数据强相关、需要完全掌控的流程密码哈希、Session 生命周期、账号绑定规则由应用自己实现代码直接、可测、可审计与外部协议强相关、容易踩坑的握手OAuth 授权码交换、token 刷新、OIDC claims交给 remix-auth 及对应 Strategy降低维护成本同时保留任意替换 Provider 的灵活性。这种拆分的收益在仓库中清晰可见自研部分全部集中在 auth.server.ts 一个文件内单元测试与 e2e 测试覆盖完整第三方部分通过 AuthProvider 接口 与 connections.tsx 的注册表解耦新增一个 Provider 的边际成本极低。对任何需要同时支持自有账号体系 多家 SSO的 React 全栈项目而言Epic Stack 这套决策与实现都值得直接借鉴。【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考