Basic auth flow (no signout)

This commit is contained in:
Roman Godmaire 2023-11-19 08:15:37 -05:00
parent 1b54c2dff8
commit fb825a0d16
27 changed files with 1823 additions and 115 deletions

View file

@ -1,5 +1,5 @@
{
"useTabs": true,
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,

View file

@ -22,6 +22,7 @@
"eslint-plugin-svelte": "^2.30.0",
"prettier": "^3.0.0",
"prettier-plugin-svelte": "^3.0.0",
"prisma": "^5.6.0",
"svelte": "^4.0.5",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
@ -29,5 +30,12 @@
"vite": "^4.4.2",
"vitest": "^0.34.0"
},
"type": "module"
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.454.0",
"@lucia-auth/adapter-prisma": "^3.0.2",
"@picocss/pico": "^1.5.10",
"@prisma/client": "5.6.0",
"lucia": "^2.7.4"
}
}

File diff suppressed because it is too large Load diff

BIN
prisma/dev.db Normal file

Binary file not shown.

62
prisma/schema.prisma Normal file
View file

@ -0,0 +1,62 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @unique
username String @unique
tracks Track[]
comments Comment[]
auth_session Session[]
key Key[]
}
// Auth Stuff
model Session {
id String @id @unique
user_id String
active_expires BigInt
idle_expires BigInt
user User @relation(references: [id], fields: [user_id], onDelete: Cascade)
@@index([user_id])
}
model Key {
id String @id @unique
hashed_password String?
user_id String
user User @relation(references: [id], fields: [user_id], onDelete: Cascade)
@@index([user_id])
}
// Application stuff
model Track {
id Int @id @default(autoincrement())
title String
versions TrackVersion[]
producer User @relation(fields: [producerId], references: [id], onDelete: Cascade)
producerId String
}
model TrackVersion {
id Int @id @default(autoincrement())
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
trackId Int
comments Comment[]
}
model Comment {
id Int @id @default(autoincrement())
author User @relation(fields: [authorId], references: [id])
authorId String
trackVersion TrackVersion @relation(fields: [trackVersionId], references: [id], onDelete: Cascade)
trackVersionId Int
}

37
src/app.css Normal file
View file

@ -0,0 +1,37 @@
/* Teal Light scheme (Default) */
/* Can be forced with data-theme="light" */
[data-theme='light'],
:root:not([data-theme='dark']) {
--primary: #00897b;
--primary-hover: #00796b;
--primary-focus: rgba(0, 137, 123, 0.125);
--primary-inverse: #fff;
}
/* Teal Dark scheme (Auto) */
/* Automatically enabled if user has Dark mode enabled */
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--primary: #00897b;
--primary-hover: #009688;
--primary-focus: rgba(0, 137, 123, 0.25);
--primary-inverse: #fff;
}
}
/* Teal Dark scheme (Forced) */
/* Enabled if forced with data-theme="dark" */
[data-theme='dark'] {
--primary: #00897b;
--primary-hover: #009688;
--primary-focus: rgba(0, 137, 123, 0.25);
--primary-inverse: #fff;
}
/* Teal (Common styles) */
:root {
--form-element-active-border-color: var(--primary);
--form-element-focus-color: var(--primary-focus);
--switch-color: var(--primary-inverse);
--switch-checked-background-color: var(--primary);
}

20
src/app.d.ts vendored
View file

@ -1,12 +1,30 @@
// See https://kit.svelte.dev/docs/types#app
import type { PrismaClient } from '@prisma/client';
import type { Auth, AuthRequest } from 'lucia';
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
database: PrismaClient;
auth: Auth;
objectStorage: ObjectStorage;
authReq: AuthRequest;
}
// interface PageData {}
// interface Platform {}
}
namespace Lucia {
type Auth = import('$lib/server/lucia').Auth;
type DatabaseUserAttributes = {
username: string;
};
type DatabaseSessionAttributes = {};
}
}
export {};

17
src/hooks.server.ts Normal file
View file

@ -0,0 +1,17 @@
import type { Handle } from '@sveltejs/kit';
import { S3_STORAGE_URL } from '$env/static/private';
import { auth } from '$lib/server/lucia';
import { ObjectStorageS3 } from '$lib/server/storage/s3';
const s3Client = new ObjectStorageS3(S3_STORAGE_URL);
export const handle: Handle = async ({ event, resolve }) => {
event.locals.auth = auth;
event.locals.objectStorage = s3Client;
event.locals.authReq = auth.handleRequest(event);
return await resolve(event);
};

