Skip to content
Open
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ Emails are just logged to the console. Rate limiting is implemented using JavaSc

Create `sqlite.db` and run `setup.sql`.

```
```bash
sqlite3 sqlite.db
.read setup.sql
```

Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`.
Create a `.env` file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`.

```bash
ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA=="
Expand All @@ -30,12 +31,12 @@ ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA=="
> You can use OpenSSL to quickly generate a secure key.
>
> ```bash
> openssl rand --base64 16
> openssl rand -base64 16
> ```

Install dependencies and run the application:

```
```bash
pnpm i
pnpm dev
```
Expand Down
3 changes: 2 additions & 1 deletion actions/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const webauthnChallengeRateLimitBucket = new RefillingTokenBucket<string>(30, 10

export async function createWebAuthnChallengeAction(): Promise<string> {
console.log("create");
const clientIP = headers().get("X-Forwarded-For");
const headersList = await headers();
const clientIP = headersList.get("X-Forwarded-For");
if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) {
throw new Error("Too many requests");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/passkey/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ import { globalPOSTRateLimit } from "@/lib/server/request";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";

export async function verify2FAWithPasskeyAction(data: unknown): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
error: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return {
error: "Not authenticated"
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/passkey/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { redirect } from "next/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/passkey/register/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ import type {
import type { WebAuthnUserCredential } from "@/lib/server/webauthn";

export async function registerPasskeyAction(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
message: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return {
message: "Not authenticated"
Expand Down
4 changes: 2 additions & 2 deletions app/2fa/passkey/register/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { createChallenge } from "@/lib/client/webauthn";
import { decodeBase64, encodeBase64 } from "@oslojs/encoding";
import { useState } from "react";
import { useFormState } from "react-dom";
import { useActionState } from "react";
import { registerPasskeyAction } from "./actions";

import type { User } from "@/lib/server/user";
Expand All @@ -19,7 +19,7 @@ export function RegisterPasskeyForm(props: {
}) {
const [encodedAttestationObject, setEncodedAttestationObject] = useState<string | null>(null);
const [encodedClientDataJSON, setEncodedClientDataJSON] = useState<string | null>(null);
const [formState, action] = useFormState(registerPasskeyAction, initialRegisterPasskeyState);
const [formState, action] = useActionState(registerPasskeyAction, initialRegisterPasskeyState);
return (
<>
<button
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/passkey/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { bigEndian } from "@oslojs/binary";
import { encodeBase64 } from "@oslojs/encoding";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/reset/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { globalPOSTRateLimit } from "@/lib/server/request";
import { redirect } from "next/navigation";

export async function reset2FAAction(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
message: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return {
message: "Not authenticated"
Expand Down
4 changes: 2 additions & 2 deletions app/2fa/reset/components.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { reset2FAAction } from "./actions";
import { useFormState } from "react-dom";
import { useActionState } from "react";

const initial2FAResetState = {
message: ""
};

export function TwoFactorResetForm() {
const [state, action] = useFormState(reset2FAAction, initial2FAResetState);
const [state, action] = useActionState(reset2FAAction, initial2FAResetState);
return (
<form action={action}>
<label htmlFor="form-totp.code">Recovery code</label>
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/reset/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { getCurrentSession } from "@/lib/server/session";
import { redirect } from "next/navigation";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { globalGETRateLimit } from "@/lib/server/request";
import { getCurrentSession } from "@/lib/server/session";

export async function GET() {
if (!globalGETRateLimit()) {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return new Response("Too many requests", {
status: 429
});
}
const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return new Response(null, {
status: 302,
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/security-key/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ import { globalPOSTRateLimit } from "@/lib/server/request";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";

export async function verify2FAWithSecurityKeyAction(data: unknown): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
error: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return {
error: "Not authenticated"
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/security-key/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { redirect } from "next/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/security-key/register/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ import type {
import type { WebAuthnUserCredential } from "@/lib/server/webauthn";

export async function registerSecurityKeyAction(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
message: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return {
message: "Not authenticated"
Expand Down
4 changes: 2 additions & 2 deletions app/2fa/security-key/register/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { createChallenge } from "@/lib/client/webauthn";
import { decodeBase64, encodeBase64 } from "@oslojs/encoding";
import { useState } from "react";
import { useFormState } from "react-dom";
import { useActionState } from "react";
import { registerSecurityKeyAction } from "./actions";

import type { User } from "@/lib/server/user";
Expand All @@ -19,7 +19,7 @@ export function RegisterSecurityKey(props: {
}) {
const [encodedAttestationObject, setEncodedAttestationObject] = useState<string | null>(null);
const [encodedClientDataJSON, setEncodedClientDataJSON] = useState<string | null>(null);
const [formState, action] = useFormState(registerSecurityKeyAction, initialRegisterSecurityKeyState);
const [formState, action] = useActionState(registerSecurityKeyAction, initialRegisterSecurityKeyState);
return (
<>
<button
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/security-key/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { bigEndian } from "@oslojs/binary";
import { encodeBase64 } from "@oslojs/encoding";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return redirect("/login");
}
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { getCurrentSession } from "@/lib/server/session";
import { redirect } from "next/navigation";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null || user === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/totp/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import { redirect } from "next/navigation";
import { globalPOSTRateLimit } from "@/lib/server/request";

export async function verify2FAAction(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
message: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return {
message: "Not authenticated"
Expand Down
4 changes: 2 additions & 2 deletions app/2fa/totp/components.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { verify2FAAction } from "./actions";
import { useFormState } from "react-dom";
import { useActionState } from "react";

const initial2FAVerificationState = {
message: ""
};

export function TwoFactorVerificationForm() {
const [state, action] = useFormState(verify2FAAction, initial2FAVerificationState);
const [state, action] = useActionState(verify2FAAction, initial2FAVerificationState);
return (
<form action={action}>
<label htmlFor="form-totp.code">Code</label>
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/totp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { getCurrentSession } from "@/lib/server/session";
import { redirect } from "next/navigation";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return redirect("/login");
}
Expand Down
5 changes: 3 additions & 2 deletions app/2fa/totp/setup/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { globalPOSTRateLimit } from "@/lib/server/request";
const totpUpdateBucket = new RefillingTokenBucket<number>(3, 60 * 10);

export async function setup2FAAction(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
if (!globalPOSTRateLimit()) {
const canPerformRequest = await globalPOSTRateLimit();
if (!canPerformRequest) {
return {
message: "Too many requests"
};
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return {
message: "Not authenticated"
Expand Down
4 changes: 2 additions & 2 deletions app/2fa/totp/setup/components.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { setup2FAAction } from "./actions";
import { useFormState } from "react-dom";
import { useActionState } from "react";

const initial2FASetUpState = {
message: ""
};

export function TwoFactorSetUpForm(props: { encodedTOTPKey: string }) {
const [state, action] = useFormState(setup2FAAction, initial2FASetUpState);
const [state, action] = useActionState(setup2FAAction, initial2FASetUpState);
return (
<form action={action}>
<input name="key" value={props.encodedTOTPKey} hidden required />
Expand Down
7 changes: 4 additions & 3 deletions app/2fa/totp/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { renderSVG } from "uqr";
import { get2FARedirect } from "@/lib/server/2fa";
import { globalGETRateLimit } from "@/lib/server/request";

export default function Page() {
if (!globalGETRateLimit()) {
export default async function Page() {
const canPerformRequest = await globalGETRateLimit();
if (!canPerformRequest) {
return "Too many requests";
}

const { session, user } = getCurrentSession();
const { session, user } = await getCurrentSession();
if (session === null) {
return redirect("/login");
}
Expand Down
Loading