Fully set up username/password auth

This commit is contained in:
Chad Freeman 2024-08-17 14:07:09 -04:00
parent bcdad3442c
commit aeba55b315
13 changed files with 194 additions and 59 deletions

View file

@ -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

Binary file not shown.

View file

@ -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
View file

@ -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
View 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);
};

View file

@ -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 }

View file

@ -1 +0,0 @@
<script></script>

5
src/routes/+page.ts Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export async function load() {
return redirect(302, '/app')
}

View file

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
export async function load({ locals }) {
if(!locals.session) {
return redirect(302, "/auth")
}
}

View file

@ -0,0 +1,3 @@
<script>
</script>

View file

@ -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 })
}
} }
} }

View file

@ -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" />

View 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, '/')
}