19
src/lib/server/lucia.ts Normal file
View file

@ -0,0 +1,19 @@
import { lucia } from 'lucia';
import { sveltekit } from 'lucia/middleware';
import { dev } from '$app/environment';
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';
const client = new PrismaClient();
export const auth = lucia({
env: dev ? 'DEV' : 'PROD',
middleware: sveltekit(),
adapter: prisma(client),
getUserAttributes: (data) => {
return {
username: data.username
};
}
});

20
src/lib/server/s3.ts Normal file
View file

@ -0,0 +1,20 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
const client = new S3Client();
const uploadObject = async (obj: Buffer) => {
const command = new PutObjectCommand({
Bucket: 'test-bucket',
Key: 'hello-s3.txt',
Body: obj
});
try {
await client.send(command);
return null;
} catch (err) {
return err;
}
};
export { uploadObject };

View file

@ -0,0 +1,3 @@
interface ObjectStorage {
putObject: (obj: Buffer) => Promise<Error | null>;
}

View file

@ -0,0 +1,29 @@
import { PutObjectCommand, S3Client, S3ServiceException } from '@aws-sdk/client-s3';
class ObjectStorageS3 implements ObjectStorage {
client: S3Client;
constructor(url: string) {
this.client = new S3Client({
endpoint: url
});
}
putObject = async (obj: Buffer) => {
const command = new PutObjectCommand({
Bucket: 'test-bucket',
Key: 'hello-s3.txt',
Body: obj
});
try {
await this.client.send(command);
} catch (err) {
return err as S3ServiceException;
}
return null;
};
}
export { ObjectStorageS3 };

6
src/lib/validators.ts Normal file
View file

@ -0,0 +1,6 @@
const validatePassword = (password: string) => {
const passwordRe = /^.{8,255}$/;
return passwordRe.test(password);
};
export { validatePassword };

View file

@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals: { authReq } }) => {
return {
isLoggedIn: authReq.validate()
};
};

31
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,31 @@
<script lang="ts">
import '@picocss/pico';
import '../app.css';
export let data;
</script>
<svelte:head>
<meta name="description" content="Get feedback on drafts of your music." />
</svelte:head>
<nav class="container-fluid">
<ul>
<li>
<a href="/"><strong>Larsen</strong></a>
</li>
</ul>
<ul>
{#if data.isLoggedIn}
<li><a href="/upload">Upload</a></li>
<li><a href="/account">Account</a></li>
{:else}
<li><a href="/login">Login</a></li>
{/if}
</ul>
</nav>
<main class="container">
<slot />
</main>

View file

@ -1,2 +1,5 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<svelte:head>
<title>Larsen</title>
</svelte:head>
<h1>Your Tracks</h1>

View file

@ -0,0 +1,54 @@
import { validatePassword } from '$lib/validators';
import { LuciaError } from 'lucia';
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals: { authReq } }) => {
const session = await authReq.validate();
if (session) throw redirect(302, '/');
return {};
};
export const actions: Actions = {
default: async ({ request, locals: { auth, authReq } }) => {
const formData = await request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!validatePassword(password)) {
return fail(400, {
message: 'Password has an invalid length; must be between 8 and 255 characters.'
});
}
try {
const key = await auth.useKey('username', username.toLowerCase(), password);
const session = await auth.createSession({
userId: key.userId,
attributes: {}
});
authReq.setSession(session);
} catch (err) {
// Auth errors
if (
err instanceof LuciaError &&
(err.message === 'AUTH_INVALID_KEY_ID' || err.message === 'AUTH_INVALID_PASSWORD')
) {
return fail(400, {
message: 'Incorrect username or password'
});
}
// Bad errors
return fail(500, {
message: 'Internal server error; please try again later.'
});
}
throw redirect(302, '/');
}
};

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { enhance } from '$app/forms';
export let form;
</script>
<svelte:head>
<title>Login | Larsen</title>
</svelte:head>
<article id="login" class="grid">
<div id="login-form">
<hgroup>
<h1>Login</h1>
{#if form}
<h2 style="color: red">{form?.message}</h2>
{:else}
<h2>Username and password, please</h2>
{/if}
</hgroup>
<form method="post" use:enhance>
<input
type="text"
name="username"
placeholder="username"
value={form?.username ?? ''}
aria-label="Username"
autocomplete="username"
required
/>
<input
type="password"
name="password"
placeholder="Password"
aria-label="Login"
autocomplete="current-password"
required
/>
<fieldset>
<label for="remember">
<input type="checkbox" role="switch" id="remember" name="remember" />
Remember me
</label>
</fieldset>
<button type="submit">Login</button>
<p>
Don't have an account? <a href="/signup">Sign Up Now</a>
</p>
</form>
</div>
<div id="login-art" />
</article>
<style>
article#login {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
article {
padding: 0;
}
div#login-form {
padding: 2rem;
}
div#login-art {
display: none;
background-color: #374956;
background-image: url('https://images.pexels.com/photos/977935/pexels-photo-977935.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1');
background-position: center;
background-size: cover;
}
@media (min-width: 992px) {
div#login-art {
display: block;
}
}
</style>

