• Remix v2 + Cloudflare Pages 集成 Github 登录


    在这里插入图片描述

    Remix Auth 特性

    • 完整的服务器端身份验证
    • 完整的 TypeScript 支持
    • 基于策略的身份验证
    • 轻松处理成功和失败
    • 实施自定义策略
    • 支持持久会话

    安装依赖

    npm i --save remix-auth remix-auth-github
    
    • 1

    需要用到这两个包。然后创建 auth 相关的文件,参考以下结构:

    ├── app
    │   ├── entry.client.tsx
    │   ├── entry.server.tsx
    │   ├── root.tsx
    │   └── routes
    │       ├── _index.tsx
    │       ├── auth.github.callback.ts
    │       ├── auth.github.ts
    │       └── private.tsx
    └── tsconfig.json
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    routes 目录中创建 auth.github.tsauth.github.callback.ts 两个核心文件。

    封装服务

    由于 Cloudflare Pages 无法使用 process.env 所以采用了一种很骚的操作来实现:

    // auth.server.ts
    import { createCookieSessionStorage } from '@remix-run/cloudflare';
    import { Authenticator } from 'remix-auth';
    import type { SessionStorage } from '@remix-run/cloudflare';
    import { GitHubStrategy } from 'remix-auth-github';
    import { z } from 'zod';
    import type { Env } from '../env';
    
    const UserSchema = z.object({
      username: z.string(),
      displayName: z.string(),
      email: z.string().email().nullable(),
      avatar: z.string().url(),
      githubId: z.string().min(1),
      isSponsor: z.boolean()
    });
    
    const SessionSchema = z.object({
      user: UserSchema.optional(),
      strategy: z.string().optional(),
      'oauth2:state': z.string().uuid().optional(),
      'auth:error': z.object({ message: z.string() }).optional()
    });
    
    export type User = z.infer<typeof UserSchema>;
    
    export type Session = z.infer<typeof SessionSchema>;
    
    export interface IAuthService {
      readonly authenticator: Authenticator<User>;
      readonly sessionStorage: TypedSessionStorage<typeof SessionSchema>;
    }
    
    export class AuthService implements IAuthService {
      #sessionStorage: SessionStorage<typeof SessionSchema>;
      #authenticator: Authenticator<User>;
    
      constructor(env: Env, hostname: string) {
        let sessionStorage = createCookieSessionStorage({
          cookie: {
            name: 'sid',
            httpOnly: true,
            secure: env.CF_PAGES === 'production',
            sameSite: 'lax',
            path: '/',
            secrets: [env.COOKIE_SESSION_SECRET]
          }
        });
    
        this.#sessionStorage = sessionStorage;
        this.#authenticator = new Authenticator<User>(this.#sessionStorage as unknown as SessionStorage, {
          throwOnError: true
        });
    
        let callbackURL = new URL(env.GITHUB_CALLBACK_URL);
        callbackURL.hostname = hostname;
    
        this.#authenticator.use(
          new GitHubStrategy(
            {
              clientID: env.GITHUB_ID,
              clientSecret: env.GITHUB_SECRET,
              callbackURL: callbackURL.toString()
            },
            async ({ profile }) => {
              return {
                displayName: profile._json.name,
                username: profile._json.login,
                email: profile._json.email ?? profile.emails?.at(0) ?? null,
                avatar: profile._json.avatar_url,
                githubId: profile._json.node_id
                // isSponsor: await gh.isSponsoringMe(profile._json.node_id)
              };
            }
          )
        );
      }
    
      get authenticator() {
        return this.#authenticator;
      }
    
      get sessionStorage() {
        return this.#sessionStorage;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    其中还用到了 zod 来定义类型,这个部分是可以忽略不用的。

    如果感兴趣的话,可以访问 Zod 文档学习: https://zod.dev/

    然后找到根目录下的 server.ts 进行改造,主要改造的方法为 getLoadContext 部分,在其中将 services 作为依赖进行注入:

    import { logDevReady } from '@remix-run/cloudflare';
    import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
    import * as build from '@remix-run/dev/server-build';
    import { AuthService } from '~/server/services/auth';
    import { EnvSchema } from './env';
    
    if (process.env.NODE_ENV === 'development') {
      logDevReady(build);
    }
    
    export const onRequest = createPagesFunctionHandler({
      build,
      getLoadContext: (ctx) => {
        const env = EnvSchema.parse(ctx.env);
        const { hostname } = new URL(ctx.request.url);
    
        const auth = new AuthService(env, hostname);
        const services: RemixServer.Services = {
          auth
        };
        return { env, services };
      },
      mode: build.mode
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    登录及回调

    这部分可以参考 remix-auth 的文档: https://github.com/sergiodxa/remix-auth

    // auth.github.ts
    import type { ActionFunction } from '@remix-run/cloudflare';
    
    export const action: ActionFunction = async ({ request, context }) => {
      return await context.services.auth.authenticator.authenticate('github', request, {
        successRedirect: '/private',
        failureRedirect: '/'
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    如果想要用 Get 请求进行登录, action 改为 loader 即可。

    // auth.github.callback.ts
    import type { LoaderFunction } from '@remix-run/cloudflare';
    
    export const loader: LoaderFunction = async ({ request, context }) => {
      return await context.services.auth.authenticator.authenticate('github', request, {
        successRedirect: '/private',
        failureRedirect: '/'
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里通过 context 将服务传递进来,避免反复使用 env 环境变量进行初始化。

    然后写一个路由测试登录结果:

    import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare';
    import { json } from '@remix-run/cloudflare';
    import { Form, useLoaderData } from '@remix-run/react';
    
    export const action: ActionFunction = async ({ request }) => {
      await auth.logout(request, { redirectTo: '/' });
    };
    
    export const loader: LoaderFunction = async ({ request, context }) => {
      const profile = await context.services.auth.authenticator.isAuthenticated(request, {
        failureRedirect: '/'
      });
    
      return json({ profile });
    };
    
    export default function Screen() {
      const { profile } = useLoaderData<typeof loader>();
      return (
        <>
          <Form method='post'>
            <button>Log Out</button>
          </Form>
    
          <hr />
    
          <pre>
            <code>{JSON.stringify(profile, null, 2)}</code>
          </pre>
        </>
      );
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    登出/注销

    可以参考以下代码,新建一个路由实现:

    export async function action({ request }: ActionArgs) {
      await authenticator.logout(request, { redirectTo: "/login" });
    };
    
    • 1
    • 2
    • 3

    TypeScript 类型

    如果需要通过类型提示的话,添加一个 .d.ts 文件,或者在 root.tsx 中添加类型声明:

    import type { Env } from './env';
    import type { IAuthService } from './services/auth';
    
    declare global {
      namespace RemixServer {
        export interface Services {
          auth: IAuthService;
        }
      }
    }
    
    declare module '@remix-run/cloudflare' {
      interface AppLoadContext {
        env: Env;
        DB: D1Database;
        services: RemixServer.Services;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    参考这个, Env 是你自己所需要的环境变量的类型定义。

    完成。完整的示例代码在: https://github.com/willin/remix-cloudflare-pages-demo/tree/c8c350ce954d14cdc68f1f9cd11cecea00600483

    FAQ

    注意:v2 版本之后,不可以使用 remix-utils。存在兼容性问题。

  • 相关阅读:
    C语言基本算法之选择排序
    JS原生复制功能
    【Linux之Shell脚本实战】一键部署LAMP环境
    【数据结构】---几分钟简单几步学会手撕链式二叉树(中)
    MIME(Multipurpose Internet Mail Extensions)类型绕过
    【Rust日报】2022-08-08 基于Rust能力的Linux runtime
    利用背景渐变实现边框样式
    AJAX: 对话框大全
    Java网络编程1
    golang使用beego.orm连接pg数据库出错定位过程
  • 原文地址:https://blog.csdn.net/jslygwx/article/details/133002552