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 (
+
+ )
+ })
+ }, [])
+ return showTOTPDialog
+}
+
+export const TOTPInputForm = ({ onSubmit, onCancel }) => {
+ const toaster = useToast()
+ return (
+
+ )
+}
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")