View file

@ -0,0 +1,60 @@
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { validatePassword } from '$lib/validators';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
export const load: PageServerLoad = async ({ locals: { authReq } }) => {
const session = await authReq.validate();
if (session) throw redirect(302, '/');
return {};
};
export const actions: Actions = {
default: async ({ request, locals: { auth, authReq } }) => {
const formData = await request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!validatePassword(password)) {
return fail(400, {
message: 'Password has an invalid length; must be between 8 and 255 characters.'
});
}
// Create user and set session
try {
const user = await auth.createUser({
key: {
providerId: 'username',
providerUserId: username.toLowerCase(),
password
},
attributes: {
username
}
});
const session = await auth.createSession({
userId: user.userId,
attributes: {}
});
authReq.setSession(session);
} catch (err) {
if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') {
return fail(400, {
message: 'User already exists.'
});
}
console.log(err);
return fail(500, {
message: 'Internal server error, please try again later.'
});
}
throw redirect(302, '/');
}
};

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { validatePassword } from '$lib/validators.js';
export let form;
let username = form?.username ?? '';
let password = '';
let passwordIsInvalid: boolean | null = null;
const validateForm = () => {
// Only change the isInvalid state on forms that have been interacted with already
if (password) {
passwordIsInvalid = !validatePassword(password);
}
};
</script>
<svelte:head>
<title>Sign Up | Larsen</title>
</svelte:head>
<article id="sign-up" class="grid">
<div id="sign-up-form">
<hgroup>
<h1>Sign Up</h1>
{#if form}
<h2 style="color: red">{form?.message}</h2>
{:else}
<h2>Give me your soul.</h2>
{/if}
</hgroup>
<form method="POST" use:enhance>
<input
type="text"
name="username"
placeholder="Username"
aria-label="Username"
bind:value={username}
autocomplete="username"
required
/>
<input
type="password"
name="password"
placeholder="Password"
aria-label="Password"
aria-invalid={passwordIsInvalid}
bind:value={password}
on:input={validateForm}
autocomplete="new-password"
required
/>
<small>No silly requirements. Just 8 characters.</small>
<fieldset>
<label for="remember">
<input type="checkbox" role="switch" id="remember" name="remember" />
Remember me
</label>
</fieldset>
<button type="submit">Sign Up</button>
<p>
Already have an account? <a href="/login">Login Now</a>
</p>
</form>
</div>
<div id="sign-up-art" />
</article>
<style>
article#sign-up {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
article {
padding: 0;
}
div#sign-up-form {
padding: 2rem;
}
div#sign-up-art {
display: none;
background-color: #374956;
background-image: url('https://images.pexels.com/photos/977935/pexels-photo-977935.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1');
background-position: center;
background-size: cover;
}
@media (min-width: 992px) {
div#sign-up-art {
display: block;
}
}
</style>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { enhance } from '$app/forms';
export let form;
</script>
<svelte:head>
<title>Upload | Larsen</title>
</svelte:head>
<article>
<form method="POST" use:enhance>
<hgroup>
<h1>Upload a New Track</h1>
{#if form}
<h2 style="color: red">{form?.message}</h2>
{:else}
<h2>Some good tunes.</h2>
{/if}
</hgroup>
<label for="title">
Track Title
<input type="text" name="title" />
</label>
<label for="file">
Audio File
<input type="file" accept="audio/*" name="file" />
</label>
<button type="submit">Submit</button>
</form>
</article>