diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d0..c3d476bcd 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -20,6 +20,7 @@ import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' import vault from './vault' +import verify2fa from './verify2fa' const date = new GraphQLScalarType({ name: 'Date', @@ -56,4 +57,4 @@ const limit = createIntScalar({ export default [user, item, message, wallet, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] + { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault, verify2fa] diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 03f6a8d13..7b4c8ac3f 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { msatsToSats } from '@/lib/format' -import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate' +import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema, totpSchema } from '@/lib/validate' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants' import { viewGroup } from './growth' @@ -11,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey' import { hashEmail } from '@/lib/crypto' import { isMuted } from '@/lib/user' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' +import { validateTotp } from '@/lib/auth2fa' const contributors = new Set() @@ -883,6 +884,33 @@ export default { await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } }) return true + }, + setTotpSecret: async (parent, { secret, token }, { me, models }) => { + if (!me) throw new GqlAuthenticationError() + await validateSchema(totpSchema, { secret, token }) + await validateTotp({ secret, token }) + try { + await models.user.update({ + where: { + id: me.id, + totpSecret: null + }, + data: { + totpSecret: secret + } + }) + } catch (error) { + if (error.code === 'P2025') { + throw new Error('could not set totp secret') + } + throw error + } + return true + }, + unsetTotpSecret: async (parent, args, { me, models }) => { + if (!me) throw new GqlAuthenticationError() + await models.user.update({ where: { id: me.id }, data: { totpSecret: null } }) + return true } }, @@ -1049,6 +1077,12 @@ export default { return false } return !!user.tipRandomMin && !!user.tipRandomMax + }, + isTotpEnabled: async (user, args, { me }) => { + if (!me || me.id !== user.id) { + return false + } + return !!user.totpSecret } }, diff --git a/api/resolvers/verify2fa.js b/api/resolvers/verify2fa.js new file mode 100644 index 000000000..468304896 --- /dev/null +++ b/api/resolvers/verify2fa.js @@ -0,0 +1,26 @@ +import * as Auth2fa from '@/lib/auth2fa' + +export default { + Mutation: { + verify2fa: async (parent, { method, callbackUrl, ...args }, { models, unverifiedSession }) => { + const session = unverifiedSession + if (!session) throw new Error('Not authenticated') + + const userId = session.user.id + const user = await models.user.findUnique({ where: { id: userId } }) + if (!user) throw new Error('User not found') + + const valid = Auth2fa.validate2fa(method, args, { me: user }) + if (!valid) throw new Error('Invalid 2FA token') + + const token = await Auth2fa.getEncodedLogin2faToken({ result: valid, userId, jti2fa: session.jti2fa, callbackUrl }) + return { + result: valid, + tokens: [ + token + ], + callbackUrl + } + } + } +} diff --git a/api/ssrApollo.js b/api/ssrApollo.js index 7af73317e..4c46f4ad6 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -13,11 +13,17 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight' import { CHAIN_FEE } from '@/fragments/chainFee' import { getServerSession } from 'next-auth/next' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' +import * as Auth2fa from '@/lib/auth2fa' import { NOFOLLOW_LIMIT } from '@/lib/constants' import { satsToMsats } from '@/lib/format' export default async function getSSRApolloClient ({ req, res, me = null }) { - const session = req && await getServerSession(req, res, getAuthOptions(req)) + let session = req && await getServerSession(req, res, getAuthOptions(req)) + + // 2fa check + let unverifiedSession = null + ;({ session, unverifiedSession } = await Auth2fa.sessionGuard({ session, req })) + const client = new ApolloClient({ ssrMode: true, link: new SchemaLink({ @@ -31,7 +37,8 @@ export default async function getSSRApolloClient ({ req, res, me = null }) { ? session.user : me, lnd, - search + search, + unverifiedSession } }), cache: new InMemoryCache({ diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e427..b0bf147d2 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -19,6 +19,7 @@ import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' import vault from './vault' +import verify2fa from './verify2fa' const common = gql` type Query { @@ -39,4 +40,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault, verify2fa] diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index e61cb4b76..2e4f69665 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -44,6 +44,8 @@ export default gql` generateApiKey(id: ID!): String deleteApiKey(id: ID!): User disableFreebies: Boolean + setTotpSecret(secret: String!, token: String!): Boolean + unsetTotpSecret: Boolean } type User { @@ -194,6 +196,7 @@ export default gql` walletsUpdatedAt: Date proxyReceive: Boolean directReceive: Boolean + isTotpEnabled: Boolean } type UserOptional { diff --git a/api/typeDefs/verify2fa.js b/api/typeDefs/verify2fa.js new file mode 100644 index 000000000..d9f5ddeb6 --- /dev/null +++ b/api/typeDefs/verify2fa.js @@ -0,0 +1,18 @@ +import { gql } from 'graphql-tag' + +export default gql` + type Tokens2fa { + key: String + value: String + } + + type Verify2faResponse { + result: Boolean! + tokens: [Tokens2fa] + callbackUrl: String + } + + extend type Mutation { + verify2fa(method: String!, token: String!, callbackUrl: String): Verify2faResponse + } +` diff --git a/components/account.js b/components/account.js index e912c90b0..18946ce47 100644 --- a/components/account.js +++ b/components/account.js @@ -14,7 +14,7 @@ const AccountContext = createContext() const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64') -const maybeSecureCookie = cookie => { +export const maybeSecureCookie = cookie => { return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie } diff --git a/components/nav/common.js b/components/nav/common.js index 47057b7e0..6dfe4b304 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -261,7 +261,7 @@ export default function LoginButton () { ) } -function LogoutObstacle ({ onClose }) { +export function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { removeLocalWallets } = useWallets() const { multiAuthSignout } = useAccounts() diff --git a/components/totp.js b/components/totp.js new file mode 100644 index 000000000..6b3bdd9c3 --- /dev/null +++ b/components/totp.js @@ -0,0 +1,88 @@ +import { useCallback } from 'react' +import { useShowModal } from '@/components/modal' +import { validateTotp } from '@/lib/auth2fa' +import { qrImageSettings } from '@/components/qr' +import { QRCodeSVG } from 'qrcode.react' +import BootstrapForm from 'react-bootstrap/Form' +import { totpSchema, totpTokenSchema } from '@/lib/validate' +import { Form, SubmitButton, PasswordInput } from '@/components/form' +import { useToast } from '@/components/toast' +import CancelButton from './cancel-button' + +export const useTOTPEnableDialog = () => { + const showModal = useShowModal() + const toaster = useToast() + const showTOTPDialog = useCallback(({ secret, otpUri }, onToken) => { + showModal((close) => { + return ( +
{ + try { + const verified = validateTotp({ secret: secret.base32, token }) + if (!verified) { + toaster.danger('invalid code') + return + } + await onToken(token) + close() + } catch (err) { + console.error(err) + toaster.danger('failed to enable ' + err.message) + } + }} + > +
+ use a two-factor authenticator (TOTP) app to scan this qr code +
+ +
+ +
+ +
+ + enable +
+ + ) + }) + }, []) + return showTOTPDialog +} + +export const TOTPInputForm = ({ onSubmit, onCancel }) => { + const toaster = useToast() + return ( +
{ + try { + await onSubmit(token) + } catch (err) { + console.error(err) + toaster.danger(err.message) + } + }} + > +

Two-factor Authentication

+ + + open your two-factor authenticator (TOTP) app or browser extension to view your authentication code + +
+ + submit +
+ + ) +} diff --git a/fragments/users.js b/fragments/users.js index b96ec9327..b8307a754 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -53,6 +53,7 @@ ${STREAK_FIELDS} walletsUpdatedAt proxyReceive directReceive + isTotpEnabled } optional { isContributor diff --git a/lib/auth2fa.js b/lib/auth2fa.js new file mode 100644 index 000000000..38d54c7a8 --- /dev/null +++ b/lib/auth2fa.js @@ -0,0 +1,177 @@ +import { TOTP } from 'otpauth' +import { NextResponse } from 'next/server' +import { getToken, encode } from 'next-auth/jwt' + +/** + * Get totp provider + */ +function getTotp ({ label, secret } = {}) { + return new TOTP({ + issuer: 'stacker.news', + label, + digits: 6, + period: 30, + secret + }) +} + +/** + * Check if the given token is valid within the window + * @param {Object} param0 + * @param {string} param0.secret - the totp secret + * @param {string} param0.token - the totp token + * @returns {boolean} - true if the token is valid + */ +export function validateTotp ({ secret, token }) { + const totp = getTotp({ secret }) + const delta = totp.validate({ token, window: 1 }) + return delta !== null +} + +/** + * Generate a totp secret + * @param {args} param0 + * @param {string} param0.label - the totp label (eg. usernames) + * @returns {Object} - the totp secret + * @returns {string} base32 - the base32 secret + * @returns {string} uri - the totp uri + */ +export function generateTotpSecret ({ label = 'stacker.news - login' }) { + const totp = getTotp({ label }) + return { + base32: totp.secret.base32, + uri: totp.toString() + } +} + +/** + * Return all the 2fa methods supported by the user + * @param {Object} context + * @param {Object} context.me - the user object + * @returns {Array} - the 2fa methods supported by the user eg ['totp'] + */ +export function getRequired2faMethods ({ me }) { + if (me?.isTotpEnabled || me?.totpSecret) { + return ['totp'] + } + return null +} + +/** + * Validate 2fa + * @param {string} method - the 2fa method (eg totp) + * @param {Object} args - the 2fa tokens required for the method + * @param {Object} context + * @param {Object} context.me - the user object + * @returns {boolean} - true if the 2fa is valid + */ +export function validate2fa (method, args, { me }) { + switch (method) { + case 'totp': { + const { token } = args + const totpSecret = me.totpSecret + if (!totpSecret) throw new Error('2FA not enabled') + return validateTotp({ secret: totpSecret, token }) + } + default: + throw new Error('Unsupported 2FA method ' + method) + } +} + +/** + * Get the 2fa jwt token for the user session + * @param {Object} context + * @param {Object} context.req - the request object + * @param {string} context.userId - the user id + * @returns {Object} - the decoded 2fa token + */ +export async function getLogin2faToken ({ req, userId }) { + const cookieName = `sn2fa_${userId}` + const secret = process.env.NEXTAUTH_SECRET + const token = await getToken({ req, secret, cookieName }) + return token +} + +/** + * Check if the user session requires 2fa + * @param {args} param0 + * @param {Object} param0.session - the user session + * @param {Object} param0.req - the request object + * @returns {Object} + * @returns {Object} session - the verified session or null if 2fa is required + * @returns {Object} unverifiedSession - the unverified session or null if 2fa is not required + */ +export async function sessionGuard ({ session, req }) { + // if anon or 2fa is not set, user doesn't need 2fa + if (!session?.requires2faMethods) return { session } + const token2fa = await getLogin2faToken({ req, userId: session.user.id }) + let unverifiedSession = null + + // if 2fa was not passed or the jti2fa is different (replay attack), user needs 2fa + const needLogin2fa = token2fa?.jti2fa !== session?.jti2fa + if (needLogin2fa) { + unverifiedSession = session + session = null + } + + return { session, unverifiedSession } +} + +/** + * Check if the user session requires 2fa and if so redirect to the 2fa prompt page + * @param {Object} param0 + * @param {Object} param0.req - the request object + * @returns {Object|null} - the nextjs redirect response or null if no redirect is needed + */ +export async function pageGuard ({ req }) { + const secret = process.env.NEXTAUTH_SECRET + const token = await getToken({ req, secret }) + const userId = token?.id + + // anons don't need 2fa + if (!userId) return null + + // if not 2fa method is required, user doesn't need 2fa + if (!token?.requires2faMethods?.length) return null + + // select one 2fa method from the available ones + const method2fa = token.requires2faMethods[0] // there is a single 2fa method for now + + // if we are already on the right 2fa prompt page, we don't need to redirect + const pathname = req.nextUrl.pathname + const searchParams = new URLSearchParams(req.nextUrl.search) + if (pathname === '/auth/prompt2fa' && searchParams.get('method') === method2fa) return null + + // redirect only if the 2fa was not passed or the jti2fa is different (replay attack) + const token2fa = await getLogin2faToken({ req, userId }) + const needLogin2fa = token2fa?.jti2fa !== token?.jti2fa + if (needLogin2fa) { + const redirectTo = new URL('/auth/prompt2fa', req.url) + redirectTo.searchParams.set('callbackUrl', req.url) + redirectTo.searchParams.set('method', method2fa) + return NextResponse.redirect(redirectTo) + } +} + +/** + * Get the 2fa encoded token to be stored in the user session + * @param {Object} param0 + * @param {boolean} param0.result - the 2fa result + * @param {string} param0.userId - the user id + * @param {string} param0.jti2fa - the 2fa jwt id + * @returns {Object} - the cookie key and value + */ +export async function getEncodedLogin2faToken ({ result, userId, jti2fa }) { + const cookieName = `sn2fa_${userId}` + const secret = process.env.NEXTAUTH_SECRET + const encodedToken = await encode({ + token: { + jti2fa + }, + secret + }) + return { + key: cookieName, + value: encodedToken + } +} diff --git a/lib/validate.js b/lib/validate.js index 86b03bded..d7fe115e2 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -515,3 +515,12 @@ export const deviceSyncSchema = object().shape({ return true }) }) + +export const totpSchema = object({ + secret: string().required('required').trim().max(64, 'secret too long').matches(/^[A-Z2-7]+$/, 'invalid secret'), + token: string().required('required').trim().matches(/^[0-9]{6}$/, 'otp code must be 6 digits') +}) + +export const totpTokenSchema = object({ + token: string().required('required').trim().matches(/^[0-9]{6}$/, 'otp code must be 6 digits') +}) diff --git a/middleware.js b/middleware.js index d99464c31..aae34d5f3 100644 --- a/middleware.js +++ b/middleware.js @@ -1,4 +1,5 @@ import { NextResponse, URLPattern } from 'next/server' +import * as Auth2fa from '@/lib/auth2fa' const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' }) const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' }) @@ -69,7 +70,11 @@ function referrerMiddleware (request) { return response } -export function middleware (request) { +export async function middleware (request) { + // 2fa check + const redirect = await Auth2fa.pageGuard({ req: request }) + if (redirect) return redirect + const resp = referrerMiddleware(request) const isDev = process.env.NODE_ENV === 'development' diff --git a/package-lock.json b/package-lock.json index d373b8eb0..6bb7b2873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "nostr-tools": "^2.8.0", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", + "otpauth": "^9.3.5", "page-metadata-parser": "^1.1.4", "pg-boss": "^9.0.3", "piexifjs": "^1.0.6", @@ -16192,6 +16193,30 @@ "node": ">= 0.8.0" } }, + "node_modules/otpauth": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.3.5.tgz", + "integrity": "sha512-jQyqOuQExeIl4YIiOUz4TdEcamgAgPX6UYeeS9Iit4lkvs7bwHb0JNDqchGRccbRfvWHV6oRwH36tOsVmc+7hQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.5.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/otpauth/node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/package.json b/package.json index f4e7f2c5a..241365f70 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "nostr-tools": "^2.8.0", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", + "otpauth": "^9.3.5", "page-metadata-parser": "^1.1.4", "pg-boss": "^9.0.3", "piexifjs": "^1.0.6", diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 2d1160ab4..13cc7db2f 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -15,6 +15,8 @@ import { notifyReferral } from '@/lib/webPush' import { hashEmail } from '@/lib/crypto' import * as cookie from 'cookie' import { multiAuthMiddleware } from '@/pages/api/graphql' +import * as Auth2fa from '@/lib/auth2fa' +import { v4 as uuidv4 } from 'uuid' /** * Stores userIds in user table @@ -96,8 +98,13 @@ function getCallbacks (req, res) { req = new NodeNextRequest(req) res = new NodeNextResponse(res) const secret = process.env.NEXTAUTH_SECRET - const jwt = await encodeJWT({ token, secret }) const me = await prisma.user.findUnique({ where: { id: token.id } }) + + // on login we check if the user needs 2fa + token.requires2faMethods = Auth2fa.getRequired2faMethods({ me }) + token.jti2fa = uuidv4() // and we generate a 2fa jti to avoid replay attacks + + const jwt = await encodeJWT({ token, secret }) // we set multi_auth cookies on login/signup with only one user so the rest of the code doesn't // have to consider the case where they aren't set yet because account switching wasn't used yet setMultiAuthCookies(req, res, { ...me, jwt }) @@ -109,7 +116,8 @@ function getCallbacks (req, res) { // note: this function takes the current token (result of running jwt above) // and returns a new object session that's returned whenever get|use[Server]Session is called session.user.id = token.id - + session.requires2faMethods = token.requires2faMethods + session.jti2fa = token.jti2fa return session } } diff --git a/pages/api/graphql.js b/pages/api/graphql.js index 9d6626e93..973202b7b 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -7,6 +7,8 @@ import typeDefs from '@/api/typeDefs' import { getServerSession } from 'next-auth/next' import { getAuthOptions } from './auth/[...nextauth]' import search from '@/api/search' +import * as Auth2fa from '@/lib/auth2fa' + import { ApolloServerPluginLandingPageLocalDefault, ApolloServerPluginLandingPageProductionDefault @@ -70,6 +72,11 @@ export default startServerAndCreateNextHandler(apolloServer, { req = multiAuthMiddleware(req) session = await getServerSession(req, res, getAuthOptions(req)) } + + // 2fa check + let unverifiedSession = null + ;({ session, unverifiedSession } = await Auth2fa.sessionGuard({ session, req })) + return { models, headers: req.headers, @@ -77,7 +84,8 @@ export default startServerAndCreateNextHandler(apolloServer, { me: session ? session.user : null, - search + search, + unverifiedSession } } }) diff --git a/pages/auth/prompt2fa.js b/pages/auth/prompt2fa.js new file mode 100644 index 000000000..4afbb0eb4 --- /dev/null +++ b/pages/auth/prompt2fa.js @@ -0,0 +1,75 @@ +import { useRouter } from 'next/router' +import { useMutation } from '@apollo/client' +import gql from 'graphql-tag' +import { TOTPInputForm } from '@/components/totp' +import { useToast } from '@/components/toast' +import { getGetServerSideProps } from '@/api/ssrApollo' +import { maybeSecureCookie } from '@/components/account' +import { StaticLayout } from '@/components/layout' +import { LogoutObstacle } from '@/components/nav/common' +import { useShowModal } from '@/components/modal' +import CancelButton from '@/components/cancel-button' +import { useCallback } from 'react' +export const getServerSideProps = getGetServerSideProps({ }) + +export default function Prompt2fa () { + const router = useRouter() + const toaster = useToast() + const { callbackUrl, method } = router.query + + const [verify2fa] = useMutation(gql` + mutation Verify2fa($method: String!, $token: String!, $callbackUrl: String) { + verify2fa(method:$method, token: $token, callbackUrl: $callbackUrl) { + result + tokens { + key + value + } + callbackUrl + } + } + `) + const showModal = useShowModal() + + const logout = useCallback(() => { + showModal((close) => { + return ( + + ) + }) + }, []) + + switch (method) { + case 'totp':{ + return ( + + { + try { + let res = await verify2fa({ variables: { method: 'totp', token, callbackUrl } }) + res = res.data.verify2fa + console.log(res) + if (!res.result) throw new Error('Invalid token') + for (const { key, value } of res.tokens) { + document.cookie = maybeSecureCookie(`${key}=${value}; Path=/`) + } + router.push(res.callbackUrl) + } catch (e) { + console.error(e) + toaster.danger(e.message) + } + }} + /> + + ) + } + default: + return ( + +

Unsupported 2fa method

+ +
+ ) + } +} diff --git a/pages/settings/index.js b/pages/settings/index.js index d5941ea18..069ec4646 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -31,6 +31,8 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { useField } from 'formik' import styles from './settings.module.css' import { AuthBanner } from '@/components/banners' +import { generateTotpSecret } from '@/lib/auth2fa' +import { useTOTPEnableDialog } from '@/components/totp' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -644,6 +646,7 @@ export default function Settings ({ ssrData }) {
saturday newsletter
{settings?.authMethods && } + @@ -1170,3 +1173,49 @@ const TipRandomField = () => { ) } + +function Auth2fa () { + const { me } = useMe() + + const [unsetTotpSecret] = useMutation( + gql` + mutation UnsetTotpSecret { + unsetTotpSecret + } + ` + ) + const [setTotpSecret] = useMutation( + gql` + mutation SetTotpSecret ($secret: String!, $token: String!) { + setTotpSecret(secret: $secret, token: $token) + } + ` + ) + const totpDialog = useTOTPEnableDialog() + + return ( + <> +
two-factor authentication
+ {me?.privates?.isTotpEnabled + ? ( + + ) + : ( + + )} + + + ) +} diff --git a/prisma/migrations/20241127205631_totp/migration.sql b/prisma/migrations/20241127205631_totp/migration.sql new file mode 100644 index 000000000..df9456b27 --- /dev/null +++ b/prisma/migrations/20241127205631_totp/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "totpSecret" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59685b931..390498324 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,6 +119,7 @@ model User { autoWithdrawMaxFeePercent Float? autoWithdrawThreshold Int? autoWithdrawMaxFeeTotal Int? + totpSecret String? muters Mute[] @relation("muter") muteds Mute[] @relation("muted") ArcOut Arc[] @relation("fromUser")