Implement user authentication with email and password, update forms and schemas
All checks were successful
main-branch-frovide/pipeline/head This commit looks good
All checks were successful
main-branch-frovide/pipeline/head This commit looks good
This commit is contained in:
parent
48c956dae4
commit
3a2b4ded8c
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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()}
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
5
src/routes/register/+layout.svelte
Normal file
5
src/routes/register/+layout.svelte
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
67
src/routes/register/+page.server.ts
Normal file
67
src/routes/register/+page.server.ts
Normal 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');
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
10
src/routes/register/+page.svelte
Normal file
10
src/routes/register/+page.svelte
Normal 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>
|
||||||
92
src/routes/register/register-form.svelte
Normal file
92
src/routes/register/register-form.svelte
Normal 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>
|
||||||
25
src/routes/register/schema.ts
Normal file
25
src/routes/register/schema.ts
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user