리믹스 및 Supabase 인증

행 수준 보안을 사용하여 Remix 및 Supabase 애플리케이션을 보호하는 방법



목차


  • TL;DR Source and Demo
  • Introduction
  • Setting up Supabase
  • Server-side utilities
  • Client-side utilities
  • Create sign-up and sign-in page
  • Create a sign-out action
  • TL;DR version of using the setup
  • Fetch All example
  • Get one and Delete one example
  • Create one example
  • Update one example
  • Conclusion


  • TL;DR: 소스 및 데모

    Here's a live demo

    Link to the source code

    Link to step by step commits



    소개

    This blog will focus on securing our Remix application with Supabase's Row Level Security (RLS) feature .
    내가 말하는 응용 프로그램의 컨텍스트를 알고 싶다면 내 .

    수파베이스 설정

    Instead of updating my database from the previous blog, I'm just going to re-create it.

    user_id를 포함하는 테이블 생성



    CREATE TABLE words (
      id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
      name varchar NOT NULL,
      definitions varchar ARRAY NOT NULL,
      sentences varchar ARRAY NOT NULL,
      type varchar NOT NULL,
      user_id uuid NOT NULL
    );
    


    auth.users를 가리키는 user_id에 외래 키 추가



    alter table words
    add constraint words_users_fk
    foreign key (user_id)
    references auth.users (id);
    


    행 수준 보안 Supabase 정책 생성



    CREATE POLICY "anon_select" ON public.words FOR SELECT USING (
      auth.role() = 'anon' or auth.role() = 'authenticated'
    );
    
    CREATE POLICY "auth_insert" ON public.words FOR INSERT WITH CHECK (
      auth.role() = 'authenticated'
    );
    
    CREATE POLICY "user_based__update" ON public.words FOR UPDATE USING (
      auth.uid() = user_id
    );
    
    CREATE POLICY "user_based_delete" ON public.words FOR DELETE USING (
      auth.uid() = user_id
    );
    


    Supabase 세션을 관리하기 위한 서버 측 유틸리티 구현

    Supabase 클라이언트의 서버 인스턴스 생성



    // app/utils/supabase.server.ts
    import { createClient } from "@supabase/supabase-js";
    
    const supabaseUrl = process.env.SUPABASE_URL as string;
    const supabaseKey = process.env.SUPABASE_ANON_KEY as string;
    
    export const supabase = createClient(supabaseUrl, supabaseKey);
    


    createCookieSessionStorage를 사용하여 Supabase 토큰 관리를 돕습니다.



    // app/utils/supabase.server.ts
    // ...
    import { createCookieSessionStorage } from "remix";
    
    // ...
    
    const { getSession, commitSession, destroySession } =
      createCookieSessionStorage({
        // a Cookie from `createCookie` or the CookieOptions to create one
        cookie: {
          name: "supabase-session",
    
          // all of these are optional
          expires: new Date(Date.now() + 3600),
          httpOnly: true,
          maxAge: 60,
          path: "/",
          sameSite: "lax",
          secrets: ["s3cret1"],
          secure: true,
        },
      });
    
    export { getSession, commitSession, destroySession };
    


    요청에서 Supabase 토큰을 설정하는 유틸리티 생성



    // app/utils/supabase.server.ts
    // ...
    
    export const setAuthToken = async (request: Request) => {
      let session = await getSession(request.headers.get("Cookie"));
    
      supabase.auth.setAuth(session.get("access_token"));
    
      return session;
    };
    


    Remix 측에서 인증 설정


    Supabase 세션 관리를 위한 클라이언트 측 유틸리티 생성

    Supabase Provider 및 Supabase 인스턴스를 반환하는 사용자 지정 후크 생성



    // app/utils/supabase-client.tsx
    import { SupabaseClient } from "@supabase/supabase-js";
    import React from "react";
    
    export const SupabaseContext = React.createContext<SupabaseClient>(
      null as unknown as SupabaseClient
    );
    
    export const SupabaseProvider: React.FC<{ supabase: SupabaseClient }> = ({
      children,
      supabase,
    }) => (
      <SupabaseContext.Provider value={supabase}>
        {children}
      </SupabaseContext.Provider>
    );
    
    export const useSupabase = () => React.useContext(SupabaseContext);
    


    클라이언트에 Supabase 환경 변수 전달



    // app/root.tsx
    export const loader = () => {
      return {
        supabaseKey: process.env.SUPABASE_ANON_KEY,
        supabaseUrl: process.env.SUPABASE_URL,
      };
    };
    


    Supabase 인스턴스를 생성하여 루트 수준 Supabase 공급자에 전달합니다.



    // app/root.tsx
    import { createClient } from "@supabase/supabase-js";
    import { SupabaseProvider } from "./utils/supabase-client";
    
    // export const loader...
    
    export default function App() {
      const loader = useLoaderData();
    
      const supabase = createClient(loader.supabaseUrl, loader.supabaseKey);
    
      return (
        <Document>
          <SupabaseProvider supabase={supabase}>
            <Layout>
              <Outlet />
            </Layout>
          </SupabaseProvider>
        </Document>
      );
    }
    


    /auth 경로 생성

    Since I'm too lazy to implement a login page, I'll just use the UI provided by Supabase.

    @supabase/ui 설치



    npm install @supabase/ui
    
    yarn add @supabase/ui
    


    기본 인증 구성 요소 만들기



    You can create your custom sign-up and sign-in form if you want.



    // app/routes/auth.tsx
    import React from "react";
    import { Auth } from "@supabase/ui";
    import { useSupabase } from "~/utils/supabase-client";
    
    export default function AuthBasic() {
      const supabase = useSupabase();
    
      return (
        <Auth.UserContextProvider supabaseClient={supabase}>
          <Container> {/* TODO */}
            <Auth supabaseClient={supabase} />
          </Container>
        </Auth.UserContextProvider>
      );
    }
    



    Supabase 세션이 있음을 서버에 알리는 구성 요소를 만듭니다.




    // app/routes/auth.tsx
    import React, { useEffect } from "react";
    import { useSubmit } from "remix";
    
    const Container: React.FC = ({ children }) => {
      const { user, session } = Auth.useUser();
      const submit = useSubmit();
    
      useEffect(() => {
        if (user) {
          const formData = new FormData();
    
          const accessToken = session?.access_token;
    
          // you can choose whatever conditions you want
          // as long as it checks if the user is signed in
          if (accessToken) {
            formData.append("access_token", accessToken);
            submit(formData, { method: "post", action: "/auth" });
          }
        }
      }, [user]);
    
      return <>{children}</>;
    };
    
    // ...
    



    Supabase 토큰을 처리하는 작업 처리기 만들기




    // app/routes/auth.tsx
    import { Auth } from "@supabase/ui";
    import { useSubmit, redirect } from "remix";
    import type { ActionFunction } from "remix";
    import React from "react";
    import { useSupabase } from "~/utils/supabase-client";
    import { commitSession, getSession } from "~/utils/supabase.server";
    
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
    
      const session = await getSession(request.headers.get("Cookie"));
    
      session.set("access_token", formData.get("access_token"));
    
      return redirect("/words", {
        headers: {
          "Set-Cookie": await commitSession(session),
        },
      });
    };
    
    // ...
    


    로그인 후 사용자는 /words 경로로 리디렉션됩니다.

    If you want to test without signing up, use the following credentials:

    email: [email protected]

    password: testing



    로그아웃

    헤더에 로그아웃 버튼 생성




    // app/root.tsx
    import { {/*...*/}, useSubmit } from "remix";
    import { {/*...*/}, useSupabase } from "./utils/supabase-client";
    import { Button } from "./components/basic/button";
    
    function Layout({ children }: React.PropsWithChildren<{}>) {
      const submit = useSubmit();
      const supabase = useSupabase();
    
      const handleSignOut = () => {
        supabase.auth.signOut().then(() => {
          submit(null, { method: "post", action: "/signout" });
        });
      };
    
      return (
        <main>
          <header>
            {supabase.auth.session() && (
              <Button type="button" onClick={handleSignOut}>
                Sign out
              </Button>
            )}
          </header>
          {children}
        </main>
      );
    }
    



    작업 처리기 만들기



    내 다른 경로를 오염시키고 싶지 않으므로 내 사인아웃 액션 핸들러를 별도로 생성하겠습니다.

    // app/routes/signout.tsx
    import { destroySession, getSession } from "../utils/supabase.server";
    import { redirect } from "remix";
    import type { ActionFunction } from "remix";
    
    export const action: ActionFunction = async ({ request }) => {
      let session = await getSession(request.headers.get("Cookie"));
    
      return redirect("/auth", {
        headers: {
          "Set-Cookie": await destroySession(session),
        },
      });
    };
    
    export const loader = () => {
      // Redirect to `/` if user tried to access `/signout`
      return redirect("/");
    };
    



    우리의 설정을 사용하는 TL;DR 버전

    로더 또는 작업에서 사용




    export const action = async ({ request, params }) => {
      // Just set the token to any part you want to have access to.
      // I haven't tried making a global handler for this,
      // but I prefer to be explicit about setting this.
      await setAuthToken(request);
    
      await supabase.from("words").update(/*...*/);
      // ...
    };
    



    인증 상태를 기반으로 한 조건부 렌더링




    export default function Index() {
      const supabase = useSupabase();
    
      return supabase.auth.user()
        ? <div>Hello world</div>
        : <div>Please sign in</div>;
    }
    



    NOTE: Conditional server-side rendering might cause hydration warning,

    I'll fix this in another blog post.



    CRUD 작업에서 사용



    아래 예는 CRUD 작업에 대한 설정을 사용하는 더 긴 버전입니다.

    모든 작업 가져오기

    // app/routes/words
    import { Form, useTransition } from "remix";
    import type { LoaderFunction } from "remix";
    import { useLoaderData, Link, Outlet } from "remix";
    import { Button } from "~/components/basic/button";
    import { supabase } from "~/utils/supabase.server";
    import type { Word } from "~/models/word";
    import { useSupabase } from "~/utils/supabase-client";
    
    export const loader: LoaderFunction = async () => {
      // No need to add auth here, because GET /words is public
      const { data: words } = await supabase
        .from<Word>("words")
        .select("id,name,type");
    
      // We can pick and choose what we want to display
      // This can solve the issue of over-fetching or under-fetching
      return words;
    };
    
    export default function Index() {
      const words = useLoaderData<Word[]>();
      const transition = useTransition();
      const supabase = useSupabase();
    
      return (
        <main className="p-2">
          <h1 className="text-3xl text-center mb-3">English words I learned</h1>
          <div className="text-center mb-2">Route State: {transition.state}</div>
          <div className="grid grid-cols-1 md:grid-cols-2 ">
            <div className="flex flex-col items-center">
              <h2 className="text-2xl pb-2">Words</h2>
              <ul>
                {words.map((word) => (
                  <li key={word.id}>
                    <Link to={`/words/${word.id}`}>
                      {word.name} | {word.type}
                    </Link>
                  </li>
                ))}
              </ul>
              {/* Adding conditional rendering might cause a warning,
              We'll deal with it later */}
              {supabase.auth.user() ? (
                <Form method="get" action={"/words/add"} className="pt-2">
                  <Button
                    type="submit"
                    className="hover:bg-primary-100 dark:hover:bg-primary-900"
                  >
                    Add new word
                  </Button>
                </Form>
              ) : (
                <Form method="get" action={`/auth`} className="flex">
                  <Button type="submit" color="primary" className="w-full">
                    Sign-in to make changes
                  </Button>
                </Form>
              )}
            </div>
            <Outlet />
          </div>
        </main>
      );
    }
    

    하나 검색 및 하나 삭제 작업

    // app/routes/words/$id
    import { Form, useLoaderData, redirect, useTransition } from "remix";
    import type { LoaderFunction, ActionFunction } from "remix";
    import type { Word } from "~/models/word";
    import { Input } from "~/components/basic/input";
    import { Button } from "~/components/basic/button";
    import { setAuthToken, supabase } from "~/utils/supabase.server";
    import { useSupabase } from "~/utils/supabase-client";
    
    // Here's how to delete one entry
    export const action: ActionFunction = async ({ request, params }) => {
      const formData = await request.formData();
    
      // Auth Related Code
      await setAuthToken(request);
    
      if (formData.get("_method") === "delete") {
        await supabase
          .from<Word>("words")
          .delete()
          .eq("id", params.id as string);
    
        return redirect("/words");
      }
    };
    
    // Here's the how to fetch one entry
    export const loader: LoaderFunction = async ({ params }) => {
      // No need to add auth here, because GET /words is public
      const { data } = await supabase
        .from<Word>("words")
        .select("*")
        .eq("id", params.id as string)
        .single();
    
      return data;
    };
    
    export default function Word() {
      const word = useLoaderData<Word>();
      const supabase = useSupabase();
      let transition = useTransition();
    
      return (
        <div>
          <h3>
            {word.name} | {word.type}
          </h3>
          <div>Form State: {transition.state}</div>
          {word.definitions.map((definition, i) => (
            <p key={i}>
              <i>{definition}</i>
            </p>
          ))}
          {word.sentences.map((sentence, i) => (
            <p key={i}>{sentence}</p>
          ))}
    
          {/* Adding conditional rendering might cause a warning,
          We'll deal with it later */}
          {supabase.auth.user() && (
            <>
              <Form method="post">
                <Input type="hidden" name="_method" value="delete" />
                <Button type="submit" className="w-full">
                  Delete
                </Button>
              </Form>
              <Form method="get" action={`/words/edit/${word.id}`} className="flex">
                <Button type="submit" color="primary" className="w-full">
                  Edit
                </Button>
              </Form>
            </>
          )}
        </div>
      );
    }
    

    작업 생성

    // app/routes/words/add
    import { redirect } from "remix";
    import type { ActionFunction } from "remix";
    import { setAuthToken, supabase } from "~/utils/supabase.server";
    import { WordForm } from "~/components/word-form";
    
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
    
      // Auth Related Code
      const session = await setAuthToken(request);
    
      const newWord = {
        name: formData.get("name"),
        type: formData.get("type"),
        sentences: formData.getAll("sentence"),
        definitions: formData.getAll("definition"),
        user_id: session.get("uuid"),
      };
    
      const { data, error } = await supabase
        .from("words")
        .insert([newWord])
        .single();
    
      if (error) {
        return redirect(`/words`);
      }
    
      return redirect(`/words/${data?.id}`);
    };
    
    export default function AddWord() {
      return <WordForm />;
    }
    

    업데이트 작업

    // app/routes/words/edit/$id
    import { useLoaderData, redirect } from "remix";
    import type { LoaderFunction, ActionFunction } from "remix";
    import { WordForm } from "~/components/word-form";
    import type { Word } from "~/models/word";
    import { setAuthToken, supabase } from "~/utils/supabase.server";
    
    export const action: ActionFunction = async ({ request, params }) => {
      const formData = await request.formData();
      const id = params.id as string;
    
      const updates = {
        type: formData.get("type"),
        sentences: formData.getAll("sentence"),
        definitions: formData.getAll("definition"),
      };
    
      // Auth Related Code
      await setAuthToken(request);
    
      await supabase.from("words").update(updates).eq("id", id);
    
      return redirect(`/words/${id}`);
    };
    
    export const loader: LoaderFunction = async ({ params }) => {
      const { data } = await supabase
        .from<Word>("words")
        .select("*")
        .eq("id", params.id as string)
        .single();
    
      return data;
    };
    
    export default function EditWord() {
      const data = useLoaderData<Word>();
    
      return <WordForm word={data} />;
    }
    

    결론

    We can still use Supabase only on the client-side as we use it on a typical React application. However, putting the data fetching on the server-side will allow us to benefit from a typical SSR application

    좋은 웹페이지 즐겨찾기