Skip to content

Commit

Permalink
Make the demo actually secure
Browse files Browse the repository at this point in the history
  • Loading branch information
kasperpeulen committed Aug 22, 2024
1 parent 3dfe228 commit dc55ef6
Show file tree
Hide file tree
Showing 11 changed files with 61 additions and 73 deletions.
8 changes: 2 additions & 6 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getUserFromSession } from '#lib/session'

export async function saveNote(
noteId: number | undefined,
title: string,
body: string,
) {
const user = getUserFromSession()
export async function saveNote(noteId: number | undefined, title: string, body: string) {
const user = await getUserFromSession()

if (!user) {
redirect('/')
Expand Down
2 changes: 1 addition & 1 deletion app/note/edit/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type Props = {
}

export default async function EditPage({ params }: Props) {
const user = getUserFromSession()
const user = await getUserFromSession()

const note = await db.note.findUnique({
where: {
Expand Down
3 changes: 2 additions & 1 deletion components/auth-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const meta = {
args: {
noteId: null,
},
parameters: { react: { rsc: true } },
} satisfies Meta<typeof AuthButton>

export default meta
Expand All @@ -15,7 +16,7 @@ type Story = StoryObj<typeof meta>

export const LoggedIn: Story = {
beforeEach: () => {
getUserFromSession.mockReturnValue('storybookjs')
getUserFromSession.mockResolvedValue('storybookjs')
},
args: { children: 'Add' },
}
Expand Down
18 changes: 8 additions & 10 deletions components/auth-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,18 @@ type Props = {
noteId: number | null
}

export default function AuthButton({ children, noteId }: Props) {
const user = getUserFromSession()
export default async function AuthButton({ children, noteId }: Props) {
const user = await getUserFromSession()
const isDraft = noteId == null

if (user) {
return (
// Use hard link
<Link href={`/note/edit/${noteId || ''}`} className="link--unstyled">
<button
className={[
'edit-button',
isDraft ? 'edit-button--solid' : 'edit-button--outline',
].join(' ')}
className={['edit-button', isDraft ? 'edit-button--solid' : 'edit-button--outline'].join(
' ',
)}
role="menuitem"
>
{children}
Expand All @@ -41,10 +40,9 @@ export default function AuthButton({ children, noteId }: Props) {
return (
<form action={login}>
<button
className={[
'edit-button',
isDraft ? 'edit-button--solid' : 'edit-button--outline',
].join(' ')}
className={['edit-button', isDraft ? 'edit-button--solid' : 'edit-button--outline'].join(
' ',
)}
role="menuitem"
>
Login to Add
Expand Down
2 changes: 1 addition & 1 deletion components/logout-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createUserCookie, userCookieKey } from '#lib/session'

const meta = {
component: LogoutButton,
parameters: { backgrounds: { default: 'dark' } },
parameters: { backgrounds: { default: 'dark' }, react: { rsc: true } },
async beforeEach() {
cookies().set(userCookieKey, await createUserCookie('storybookjs'))
},
Expand Down
6 changes: 3 additions & 3 deletions components/logout-button.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { logout } from '#app/actions'
import { getUserFromSession } from '#lib/session'

export default function LogoutButton() {
const user = getUserFromSession()
export default async function LogoutButton() {
const user = await getUserFromSession()

return (
user && (
<form action={logout}>
<button className="logout-button" type="submit" aria-label='logout'>
<button className="logout-button" type="submit" aria-label="logout">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
Expand Down
17 changes: 5 additions & 12 deletions components/note-ui.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { getUserFromSession } from '#lib/session.mock'

const meta = {
component: NoteUI,
parameters: { react: { rsc: true } },
async beforeEach() {
getUserFromSession.mockReturnValue('storybookjs')
getUserFromSession.mockResolvedValue('storybookjs')
saveNote.mockImplementation(async () => {})
deleteNote.mockImplementation(async () => {})
},
Expand All @@ -34,12 +35,8 @@ export const SaveAndDeleteShouldTriggerActions: Story = {
note: notes[0]!,
},
play: async ({ canvas, step, userEvent }) => {
const titleInput = await canvas.findByLabelText(
'Enter a title for your note',
)
const bodyInput = await canvas.findByLabelText(
'Enter the body for your note',
)
const titleInput = await canvas.findByLabelText('Enter a title for your note')
const bodyInput = await canvas.findByLabelText('Enter the body for your note')
await userEvent.clear(titleInput)
await userEvent.type(titleInput, 'Edited Title')
await userEvent.clear(bodyInput)
Expand All @@ -49,11 +46,7 @@ export const SaveAndDeleteShouldTriggerActions: Story = {
const saveButton = await canvas.findByRole('menuitem', { name: /done/i })
await userEvent.click(saveButton)
await expect(saveNote).toHaveBeenCalledOnce()
await expect(saveNote).toHaveBeenCalledWith(
1,
'Edited Title',
'Edited Body',
)
await expect(saveNote).toHaveBeenCalledWith(1, 'Edited Title', 'Edited Body')
})

await step('Delete flow', async () => {
Expand Down
28 changes: 10 additions & 18 deletions components/note-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,20 @@ import { type Note } from '@prisma/client'

type Props =
| {
note: Partial<Note>
isEditing: true
}
note: Partial<Note>
isEditing: true
}
| {
note: Note
isEditing: false
}
note: Note
isEditing: false
}

export default function NoteUI({ note, isEditing }: Props) {
const user = getUserFromSession()
export default async function NoteUI({ note, isEditing }: Props) {
const user = await getUserFromSession()

if (isEditing) {
return (
<NoteEditor
noteId={note.id}
initialTitle={note.title ?? ''}
initialBody={note.body ?? ''}
/>
<NoteEditor noteId={note.id} initialTitle={note.title ?? ''} initialBody={note.body ?? ''} />
)
}

Expand Down Expand Up @@ -53,11 +49,7 @@ export default function NoteUI({ note, isEditing }: Props) {
height={40}
/>
&nbsp;
<a
href={`https://github.com/${createdBy}`}
target="_blank"
rel="noopener noreferrer"
>
<a href={`https://github.com/${createdBy}`} target="_blank" rel="noopener noreferrer">
{createdBy}
</a>
</div>
Expand Down
47 changes: 27 additions & 20 deletions lib/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cookies } from 'next/headers'
export const userCookieKey = '_un'
export const cookieSep = '^)&_*($'

const password = process.env.SESSION_KEY || 'session-key'
const password = 'session-key'

const pwUtf8 = encode(password)

Expand All @@ -13,43 +13,50 @@ function encode(value: string) {
}

// Encrypt
export function createEncrypt() {
return async function (data: string) {
return sign(data, pwUtf8.toString())
}
export async function encrypt(data: string) {
return await sign(data, pwUtf8.toString())
}

// Decrypt
export function createDecrypt() {
return async function decrypt(data: string) {
const decrypted = unsign(data, pwUtf8.toString())
if (decrypted) return decrypted
throw new Error('Invalid signature')
}
export async function decrypt(data: string) {
const decrypted = await unsign(data, pwUtf8.toString())

Check warning on line 21 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L20-L21

Added lines #L20 - L21 were not covered by tests
if (decrypted) return decrypted
throw new Error('Invalid signature')

Check warning on line 23 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L23

Added line #L23 was not covered by tests
}

export function getSession(userCookie = '') {
const none = [null, null]
const none = [null, null] as const
const value = decodeURIComponent(userCookie)
if (!value) return none
const index = value.indexOf(cookieSep)
if (index === -1) return none
const user = value.slice(0, index)
const session = value.slice(index + cookieSep.length)
const session = value.slice(index + cookieSep.length, value.indexOf(';') + 1)
return [user, session]
}

export function getUser(userCookie?: string) {
return getSession(userCookie)[0]
export async function getUser(userCookie?: string) {
const [user, encryptedUser] = getSession(userCookie)
if (user && encryptedUser) {
try {
const decryptedUser = await decrypt(encryptedUser)

Check warning on line 41 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L40-L41

Added lines #L40 - L41 were not covered by tests
if (decryptedUser === user) {
return user

Check warning on line 43 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L43

Added line #L43 was not covered by tests
}
return null

Check warning on line 45 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L45

Added line #L45 was not covered by tests
} catch (e) {
return null

Check warning on line 47 in lib/session.ts

View check run for this annotation

Codecov / codecov/patch

lib/session.ts#L47

Added line #L47 was not covered by tests
}
}
return user
}

export async function createUserCookie(token: string) {
const encrypt = createEncrypt()
return `${token}${cookieSep}${await encrypt(token)}`
const encrypted = await encrypt(token)
return `${token}${cookieSep}${encrypted}`
}

export function getUserFromSession() {
export async function getUserFromSession() {
const cookieStore = cookies()
const userCookie = cookieStore.get(userCookieKey)
return getUser(userCookie?.value)
return await getUser(userCookie?.value)
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dev": "next dev",
"test": "vitest",
"lint": "next lint",
"typecheck": "tsc",
"start": "next start",
"prisma:setup": "dotenv -e .env.local prisma migrate dev && pnpm run generate-dmmf",
"prisma:seed": "dotenv -e .env.local prisma db seed",
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
]
},
"include": [".storybook/*", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}

0 comments on commit dc55ef6

Please sign in to comment.