Fully set up username/password auth
This commit is contained in:
parent
bcdad3442c
commit
aeba55b315
13 changed files with 194 additions and 59 deletions
|
@ -2,6 +2,7 @@
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
|
"semi": false,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -14,6 +14,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deno/kv": "^0.8.1",
|
"@deno/kv": "^0.8.1",
|
||||||
"@olli/kvdex": "npm:@jsr/olli__kvdex",
|
"@olli/kvdex": "npm:@jsr/olli__kvdex",
|
||||||
|
"@oxi/option": "npm:@jsr/oxi__option",
|
||||||
"@oxi/result": "npm:@jsr/oxi__result",
|
"@oxi/result": "npm:@jsr/oxi__result",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
|
10
src/app.d.ts
vendored
10
src/app.d.ts
vendored
|
@ -1,5 +1,9 @@
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
|
|
||||||
|
import type { FlatDocumentData } from "@olli/kvdex"
|
||||||
|
import { user, session } from "$lib/db"
|
||||||
|
import z from "zod"
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
|
@ -7,7 +11,11 @@ declare global {
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
interface Locals {
|
||||||
|
user: FlatDocumentData<z.infer<typeof user>, string> | null;
|
||||||
|
session: FlatDocumentData<z.infer<typeof session>, string> | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export { };
|
||||||
|
|
33
src/hooks.server.ts
Normal file
33
src/hooks.server.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { cookieController, cookieExpiration, getUserAndSession } from "$lib/auth";
|
||||||
|
import type { Handle } from "@sveltejs/kit";
|
||||||
|
import { createDate } from "oslo";
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const sessionId = event.cookies.get("auth_session");
|
||||||
|
if (!sessionId) {
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.session = null;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getUserAndSession(sessionId);
|
||||||
|
if(res.isNone()) {
|
||||||
|
const sessionCookie = cookieController.createBlankCookie();
|
||||||
|
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: ".",
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.session = null;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
const { session, user } = res.unwrap()
|
||||||
|
const sessionCookie = cookieController.createCookie(session.id)
|
||||||
|
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: ".",
|
||||||
|
...sessionCookie.attributes
|
||||||
|
})
|
||||||
|
event.locals.user = user;
|
||||||
|
event.locals.session = session;
|
||||||
|
return resolve(event);
|
||||||
|
};
|
|
@ -1,30 +1,60 @@
|
||||||
import type { z } from "zod"
|
import type { z } from 'zod'
|
||||||
import { db, session } from "./db"
|
import { db, session } from './db'
|
||||||
import { Err, Ok, Result } from "@oxi/result"
|
import { Err, Ok, Result } from '@oxi/result'
|
||||||
import type { FlatDocumentData } from "@olli/kvdex"
|
import { Option, None, Some } from '@oxi/option'
|
||||||
import { nanoid } from "nanoid"
|
import type { FlatDocumentData } from '@olli/kvdex'
|
||||||
import { TimeSpan, createDate } from "oslo"
|
import { nanoid } from 'nanoid'
|
||||||
import { alphabet, generateRandomString } from "oslo/crypto"
|
import { TimeSpan, createDate } from 'oslo'
|
||||||
|
import { alphabet, generateRandomString } from 'oslo/crypto'
|
||||||
|
import { CookieController } from "oslo/cookie"
|
||||||
|
|
||||||
const sessionTimeSpan = new TimeSpan(1, 'w')
|
const sessionTimeSpan = new TimeSpan(1, 'w')
|
||||||
|
|
||||||
async function createSessionForUser(userId: string): Promise<Result<FlatDocumentData<z.infer<typeof session>, string>, null>> {
|
async function createSessionForUser(
|
||||||
const user = (await db.user.find(userId))?.flat()
|
userId: string
|
||||||
if(!user) return Err(null)
|
): Promise<Option<FlatDocumentData<z.infer<typeof session>, string>>> {
|
||||||
const sessionId = generateRandomString(10, alphabet("0-9", "a-z"))
|
const user = (await db.user.find(userId))?.flat()
|
||||||
const createdSession = (await db.session.set(sessionId, {
|
if (!user) return None
|
||||||
expiresAt: createDate(sessionTimeSpan),
|
const sessionId = generateRandomString(21, alphabet('0-9', 'a-z'))
|
||||||
userId
|
const createdSession = await db.session.set(
|
||||||
}, { expireIn: sessionTimeSpan.milliseconds() }))
|
sessionId,
|
||||||
if(!createdSession.ok) return Err(null)
|
{
|
||||||
return Ok((await db.session.find(sessionId))?.flat()!)
|
expiresAt: createDate(sessionTimeSpan),
|
||||||
|
userId
|
||||||
|
},
|
||||||
|
{ expireIn: sessionTimeSpan.milliseconds() }
|
||||||
|
)
|
||||||
|
if (!createdSession.ok) return None
|
||||||
|
return Some((await db.session.find(sessionId))?.flat()!)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession(sessionId: string): Promise<void> {
|
async function deleteSession(sessionId: string): Promise<void> {
|
||||||
await db.session.delete(sessionId)
|
await db.session.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
async function getUserAndSession(
|
||||||
createSessionForUser,
|
sessionId: string
|
||||||
deleteSession
|
): Promise<
|
||||||
|
Option<{
|
||||||
|
user: FlatDocumentData<z.infer<(typeof import('$lib/db'))['user']>, string>
|
||||||
|
session: FlatDocumentData<z.infer<(typeof import('$lib/db'))['session']>, string>
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const session = (await db.session.find(sessionId))?.flat()
|
||||||
|
if (!session) return None
|
||||||
|
const user = (await db.user.find(session.userId))?.flat()
|
||||||
|
if (!user) return None
|
||||||
|
await db.session.update(sessionId, {
|
||||||
|
expiresAt: createDate(sessionTimeSpan)
|
||||||
|
}, { expireIn: sessionTimeSpan.milliseconds() })
|
||||||
|
return Some({ user, session })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cookieExpiration = new TimeSpan(365 * 2, 'd')
|
||||||
|
export const cookieController = new CookieController('auth_session', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: ".",
|
||||||
|
}, { expiresIn: cookieExpiration })
|
||||||
|
export { createSessionForUser, deleteSession, getUserAndSession }
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
<script></script>
|
|
5
src/routes/+page.ts
Normal file
5
src/routes/+page.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
return redirect(302, '/app')
|
||||||
|
}
|
7
src/routes/app/+page.server.ts
Normal file
7
src/routes/app/+page.server.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load({ locals }) {
|
||||||
|
if(!locals.session) {
|
||||||
|
return redirect(302, "/auth")
|
||||||
|
}
|
||||||
|
}
|
3
src/routes/app/+page.svelte
Normal file
3
src/routes/app/+page.svelte
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<script>
|
||||||
|
|
||||||
|
</script>
|
|
@ -2,32 +2,50 @@ import { alphabet, generateRandomString } from 'oslo/crypto';
|
||||||
import { fail, message, setError, superValidate } from 'sveltekit-superforms';
|
import { fail, message, setError, superValidate } from 'sveltekit-superforms';
|
||||||
import { zod } from 'sveltekit-superforms/adapters';
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { hash } from "@ts-rex/argon2"
|
import { hash, verify } from "@ts-rex/argon2"
|
||||||
import { db } from '$lib/db.js';
|
import { db } from '$lib/db.js';
|
||||||
|
import { cookieController, cookieExpiration, createSessionForUser } from '$lib/auth.js';
|
||||||
|
import { createDate } from 'oslo';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().min(4, "must be atleast 4 characters").max(32, "must be less than 32 characters").regex(/^[a-z0-9_\-]+$/i, `must be alphanumeric, with the exception of "_" and "-"`),
|
username: z.string().min(4, "must be atleast 4 characters").max(32, "must be less than 32 characters").regex(/^[a-z0-9_\-]+$/i, `must be alphanumeric, with the exception of "_" and "-"`),
|
||||||
password: z.string().min(8, "must be atleast 8 characters").max(255)
|
password: z.string().min(8, "must be atleast 8 characters").max(255)
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function load() {
|
export async function load({ locals }) {
|
||||||
|
if(locals.session) {
|
||||||
|
return redirect(302, '/app')
|
||||||
|
}
|
||||||
const form = await superValidate(zod(schema));
|
const form = await superValidate(zod(schema));
|
||||||
return { form };
|
return { form };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
login: async ({ request }) => {
|
login: async ({ request, cookies }) => {
|
||||||
const form = await superValidate(request, zod(schema));
|
const form = await superValidate(request, zod(schema));
|
||||||
|
|
||||||
if (!form.valid) return fail(400, { form });
|
if (!form.valid) return fail(400, { form });
|
||||||
const { username, password } = form.data
|
const { username, password } = form.data
|
||||||
|
const user = (await db.user.findByPrimaryIndex('username', username))?.flat();
|
||||||
// TODO: Login user
|
if (!user) return setError(form, "user does not exist")
|
||||||
return message(form, 'Login form submitted');
|
if (!user.password) return setError(form, "this account does not have a password, maybe try a different method?")
|
||||||
|
const isvalid = verify(password, user.password);
|
||||||
|
if (!isvalid) return setError(form, "incorrect password")
|
||||||
|
const session = (await createSessionForUser(user.id))
|
||||||
|
if (session.isSome()) {
|
||||||
|
const sessionCookie = cookieController.createCookie(session.unwrap().id)
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: ".",
|
||||||
|
...sessionCookie.attributes
|
||||||
|
})
|
||||||
|
return redirect(302, '/app')
|
||||||
|
} else {
|
||||||
|
return fail(500, { form })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
signup: async ({ request }) => {
|
signup: async ({ request, cookies }) => {
|
||||||
const form = await superValidate(request, zod(schema));
|
const form = await superValidate(request, zod(schema));
|
||||||
console.log(form)
|
|
||||||
if (!form.valid) return fail(400, { form });
|
if (!form.valid) return fail(400, { form });
|
||||||
const { username, password } = form.data
|
const { username, password } = form.data
|
||||||
|
|
||||||
|
@ -35,15 +53,23 @@ export const actions = {
|
||||||
const passwordHash = hash(password)
|
const passwordHash = hash(password)
|
||||||
|
|
||||||
const user = (await db.user.findByPrimaryIndex('username', username))?.flat()
|
const user = (await db.user.findByPrimaryIndex('username', username))?.flat()
|
||||||
if(user) return setError(form, 'Username already exists')
|
if (user) return setError(form, "username", 'username already exists')
|
||||||
console.log(userId, password, passwordHash);
|
await db.user.set(userId, {
|
||||||
console.log(await db.user.set(userId, {
|
|
||||||
displayName: username,
|
displayName: username,
|
||||||
username,
|
username,
|
||||||
id: userId,
|
id: userId,
|
||||||
password: passwordHash
|
password: passwordHash
|
||||||
}))
|
})
|
||||||
|
const session = (await createSessionForUser(userId))
|
||||||
return message(form, 'Signup form submitted');
|
if (session.isSome()) {
|
||||||
|
const sessionCookie = cookieController.createCookie(session.unwrap().id)
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: ".",
|
||||||
|
...sessionCookie.attributes
|
||||||
|
})
|
||||||
|
return redirect(302, '/app')
|
||||||
|
} else {
|
||||||
|
return fail(500, { form })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,18 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { superForm } from 'sveltekit-superforms';
|
import { superForm } from 'sveltekit-superforms'
|
||||||
import { page } from "$app/stores"
|
import { page } from '$app/stores'
|
||||||
const { data } = $props();
|
const { data } = $props()
|
||||||
const { enhance, message, constraints, errors, form } = superForm(data.form);
|
const { enhance, message, errors, form, allErrors } = superForm(data.form)
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
let username = $state('')
|
let username = $state('')
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
form.update(f => {
|
form.update((f) => {
|
||||||
f.username = username
|
f.username = username
|
||||||
f.password = password
|
f.password = password
|
||||||
console.log(f)
|
console.log(f)
|
||||||
return f
|
return f
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-[100vh] flex items-center justify-center">
|
<div class="h-[100vh] flex items-center justify-center">
|
||||||
|
@ -54,19 +54,24 @@
|
||||||
></path
|
></path
|
||||||
></svg>
|
></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="{$page.url.pathname}/oauth/github" class="btn grow btn-outline join-item">
|
<a href="{$page.url.pathname}/oauth/github" class="btn grow btn-outline join-item group">
|
||||||
<svg class="size-5" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
|
class="size-5 text-[#24292f] dark:text-[#fff] group-hover:text-[#fff] dark:group-hover:text-[#24292f] transition-colors"
|
||||||
|
viewBox="0 0 98 96"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
><path
|
><path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
|
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
|
||||||
fill="#24292f"></path
|
fill="currentColor"></path
|
||||||
></svg>
|
></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="divider">or</span>
|
<span class="divider">or</span>
|
||||||
<form class="flex-col flex gap-y-4" method="post" use:enhance>
|
<form class="flex-col flex gap-y-4" method="post" use:enhance>
|
||||||
<label class="input input-bordered flex items-center gap-2" class:input-error={!!$errors.username}>
|
<label
|
||||||
|
class="input input-bordered flex items-center gap-2"
|
||||||
|
class:input-error={!!$errors.username}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
|
@ -81,7 +86,7 @@
|
||||||
<input
|
<input
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
aria-invalid={$errors.username ? 'true' : undefined}
|
aria-invalid={$errors.username ? 'true' : undefined}
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
class="grow placeholder:text-base-content/20"
|
class="grow placeholder:text-base-content/20"
|
||||||
placeholder="kaii" />
|
placeholder="kaii" />
|
||||||
|
@ -89,7 +94,9 @@
|
||||||
<span
|
<span
|
||||||
class="opacity-0 hidden transition-opacity duration-1000 text-error"
|
class="opacity-0 hidden transition-opacity duration-1000 text-error"
|
||||||
class:showerror={$errors.username}>{$errors.username?.join(' & ')}</span>
|
class:showerror={$errors.username}>{$errors.username?.join(' & ')}</span>
|
||||||
<label class="input input-bordered flex items-center gap-2" class:input-error={!!$errors.password}>
|
<label
|
||||||
|
class="input input-bordered flex items-center gap-2"
|
||||||
|
class:input-error={!!$errors.password}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
|
@ -103,7 +110,7 @@
|
||||||
<input
|
<input
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
aria-invalid={$errors.password ? 'true' : undefined}
|
aria-invalid={$errors.password ? 'true' : undefined}
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
class="grow placeholder:text-base-content/20"
|
class="grow placeholder:text-base-content/20"
|
||||||
placeholder="verygoodpassword" />
|
placeholder="verygoodpassword" />
|
||||||
|
|
15
src/routes/auth/logout/+server.ts
Normal file
15
src/routes/auth/logout/+server.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { cookieController, deleteSession } from '$lib/auth';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET({ locals, cookies }) {
|
||||||
|
console.log(locals)
|
||||||
|
if(locals.session) {
|
||||||
|
await deleteSession(locals.session.id)
|
||||||
|
const sessionCookie = cookieController.createBlankCookie();
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: ".",
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return redirect(302, '/')
|
||||||
|
}
|
Loading…
Reference in a new issue