mirror of
https://github.com/BradNut/boredgame
synced 2025-09-08 17:40:22 +00:00
Fixing totp mfa enable, disable, and recovery codes.
This commit is contained in:
parent
3aa537f389
commit
679f88d50d
12 changed files with 631 additions and 484 deletions
14
package.json
14
package.json
|
|
@ -33,7 +33,7 @@
|
||||||
"@sveltejs/kit": "^2.5.25",
|
"@sveltejs/kit": "^2.5.25",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/node": "^20.16.2",
|
"@types/node": "^20.16.3",
|
||||||
"@types/pg": "^8.11.8",
|
"@types/pg": "^8.11.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
"just-debounce-it": "^3.2.0",
|
"just-debounce-it": "^3.2.0",
|
||||||
"lucia": "3.2.0",
|
"lucia": "3.2.0",
|
||||||
"lucide-svelte": "^0.408.0",
|
"lucide-svelte": "^0.408.0",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^6.9.15",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.44",
|
||||||
"postcss-import": "^16.1.0",
|
"postcss-import": "^16.1.0",
|
||||||
"postcss-load-config": "^5.1.0",
|
"postcss-load-config": "^5.1.0",
|
||||||
"postcss-preset-env": "^9.6.0",
|
"postcss-preset-env": "^9.6.0",
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"tslib": "^2.7.0",
|
"tslib": "^2.7.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.2",
|
"vite": "^5.4.3",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.5.5",
|
||||||
"@lucia-auth/adapter-drizzle": "^1.1.0",
|
"@lucia-auth/adapter-drizzle": "^1.1.0",
|
||||||
"@lukeed/uuid": "^2.0.1",
|
"@lukeed/uuid": "^2.0.1",
|
||||||
"@neondatabase/serverless": "^0.9.4",
|
"@neondatabase/serverless": "^0.9.5",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@sveltejs/adapter-node": "^5.2.2",
|
"@sveltejs/adapter-node": "^5.2.2",
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
"arctic": "^1.9.2",
|
"arctic": "^1.9.2",
|
||||||
"bits-ui": "^0.21.13",
|
"bits-ui": "^0.21.13",
|
||||||
"boardgamegeekclient": "^1.9.1",
|
"boardgamegeekclient": "^1.9.1",
|
||||||
"bullmq": "^5.12.12",
|
"bullmq": "^5.12.13",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
"feather-icons": "^4.29.2",
|
"feather-icons": "^4.29.2",
|
||||||
"formsnap": "^1.0.1",
|
"formsnap": "^1.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hono": "^4.5.9",
|
"hono": "^4.5.11",
|
||||||
"hono-rate-limiter": "^0.4.0",
|
"hono-rate-limiter": "^0.4.0",
|
||||||
"html-entities": "^2.5.2",
|
"html-entities": "^2.5.2",
|
||||||
"iconify-icon": "^2.1.0",
|
"iconify-icon": "^2.1.0",
|
||||||
|
|
|
||||||
750
pnpm-lock.yaml
750
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
77
src/lib/components/LeftNav.svelte
Normal file
77
src/lib/components/LeftNav.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
href: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, routes }: { children: unknown; routes: Route[] } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="security-nav">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
{#each routes as { href, label }}
|
||||||
|
<li>
|
||||||
|
<a href={href} class:active={$page.url.pathname === href}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="security-nav-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.security-nav {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
nav {
|
||||||
|
width: 16rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #337ab7;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #23527c;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-nav-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,13 +2,15 @@ import 'reflect-metadata'
|
||||||
import { StatusCodes } from '$lib/constants/status-codes'
|
import { StatusCodes } from '$lib/constants/status-codes'
|
||||||
import type { Controller } from '$lib/server/api/common/interfaces/controller.interface'
|
import type { Controller } from '$lib/server/api/common/interfaces/controller.interface'
|
||||||
import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto'
|
import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto'
|
||||||
|
import { db } from '$lib/server/api/packages/drizzle'
|
||||||
|
import { RecoveryCodesService } from '$lib/server/api/services/recovery-codes.service'
|
||||||
import { TotpService } from '$lib/server/api/services/totp.service'
|
import { TotpService } from '$lib/server/api/services/totp.service'
|
||||||
|
import { UsersService } from '$lib/server/api/services/users.service'
|
||||||
import { zValidator } from '@hono/zod-validator'
|
import { zValidator } from '@hono/zod-validator'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { inject, injectable } from 'tsyringe'
|
import { inject, injectable } from 'tsyringe'
|
||||||
import { CredentialsType } from '../databases/tables'
|
import { CredentialsType } from '../databases/tables'
|
||||||
import { requireAuth } from '../middleware/auth.middleware'
|
import { requireAuth } from '../middleware/auth.middleware'
|
||||||
import { UsersService } from '../services/users.service'
|
|
||||||
import type { HonoTypes } from '../types'
|
import type { HonoTypes } from '../types'
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|
@ -16,6 +18,7 @@ export class MfaController implements Controller {
|
||||||
controller = new Hono<HonoTypes>()
|
controller = new Hono<HonoTypes>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@inject(RecoveryCodesService) private readonly recoveryCodesService: RecoveryCodesService,
|
||||||
@inject(TotpService) private readonly totpService: TotpService,
|
@inject(TotpService) private readonly totpService: TotpService,
|
||||||
@inject(UsersService) private readonly usersService: UsersService,
|
@inject(UsersService) private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -36,6 +39,8 @@ export class MfaController implements Controller {
|
||||||
const user = c.var.user
|
const user = c.var.user
|
||||||
try {
|
try {
|
||||||
await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP)
|
await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP)
|
||||||
|
await this.recoveryCodesService.deleteAllRecoveryCodesByUserId(user.id)
|
||||||
|
await this.usersService.updateUser(user.id, { mfa_enabled: false })
|
||||||
console.log('TOTP deleted')
|
console.log('TOTP deleted')
|
||||||
return c.body(null, StatusCodes.NO_CONTENT)
|
return c.body(null, StatusCodes.NO_CONTENT)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -43,16 +48,26 @@ export class MfaController implements Controller {
|
||||||
return c.status(StatusCodes.INTERNAL_SERVER_ERROR)
|
return c.status(StatusCodes.INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.get('/totp/recoveryCodes', requireAuth, async (c) => {
|
||||||
|
const user = c.var.user
|
||||||
|
// You can only view recovery codes once and that is on creation
|
||||||
|
const existingCodes = await this.recoveryCodesService.findAllRecoveryCodesByUserId(user.id)
|
||||||
|
if (existingCodes) {
|
||||||
|
return c.body('You have already generated recovery codes', StatusCodes.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
const recoveryCodes = await this.recoveryCodesService.createRecoveryCodes(user.id)
|
||||||
|
return c.json({ recoveryCodes })
|
||||||
|
})
|
||||||
.post('/totp/verify', requireAuth, zValidator('json', verifyTotpDto), async (c) => {
|
.post('/totp/verify', requireAuth, zValidator('json', verifyTotpDto), async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.var.user
|
const user = c.var.user
|
||||||
const { code } = c.req.valid('json')
|
const { code } = c.req.valid('json')
|
||||||
const verified = await this.totpService.verify(user.id, code)
|
const verified = await this.totpService.verify(user.id, code)
|
||||||
if (verified) {
|
if (verified) {
|
||||||
this.usersService.updateUser(user.id, { mfa_enabled: true })
|
await this.usersService.updateUser(user.id, { mfa_enabled: true })
|
||||||
return c.json({}, StatusCodes.OK)
|
return c.json({}, StatusCodes.OK)
|
||||||
}
|
}
|
||||||
return c.json({}, StatusCodes.BAD_REQUEST)
|
return c.json('Invalid code', StatusCodes.BAD_REQUEST)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
return c.status(StatusCodes.INTERNAL_SERVER_ERROR)
|
return c.status(StatusCodes.INTERNAL_SERVER_ERROR)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'reflect-metadata'
|
||||||
import type { Repository } from '$lib/server/api/common/interfaces/repository.interface'
|
import type { Repository } from '$lib/server/api/common/interfaces/repository.interface'
|
||||||
import { CredentialsType, credentialsTable } from '$lib/server/api/databases/tables/credentials.table'
|
import { CredentialsType, credentialsTable } from '$lib/server/api/databases/tables/credentials.table'
|
||||||
import { DatabaseProvider } from '$lib/server/api/providers/database.provider'
|
import { DatabaseProvider } from '$lib/server/api/providers/database.provider'
|
||||||
|
|
|
||||||
27
src/lib/server/api/repositories/recovery-codes.repository.ts
Normal file
27
src/lib/server/api/repositories/recovery-codes.repository.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import type { Repository } from '$lib/server/api/common/interfaces/repository.interface'
|
||||||
|
import { DatabaseProvider } from '$lib/server/api/providers/database.provider'
|
||||||
|
import { type InferInsertModel, eq } from 'drizzle-orm'
|
||||||
|
import { inject, injectable } from 'tsyringe'
|
||||||
|
import { recoveryCodesTable } from '../databases/tables'
|
||||||
|
|
||||||
|
export type CreateRecoveryCodes = InferInsertModel<typeof recoveryCodesTable>
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class RecoveryCodesRepository implements Repository {
|
||||||
|
constructor(@inject(DatabaseProvider) private readonly db: DatabaseProvider) {}
|
||||||
|
|
||||||
|
async findAllByUserId(userId: string) {
|
||||||
|
return this.db.query.recoveryCodesTable.findFirst({
|
||||||
|
where: eq(recoveryCodesTable.userId, userId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllByUserId(userId: string) {
|
||||||
|
return this.db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
trxHost(trx: DatabaseProvider) {
|
||||||
|
return new RecoveryCodesRepository(trx)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/lib/server/api/services/recovery-codes.service.ts
Normal file
38
src/lib/server/api/services/recovery-codes.service.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import { recoveryCodesTable } from '$lib/server/api/databases/tables'
|
||||||
|
import { db } from '$lib/server/api/packages/drizzle'
|
||||||
|
import { RecoveryCodesRepository } from '$lib/server/api/repositories/recovery-codes.repository'
|
||||||
|
import { alphabet, generateRandomString } from 'oslo/crypto'
|
||||||
|
import { Argon2id } from 'oslo/password'
|
||||||
|
import { inject, injectable } from 'tsyringe'
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class RecoveryCodesService {
|
||||||
|
constructor(@inject(RecoveryCodesRepository) private readonly recoveryCodesRepository: RecoveryCodesRepository) {}
|
||||||
|
|
||||||
|
async findAllRecoveryCodesByUserId(userId: string) {
|
||||||
|
return this.recoveryCodesRepository.findAllByUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecoveryCodes(userId: string) {
|
||||||
|
const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9')))
|
||||||
|
if (createdRecoveryCodes && userId) {
|
||||||
|
for (const code of createdRecoveryCodes) {
|
||||||
|
const hashedCode = await new Argon2id().hash(code)
|
||||||
|
console.log('Inserting recovery code', code, hashedCode)
|
||||||
|
await db.insert(recoveryCodesTable).values({
|
||||||
|
userId,
|
||||||
|
code: hashedCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRecoveryCodes
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllRecoveryCodesByUserId(userId: string) {
|
||||||
|
return this.recoveryCodesRepository.deleteAllByUserId(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const load = async () => {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
15
src/routes/(app)/(protected)/profile/security/+layout.svelte
Normal file
15
src/routes/(app)/(protected)/profile/security/+layout.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import LeftNav from '$components/LeftNav.svelte'
|
||||||
|
|
||||||
|
let { children } = $props()
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ href: '/profile/security', label: 'Security' },
|
||||||
|
{ href: '/profile/security/mfa', label: 'MFA' },
|
||||||
|
{ href: '/profile/security/password/change', label: 'Change Password' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LeftNav {routes}>
|
||||||
|
{@render children()}
|
||||||
|
</LeftNav>
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
import { StatusCodes } from '$lib/constants/status-codes'
|
|
||||||
import { notSignedInMessage } from '$lib/flashMessages'
|
import { notSignedInMessage } from '$lib/flashMessages'
|
||||||
import { db } from '$lib/server/api/packages/drizzle'
|
|
||||||
import { userNotAuthenticated } from '$lib/server/auth-utils'
|
|
||||||
import { addTwoFactorSchema, removeTwoFactorSchema } from '$lib/validations/account'
|
import { addTwoFactorSchema, removeTwoFactorSchema } from '$lib/validations/account'
|
||||||
import env from '$src/env'
|
import env from '$src/env'
|
||||||
import { type Actions, error, fail } from '@sveltejs/kit'
|
import { type Actions, fail } from '@sveltejs/kit'
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import kebabCase from 'just-kebab-case'
|
import kebabCase from 'just-kebab-case'
|
||||||
import { HMAC } from 'oslo/crypto'
|
import { base32, decodeHex } from 'oslo/encoding'
|
||||||
import { decodeHex, encodeHex } from 'oslo/encoding'
|
import { createTOTPKeyURI } from 'oslo/otp'
|
||||||
import { TOTPController, createTOTPKeyURI } from 'oslo/otp'
|
|
||||||
import { Argon2id } from 'oslo/password'
|
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { redirect, setFlash } from 'sveltekit-flash-message/server'
|
import { redirect } from 'sveltekit-flash-message/server'
|
||||||
import { zod } from 'sveltekit-superforms/adapters'
|
import { zod } from 'sveltekit-superforms/adapters'
|
||||||
import { setError, superValidate } from 'sveltekit-superforms/server'
|
import { setError, superValidate } from 'sveltekit-superforms/server'
|
||||||
import type { PageServerLoad } from '../../$types'
|
import type { PageServerLoad } from '../../$types'
|
||||||
import { type Credentials, credentialsTable, recoveryCodesTable, usersTable } from '../../../../../../lib/server/api/databases/tables'
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const { locals } = event
|
const { locals } = event
|
||||||
|
|
@ -69,7 +62,11 @@ export const load: PageServerLoad = async (event) => {
|
||||||
addTwoFactorForm,
|
addTwoFactorForm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const totpUri = createTOTPKeyURI(issuer, accountName, decodeHex(createdTotpCredentials.secret_data))
|
const decodedHexSecret = decodeHex(createdTotpCredentials.secret_data)
|
||||||
|
const secret = base32.encode(new Uint8Array(decodedHexSecret), {
|
||||||
|
includePadding: false,
|
||||||
|
})
|
||||||
|
const totpUri = createTOTPKeyURI(issuer, accountName, decodedHexSecret)
|
||||||
|
|
||||||
addTwoFactorForm.data = {
|
addTwoFactorForm.data = {
|
||||||
current_password: '',
|
current_password: '',
|
||||||
|
|
@ -82,6 +79,7 @@ export const load: PageServerLoad = async (event) => {
|
||||||
recoveryCodes: [],
|
recoveryCodes: [],
|
||||||
totpUri,
|
totpUri,
|
||||||
qrCode: await QRCode.toDataURL(totpUri),
|
qrCode: await QRCode.toDataURL(totpUri),
|
||||||
|
secret,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,21 +116,25 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const twoFactorCode = addTwoFactorForm.data.two_factor_code
|
const twoFactorCode = addTwoFactorForm.data.two_factor_code
|
||||||
const { error: verifyTotpError } = locals.api.mfa.totp.verify
|
const { error: verifyTotpError } = await locals.api.mfa.totp.verify
|
||||||
.$post({
|
.$post({
|
||||||
json: { code: twoFactorCode },
|
json: { code: twoFactorCode },
|
||||||
})
|
})
|
||||||
.then(locals.parseApiResponse)
|
.then(locals.parseApiResponse)
|
||||||
|
|
||||||
if (verifyTotpError) {
|
if (verifyTotpError) {
|
||||||
return setError(addTwoFactorForm, 'two_factor_code', 'Invalid code')
|
return setError(addTwoFactorForm, 'two_factor_code', 'Invalid code')
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(302, '/profile/security/two-factor/recovery-codes')
|
redirect(302, '/profile/security/mfa/recovery-codes')
|
||||||
},
|
},
|
||||||
disableTotp: async (event) => {
|
disableTotp: async (event) => {
|
||||||
const { locals } = event
|
const { locals } = event
|
||||||
const { user, session } = locals
|
|
||||||
|
const authedUser = await locals.getAuthedUser()
|
||||||
|
if (!authedUser) {
|
||||||
|
throw redirect(302, '/login', notSignedInMessage, event)
|
||||||
|
}
|
||||||
|
|
||||||
const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema))
|
const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema))
|
||||||
|
|
||||||
if (!removeTwoFactorForm.valid) {
|
if (!removeTwoFactorForm.valid) {
|
||||||
|
|
@ -140,51 +142,27 @@ export const actions: Actions = {
|
||||||
removeTwoFactorForm,
|
removeTwoFactorForm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const { error: verifyPasswordError } = await locals.api.me.verify.password
|
||||||
if (!user || !session) {
|
.$post({
|
||||||
return fail(401, {
|
json: { password: removeTwoFactorForm.data.current_password },
|
||||||
removeTwoFactorForm,
|
|
||||||
})
|
})
|
||||||
}
|
.then(locals.parseApiResponse)
|
||||||
|
|
||||||
const dbUser = await db.query.usersTable.findFirst({
|
if (verifyPasswordError) {
|
||||||
where: eq(usersTable.id, user.id),
|
console.log(verifyPasswordError)
|
||||||
})
|
|
||||||
|
|
||||||
// if (!dbUser?.hashed_password) {
|
|
||||||
// removeTwoFactorForm.data.current_password = '';
|
|
||||||
// return setError(
|
|
||||||
// removeTwoFactorForm,
|
|
||||||
// 'Error occurred. Please try again or contact support if you need further help.',
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
const currentPasswordVerified = await new Argon2id().verify(
|
|
||||||
// dbUser.hashed_password,
|
|
||||||
removeTwoFactorForm.data.current_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!currentPasswordVerified) {
|
|
||||||
return setError(removeTwoFactorForm, 'current_password', 'Your password is incorrect')
|
return setError(removeTwoFactorForm, 'current_password', 'Your password is incorrect')
|
||||||
}
|
}
|
||||||
|
|
||||||
const twoFactorDetails = await db.query.twoFactor.findFirst({
|
const { error: deleteTotpError } = await locals.api.mfa.totp.$delete().then(locals.parseApiResponse)
|
||||||
where: eq(twoFactor.userId, dbUser.id),
|
if (deleteTotpError) {
|
||||||
})
|
|
||||||
|
|
||||||
if (!twoFactorDetails) {
|
|
||||||
return fail(500, {
|
return fail(500, {
|
||||||
removeTwoFactorForm,
|
removeTwoFactorForm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(twoFactor).set({ enabled: false }).where(eq(twoFactor.userId, user.id))
|
|
||||||
await db.delete(recoveryCodes).where(eq(recoveryCodes.userId, user.id))
|
|
||||||
|
|
||||||
// setFlash({ type: 'success', message: 'Two-Factor Authentication has been disabled.' }, cookies);
|
|
||||||
redirect(
|
redirect(
|
||||||
302,
|
302,
|
||||||
'/profile/security/two-factor',
|
'/profile/security/mfa',
|
||||||
{
|
{
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Two-Factor Authentication has been disabled.',
|
message: 'Two-Factor Authentication has been disabled.',
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,38 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
import PinInput from '$components/pin-input.svelte'
|
||||||
import { superForm } from 'sveltekit-superforms/client';
|
import * as Alert from '$components/ui/alert'
|
||||||
import { AlertTriangle } from 'lucide-svelte';
|
import * as Form from '$components/ui/form'
|
||||||
import * as Alert from '$components/ui/alert';
|
import { Input } from '$components/ui/input'
|
||||||
import * as Form from '$components/ui/form';
|
import { addTwoFactorSchema, removeTwoFactorSchema } from '$lib/validations/account'
|
||||||
import { Input } from '$components/ui/input';
|
import { AlertTriangle } from 'lucide-svelte'
|
||||||
import { addTwoFactorSchema, removeTwoFactorSchema } from '$lib/validations/account';
|
import { zodClient } from 'sveltekit-superforms/adapters'
|
||||||
import PinInput from '$components/pin-input.svelte';
|
import { superForm } from 'sveltekit-superforms/client'
|
||||||
|
|
||||||
export let data;
|
export let data
|
||||||
|
|
||||||
const { qrCode, twoFactorEnabled, recoveryCodes } = data;
|
const { qrCode, secret, twoFactorEnabled, recoveryCodes } = data
|
||||||
|
|
||||||
const addTwoFactorForm = superForm(data.addTwoFactorForm, {
|
const addTwoFactorForm = superForm(data.addTwoFactorForm, {
|
||||||
taintedMessage: null,
|
taintedMessage: null,
|
||||||
validators: zodClient(addTwoFactorSchema),
|
validators: zodClient(addTwoFactorSchema),
|
||||||
delayMs: 500,
|
delayMs: 500,
|
||||||
multipleSubmits: 'prevent',
|
multipleSubmits: 'prevent',
|
||||||
});
|
})
|
||||||
|
|
||||||
const removeTwoFactorForm = superForm(data.removeTwoFactorForm, {
|
const removeTwoFactorForm = superForm(data.removeTwoFactorForm, {
|
||||||
taintedMessage: null,
|
taintedMessage: null,
|
||||||
validators: zodClient(removeTwoFactorSchema),
|
validators: zodClient(removeTwoFactorSchema),
|
||||||
delayMs: 500,
|
delayMs: 500,
|
||||||
multipleSubmits: 'prevent',
|
multipleSubmits: 'prevent',
|
||||||
});
|
})
|
||||||
|
|
||||||
console.log('Two Factor: ', twoFactorEnabled, recoveryCodes);
|
console.log('Two Factor: ', twoFactorEnabled, recoveryCodes)
|
||||||
|
|
||||||
const { form: addTwoFactorFormData, enhance: addTwoFactorEnhance } = addTwoFactorForm;
|
const { form: addTwoFactorFormData, enhance: addTwoFactorEnhance } = addTwoFactorForm
|
||||||
const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = removeTwoFactorForm;
|
const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = removeTwoFactorForm
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<section class="two-factor">
|
||||||
<h1>Two-Factor Authentication</h1>
|
<h1>Two-Factor Authentication</h1>
|
||||||
|
|
||||||
{#if twoFactorEnabled}
|
{#if twoFactorEnabled}
|
||||||
|
|
@ -70,4 +71,13 @@
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Button>Submit</Form.Button>
|
<Form.Button>Submit</Form.Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
<span>Secret: {secret}</span>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
section {
|
||||||
|
max-width: 20rem;
|
||||||
|
line-break: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { notSignedInMessage } from '$lib/flashMessages'
|
import { notSignedInMessage } from '$lib/flashMessages'
|
||||||
import { db } from '$lib/server/api/packages/drizzle'
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { alphabet, generateRandomString } from 'oslo/crypto'
|
|
||||||
import { Argon2id } from 'oslo/password'
|
|
||||||
import { redirect } from 'sveltekit-flash-message/server'
|
import { redirect } from 'sveltekit-flash-message/server'
|
||||||
import type { PageServerLoad } from '../../../$types'
|
import type { PageServerLoad } from '../../../$types'
|
||||||
import { recoveryCodesTable } from '../../../../../../../lib/server/api/databases/tables'
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const { locals } = event
|
const { locals } = event
|
||||||
|
|
@ -16,30 +11,18 @@ export const load: PageServerLoad = async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authedUser.mfa_enabled) {
|
if (authedUser.mfa_enabled) {
|
||||||
const dbRecoveryCodes = await db.query.recoveryCodesTable.findMany({
|
const { data: recoveryCodesData, error: recoveryCodesError } = await locals.api.mfa.totp.recoveryCodes.$get().then(locals.parseApiResponse)
|
||||||
where: eq(recoveryCodesTable.userId, authedUser.id),
|
console.log('recoveryCodesData', recoveryCodesData)
|
||||||
})
|
console.log('recoveryCodesError', recoveryCodesError)
|
||||||
|
if (recoveryCodesError || !recoveryCodesData || !recoveryCodesData.recoveryCodes) {
|
||||||
if (dbRecoveryCodes.length === 0) {
|
|
||||||
const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9')))
|
|
||||||
if (createdRecoveryCodes) {
|
|
||||||
for (const code of createdRecoveryCodes) {
|
|
||||||
const hashedCode = await new Argon2id().hash(code)
|
|
||||||
console.log('Inserting recovery code', code, hashedCode)
|
|
||||||
await db.insert(recoveryCodesTable).values({
|
|
||||||
userId: authedUser.id,
|
|
||||||
code: hashedCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
recoveryCodes: createdRecoveryCodes,
|
recoveryCodes: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
recoveryCodes: [],
|
recoveryCodes: recoveryCodesData.recoveryCodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error('2FA not enabled')
|
|
||||||
redirect(302, '/profile', { message: 'Two-Factor Authentication is not enabled', type: 'error' }, event)
|
redirect(302, '/profile', { message: 'Two-Factor Authentication is not enabled', type: 'error' }, event)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue