Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

set up basic auth with username & password #13

Merged
merged 12 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
'use server';

import { hash, verify } from '@node-rs/argon2';
import { eq } from 'drizzle-orm';
import { generateIdFromEntropySize } from 'lucia';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { db } from '~/drizzle/db';
import { user } from '~/drizzle/schema';
import { lucia, validateRequest } from '~/utils/auth';
import {
createUserFormDataSchema,
getUserFormDataSchema,
} from '~/utils/authSchema';

export async function signup(
currentState: {
success: boolean;
error: null | string;
},
formData: FormData,
) {
const parsedFormData = createUserFormDataSchema.safeParse(formData);

if (!parsedFormData.success) {
return {
success: false,
error: parsedFormData.error.message,
};
}

try {
const { username, password } = parsedFormData.data;

const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});

const userId = generateIdFromEntropySize(10);

// check if username is taken
const [existingUser] = await db
.select()
.from(user)
.where(eq(user.username, username))
.limit(1);

if (existingUser) throw new Error('Username already taken!');

// create user
await db.insert(user).values({
id: userId,
username,
passwordHash,
});

const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
return { error: null, success: true };
} catch (error) {
return {
success: false,
error: 'Username already taken!',
};
}
}

export async function signin(
currentState: {
success: boolean;
error: null | string;
},
formData: FormData,
) {
const parsedFormData = getUserFormDataSchema.safeParse(formData);

if (!parsedFormData.success) {
return {
success: false,
error: parsedFormData.error.message,
};
}

try {
const { username, password } = parsedFormData.data;

const [existingUser] = await db
.select()
.from(user)
.where(eq(user.username, username))
.limit(1);

if (!existingUser) {
// NOTE:
// Returning immediately allows malicious actors to figure out valid usernames from response times,
// allowing them to only focus on guessing passwords in brute-force attacks.
// As a preventive measure, you may want to hash passwords even for invalid usernames.
// However, valid usernames can be already be revealed with the signup page among other methods.
// It will also be much more resource intensive.
// Since protecting against this is non-trivial,
// it is crucial your implementation is protected against brute-force attacks with login throttling etc.
// If usernames are public, you may outright tell the user that the username is invalid.
// eslint-disable-next-line no-console
console.log('invalid username');
return {
success: false,
error: 'Incorrect username or password!',
};
}

const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
return {
success: false,
error: 'Incorrect username or password!',
};
}

const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);

return {
success: true,
error: null,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error while signing in', error);
return {
success: false,
error: 'Something went wrong! Please try again.',
};
}
}

export async function signout() {
const { session } = await validateRequest();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}

await lucia.invalidateSession(session.id);

const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);

revalidatePath('/');
return {
success: true,
error: null,
};
}
11 changes: 11 additions & 0 deletions app/_components/SignOutBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import React from "react";
import { signout } from "~/actions/auth";
import { Button } from "~/components/ui/button";

const SignOutBtn = () => {
return <Button onClick={() => void signout()}>Sign Out</Button>;
};

export default SignOutBtn;
86 changes: 71 additions & 15 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,81 @@
@tailwind components;
@tailwind utilities;

:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
@layer base {
:root {
--foreground-rgb: 255, 255, 255;
--background-rgb: 0, 0, 0;
--platinum-hue: 210;
--platinum-saturation: 43%;
--platinum-lightness: 95%;
--platinum: var(--platinum-hue) var(--platinum-saturation)
var(--platinum-lightness);

--background: var(--platinum);
--foreground: 222.2 84% 4.9%;

--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;

--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;

--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;

--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;

--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;

--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;

--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;

--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;

--radius: 0.5rem;
}
}

body {
color: rgb(var(--foreground-rgb));
background-color: rgb(var(--background-rgb));
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;

--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;

--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;

--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;

--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;

--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;

--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;

--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;

--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}

@layer utilities {
.text-balance {
text-wrap: balance;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
6 changes: 6 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { getOrganizations } from '~/actions/organizations';
import CreateOrgForm from '~/app/[org]/_components/CreateOrgForm';
import { requirePageAuth } from "~/utils/auth";
import SignOutBtn from "./_components/SignOutBtn";

export default async function Home() {
await requirePageAuth();

const allOrgs = await getOrganizations();
return (
<main className="flex flex-col p-12">
Expand All @@ -15,6 +19,8 @@ export default async function Home() {
</a>
))}
</div>

<SignOutBtn />
</main>
);
}
41 changes: 41 additions & 0 deletions app/signin/_components/SignInForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useFormState } from "react-dom";
import { signin } from "~/actions/auth";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";

const SignInForm = () => {
const initialState = { error: null, success: false };
const [formState, formAction] = useFormState(signin, initialState);

return (
<form action={formAction}>
{formState.error && (
<div className="text-red-500 text-center text-sm">
{formState.error}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as SignUpForm - can this be error.message

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered it here: #13 (comment)

</div>
)}

<div className="flex flex-col gap-3 p-2">
<Label htmlFor="username">Username</Label>
<Input name="username" id="username" placeholder="username..." />
</div>
<br />
<div className="flex flex-col gap-3 p-2">
<Label htmlFor="password">Password</Label>
<Input
type="password"
name="password"
id="password"
placeholder="password..."
/>
</div>
<br />
<Button>Continue</Button>
</form>
);
};

export default SignInForm;
39 changes: 39 additions & 0 deletions app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { validateRequest } from "~/utils/auth";
import SignInForm from "./_components/SignInForm";

export default async function Page() {
const { session, user } = await validateRequest();

if (session && user) {
// If the user is already signed in, redirect to the home page
redirect("/");
}

return (
<div className="grid w-full items-center h-[100vh] justify-center gap-1.5">
<Card className="w-[28rem] m-3">
<CardHeader>
<CardTitle>Sign in to Studio</CardTitle>
<CardDescription>
Don&apos;t have an account?{" "}
<Link className="text-blue-400 underline" href={"/signup"}>
Sign Up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<SignInForm />
</CardContent>
</Card>
</div>
);
}
Loading
Loading