Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login 2fa (totp) #1680

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
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
3 changes: 2 additions & 1 deletion api/resolvers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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]
36 changes: 35 additions & 1 deletion api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()

Expand Down Expand Up @@ -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
}
},

Expand Down Expand Up @@ -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
}
},

Expand Down
26 changes: 26 additions & 0 deletions api/resolvers/verify2fa.js
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
11 changes: 9 additions & 2 deletions api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -31,7 +37,8 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
? session.user
: me,
lnd,
search
search,
unverifiedSession
}
}),
cache: new InMemoryCache({
Expand Down
3 changes: 2 additions & 1 deletion api/typeDefs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
3 changes: 3 additions & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -194,6 +196,7 @@ export default gql`
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
isTotpEnabled: Boolean
}

type UserOptional {
Expand Down
18 changes: 18 additions & 0 deletions api/typeDefs/verify2fa.js
Original file line number Diff line number Diff line change
@@ -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
}
`
2 changes: 1 addition & 1 deletion components/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion components/nav/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions components/totp.js
Original file line number Diff line number Diff line change
@@ -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 (
<Form
initial={{
secret: secret.base32,
token: ''
}}
schema={totpSchema}
onSubmit={async ({ token }) => {
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)
}
}}
>
<div className='mb-4 text-center'>
<BootstrapForm.Label>use a two-factor authenticator (TOTP) app to scan this qr code</BootstrapForm.Label>
<div className='d-block p-3 mb-2 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
<QRCodeSVG
className='h-auto mw-100' value={otpUri} size={300} imageSettings={qrImageSettings}
/>
</div>
<PasswordInput name='secret' label='or use this setup key' as='textarea' readOnly copy />
</div>
<PasswordInput name='token' required label='input the one-time authentication code to confirm' />
<div className='d-flex'>
<CancelButton onClick={close} />
<SubmitButton variant='primary' className='ms-auto mt-1 px-4'>enable</SubmitButton>
</div>
</Form>
)
})
}, [])
return showTOTPDialog
}

export const TOTPInputForm = ({ onSubmit, onCancel }) => {
const toaster = useToast()
return (
<Form
initial={{
token: ''
}}
schema={totpTokenSchema}
onSubmit={async ({ token }) => {
try {
await onSubmit(token)
} catch (err) {
console.error(err)
toaster.danger(err.message)
}
}}
>
<h3>Two-factor Authentication</h3>
<PasswordInput name='token' required label='input the one-time authentication code to continue' />
<small className='text-muted'>
open your two-factor authenticator (TOTP) app or browser extension to view your authentication code
</small>
<div className='d-flex mt-4'>
<CancelButton onClick={onCancel} />
<SubmitButton variant='primary' className='ms-auto mt-1 px-4'>submit</SubmitButton>
</div>
</Form>
)
}
1 change: 1 addition & 0 deletions fragments/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ ${STREAK_FIELDS}
walletsUpdatedAt
proxyReceive
directReceive
isTotpEnabled
}
optional {
isContributor
Expand Down
Loading
Loading