Implement user authentication with email and password, update forms and schemas
All checks were successful
main-branch-frovide/pipeline/head This commit looks good

This commit is contained in:
pd0a6847 2025-06-13 12:00:21 +09:00
parent 48c956dae4
commit 3a2b4ded8c
12 changed files with 252 additions and 54 deletions

View File

@ -33,7 +33,7 @@ export async function validateSessionToken(token: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username }, user: { id: table.user.id, email: table.user.email },
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)

View File

@ -1,45 +1,45 @@
import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core'; import {pgTable, serial, integer, text, timestamp} from 'drizzle-orm/pg-core';
export const user = pgTable('user', { export const user = pgTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
age: integer('age'), //나이 age: integer('age'), //나이
username: text('username').notNull().unique(), // username 이 사용자가 회원가입 할때 id 임 username: text('username'),
nickname: text('nickname'), // 별칭 nickname: text('nickname'), // 별칭
helloword: text('helloword'), // 인사말, 소개말 helloword: text('helloword'), // 인사말, 소개말
avatar: text('avatar'), // 아바타 파일경로 avatar: text('avatar'), // 아바타 파일경로
email: text('email'), // 이메일주소 email: text('email').notNull().unique(), // 이메일주소 이것을 아이디로 사용하도록 변경하자
gender: text('gender'), // 성별 gender: text('gender'), // 성별
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
createdAt: timestamp(), createdAt: timestamp(),
updatedAt: timestamp(), updatedAt: timestamp(),
}); });
export const session = pgTable('session', { export const session = pgTable('session', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id), userId: text('user_id').notNull().references(() => user.id),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(), expiresAt: timestamp('expires_at', {withTimezone: true, mode: 'date'}).notNull(),
createdAt: timestamp(), createdAt: timestamp(),
updatedAt: timestamp(), updatedAt: timestamp(),
}); });
export const post = pgTable('post', { export const post = pgTable('post', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
subject: text('subject'), subject: text('subject'),
slug: text('subject'), slug: text('subject'),
content: text('subject'), content: text('subject'),
author: text('author'), author: text('author'),
status: text('status'), // draft, published 등등.. status: text('status'), // draft, published 등등..
category: text('category'), category: text('category'),
createdAt: timestamp(), createdAt: timestamp(),
updatedAt: timestamp(), updatedAt: timestamp(),
}); });
export const hierarchy = pgTable('hierarchy', { export const hierarchy = pgTable('hierarchy', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
name: text('subject'), name: text('subject'),
parent_id: text('subject'), parent_id: text('subject'),
createdAt: timestamp(), createdAt: timestamp(),
updatedAt: timestamp(), updatedAt: timestamp(),
}); });
export type Session = typeof session.$inferSelect; export type Session = typeof session.$inferSelect;

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { Toaster } from "$lib/components/ui/sonner/index.js";
let { children } = $props(); let { children } = $props();
</script> </script>
<Toaster />
{@render children()} {@render children()}

View File

