리믹스 및 Supabase 인증
행 수준 보안을 사용하여 Remix 및 Supabase 애플리케이션을 보호하는 방법
목차
TL;DR: 소스 및 데모
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 applicationReference
이 문제에 관하여(리믹스 및 Supabase 인증), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/codegino/remix-and-supabase-authentication-205i텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)