@ -30,17 +30,17 @@ export const actions: Actions = {
}); });
} }
const userId = form.data.userId; const email = form.data.email;
const password = form.data.password; const password = form.data.password;
const results = await db const results = await db
.select() .select()
.from(table.user) .from(table.user)
.where(eq(table.user.username, userId)); .where(eq(table.user.email, email));
const existingUser = results.at(0); const existingUser = results.at(0);
if (!existingUser) { if (!existingUser) {
return setError(form, 'userId', '존재하지 않는 아이디 입니다.'); return setError(form, 'email', '등록된 이메일이 아닙니다.');
} }
const validPassword = await verify(existingUser.passwordHash, password, { const validPassword = await verify(existingUser.passwordHash, password, {

View File

@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "../../../.svelte-kit/types/src/routes"; import type {PageData} from "./$types.js";
import LoginForm from "./login-form.svelte"; import LoginForm from "./login-form.svelte";
let { data }: { data: PageData } = $props();
let {data}: { data: PageData } = $props();
</script> </script>
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6"> <div class="flex w-full max-w-sm flex-col gap-6">
<LoginForm {data} /> <LoginForm {data}/>
</div> </div>
</div> </div>

View File

@ -26,7 +26,7 @@
</script> </script>
<div class={cn("flex flex-col gap-6")}> <div class="flex flex-col gap-6">
<Card.Root> <Card.Root>
<Card.Header class="text-center"> <Card.Header class="text-center">
<Card.Title class="text-xl font-semibold">LOGIN</Card.Title> <Card.Title class="text-xl font-semibold">LOGIN</Card.Title>
@ -36,11 +36,11 @@
<div class="grid gap-6"> <div class="grid gap-6">
<div class="grid gap-6"> <div class="grid gap-6">
<div class="grid gap-3"> <div class="grid gap-3">
<Form.Field {form} name="userId"> <Form.Field {form} name="email">
<Form.Control> <Form.Control>
{#snippet children({props})} {#snippet children({props})}
<Form.Label>UserId</Form.Label> <Form.Label>Email</Form.Label>
<Input {...props} bind:value={$formData.userId}/> <Input {...props} bind:value={$formData.email}/>
{/snippet} {/snippet}
</Form.Control> </Form.Control>
<Form.FieldErrors/> <Form.FieldErrors/>
@ -50,7 +50,7 @@
<Form.Field {form} name="password"> <Form.Field {form} name="password">
<Form.Control> <Form.Control>
{#snippet children({props})} {#snippet children({props})}
<Form.Label>password</Form.Label> <Form.Label>Password</Form.Label>
<Input <Input
{...props} {...props}
bind:value={$formData.password} bind:value={$formData.password}
@ -69,7 +69,7 @@
</div> </div>
<div class="text-center text-sm"> <div class="text-center text-sm">
이메일 인증을 통한 이메일 인증을 통한
<a href="##" class="underline underline-offset-4"> 회원가입</a> <a href="/register" class="underline underline-offset-4"> 회원가입</a>
</div> </div>
</div> </div>
</form> </form>

View File

@ -2,10 +2,8 @@ import { z } from "zod";
export const formSchema = z.object({ export const formSchema = z.object({
// User ID (formerly 'user') // User ID (formerly 'user')
userId: z.string() email: z.string()
.min(3, "최소 3자 이상이어야 합니다.") // A common minimum for IDs .email("이메일주소 형식이 아닙니다.")
.max(50, "최대 50자 이하이어야 합니다.") // Reasonable maximum length
.regex(/^[a-zA-Z0-9_.-]+$/, "영문, 숫자, '_', '.', '-'만 포함할 수 있습니다.") // Restrict characters
.trim(), // Remove leading/trailing whitespace .trim(), // Remove leading/trailing whitespace
// Password // Password

View File

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View File

@ -0,0 +1,67 @@
import {hash, verify} from '@node-rs/argon2';
import {encodeBase32LowerCase} from '@oslojs/encoding';
import {redirect} from '@sveltejs/kit';
import {eq} from 'drizzle-orm';
import * as auth from '$lib/server/auth';
import {db} from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import type {Actions, PageServerLoad} from './$types';
import { setError, superValidate , fail} from 'sveltekit-superforms';
import {zod} from "sveltekit-superforms/adapters";
import {formSchema} from "./schema";
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia');
}
return {
form: await superValidate(zod(formSchema)),
};
};
export const actions: Actions = {
login: async (event) => {
const form = await superValidate(event, zod(formSchema));
if (!form.valid) {
return fail(400, {
form,
});
}
const email = form.data.email;
const password = form.data.password;
const results = await db
.select()
.from(table.user)
.where(eq(table.user.email, email));
const existingUser = results.at(0);
if (!existingUser) {
return setError(form, 'email', '등록된 이메일이 아닙니다.');
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
return setError(form, 'password', '비밀번호가 일치하지 않습니다.');
}
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, existingUser.id);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
return redirect(302, '/demo/lucia');
},
};

View File

@ -0,0 +1,10 @@
<script lang="ts">
import type { PageData } from "./$types.js";
import RegisterForm from "./register-form.svelte";
let { data }: { data: PageData } = $props();
</script>
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<RegisterForm {data} />
</div>
</div>

View File

@ -0,0 +1,92 @@
<script lang="ts">
import * as Form from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import * as Card from "@/components/ui/card";
import {formSchema, type FormSchema} from "./schema";
import {
type SuperValidated,
type Infer,
superForm,
} from "sveltekit-superforms";
import {zodClient} from "sveltekit-superforms/adapters";
import { dev } from '$app/environment';
import {Label} from "@/components/ui/label";
import {Switch} from "@/components/ui/switch";
import {cn} from "@/utils.js";
import SuperDebug from 'sveltekit-superforms';
let {data}: { data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
});
const {form: formData, enhance} = form;
</script>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl font-semibold">회원가입</Card.Title>
<Card.Description class="text-sm text-muted-foreground">
Register a new account
</Card.Description>
</Card.Header>
<Card.Content>
<form method="POST" action="?/register" use:enhance>
<div class="grid gap-6">
<div class="grid gap-6">
<div class="grid gap-3">
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({props})}
<Form.Label>이메일</Form.Label>
<Input {...props} bind:value={$formData.email}/>
{/snippet}
</Form.Control>
<Form.FieldErrors/>
</Form.Field>
</div>
<div class="grid gap-3">
<Form.Field {form} name="password">
<Form.Control>
{#snippet children({props})}
<Form.Label>비밀번호</Form.Label>
<Input
{...props}
bind:value={$formData.password}
type="password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors/>
</Form.Field>
<Form.Field {form} name="passwordConfirm">
<Form.Control>
{#snippet children({props})}
<Form.Label>비밀번호 확인</Form.Label>
<Input
{...props}
bind:value={$formData.passwordConfirm}
type="password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors/>
</Form.Field>
</div>
<Form.Button class="w-full">회원가입</Form.Button>
</div>
<div class="text-center text-sm">
<a href="/login" class="underline underline-offset-4"> 로그인</a>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
{#if dev}
<SuperDebug data={formData}/>
{/if}
</div>

View File

@ -0,0 +1,25 @@
import { z } from "zod";
import {integer, text, timestamp} from "drizzle-orm/pg-core";
export const formSchema = z.object({
// User ID (formerly 'user')
email: z.string()
.email("이메일주소 형식이 아닙니다.")
.trim(), // Remove leading/trailing whitespace
// Password
password: z.string()
.min(8, "최소 8자 이상이어야 합니다.")
.max(100, "최대 100자 이하이어야 합니다.") // Reasonable maximum length
.regex(/[a-z]/, "최소 하나의 소문자를 포함해야 합니다.")
.regex(/[A-Z]/, "최소 하나의 대문자를 포함해야 합니다.")
.regex(/[0-9]/, "최소 하나의 숫자를 포함해야 합니다.")
.regex(/[^a-zA-Z0-9]/, "최소 하나의 특수문자를 포함해야 합니다.") // For common special characters
.trim(),
passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
message: "비밀번호가 일치하지 않습니다",
path: ["passwordConfirm"], // passwordConfirm 필드에 에러를 표시
});
export type FormSchema = typeof formSchema;