Skip to content

Commit

Permalink
2fa enable/disable logic
Browse files Browse the repository at this point in the history
  • Loading branch information
tdjsnelling committed Feb 13, 2023
1 parent 01d3daf commit 977205f
Show file tree
Hide file tree
Showing 9 changed files with 447 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ If your configuration is not valid, sqtracker will fail to start.
|----------------------------|---------|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| SQ_SITE_NAME | envs | sqtracker demo | The name of your tracker site |
| SQ_SITE_DESCRIPTION | envs | My very own private tracker | A short description of your tracker site |
| SQ_THEME_COLOUR | envs | #f45d48 | A hex colour code used as the main theme colour of your site |
| SQ_ALLOW_REGISTER | envs | `invite` | Registration mode. Either `open`, `invite` or `closed` |
| SQ_ALLOW_ANONYMOUS_UPLOADS | envs | `false` | Whether or not users can upload torrents anonymously. Either `true` or `false` |
| SQ_MINIMUM_RATIO | envs | 0.75 | Minimum allowed ratio. Below this users will not be able to download |
Expand Down
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
"multer": "^1.4.2",
"node-fetch": "^2.6.1",
"nodemailer": "^6.7.8",
"qrcode": "^1.5.1",
"qs": "^6.11.0",
"slugify": "^1.6.5",
"speakeasy": "^2.0.0",
"yup": "^0.32.11"
},
"devDependencies": {
Expand Down
119 changes: 119 additions & 0 deletions api/src/controllers/user.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import speakeasy from 'speakeasy'
import qrcode from 'qrcode'
import User from '../schema/user'
import Invite from '../schema/invite'
import Progress from '../schema/progress'
Expand Down Expand Up @@ -101,6 +103,9 @@ export const register = async (req, res) => {
remainingInvites: 0,
emailVerified: false,
bonusPoints: 0,
totp: {
enabled: false,
},
})

newUser.uid = crypto
Expand Down Expand Up @@ -426,6 +431,7 @@ export const fetchUser = async (req, res) => {
remainingInvites: 1,
banned: 1,
bonusPoints: 1,
'totp.enabled': 1,
},
},
{
Expand Down Expand Up @@ -825,3 +831,116 @@ export const unbanUser = async (req, res) => {
res.status(500).send(e.message)
}
}

export const generateTotpSecret = async (req, res) => {
try {
const user = await User.findOne({ _id: req.userId }).lean()
if (user.totp.enabled) {
res.status(409).send('TOTP already enabled')
return
}

const secret = speakeasy.generateSecret({ length: 20 })
const url = speakeasy.otpauthURL({
secret: secret.ascii,
label: `${process.env.SQ_SITE_NAME}: ${user.username}`,
})
const imageDataUrl = await qrcode.toDataURL(url)

await User.findOneAndUpdate(
{ _id: req.userId },
{
$set: {
'totp.secret': secret.base32,
'totp.qr': imageDataUrl,
},
}
)

res.json({ qr: imageDataUrl, secret: secret.base32 })
} catch (e) {
res.status(500).send(e.message)
}
}

export const enableTotp = async (req, res) => {
if (req.body.token) {
try {
const user = await User.findOne({ _id: req.userId }).lean()
if (user.totp.enabled) {
res.status(409).send('TOTP already enabled')
return
}

const validToken = speakeasy.totp.verify({
secret: user.totp.secret,
encoding: 'base32',
token: req.body.token,
window: 1,
})

if (!validToken) {
res.status(400).send('Invalid TOTP code')
return
}

const backupCodes = [...Array(10)].map(() =>
crypto.randomBytes(32).toString('hex').slice(0, 10)
)

await User.findOneAndUpdate(
{ _id: req.userId },
{
$set: {
'totp.enabled': true,
'totp.backup': backupCodes,
},
}
)

res.send(backupCodes.join(','))
} catch (e) {
res.status(500).send(e.message)
}
} else {
res.status(400).send('Request must include token')
}
}

export const disableTotp = async (req, res) => {
if (req.body.token) {
try {
const user = await User.findOne({ _id: req.userId }).lean()

const validToken = speakeasy.totp.verify({
secret: user.totp.secret,
encoding: 'base32',
token: req.body.token,
window: 1,
})

if (!validToken) {
res.status(400).send('Invalid TOTP code')
return
}

await User.findOneAndUpdate(
{ _id: req.userId },
{
$set: {
'totp.enabled': false,
'totp.secret': '',
'totp.qr': '',
'totp.backup': [],
},
}
)

res.sendStatus(200)
} catch (e) {
res.status(500).send(e.message)
}
} else {
res.status(400).send('Request must include token')
}
}
6 changes: 6 additions & 0 deletions api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
banUser,
unbanUser,
buyItems,
generateTotpSecret,
enableTotp,
disableTotp,
} from './controllers/user'
import {
uploadTorrent,
Expand Down Expand Up @@ -186,6 +189,9 @@ app.post('/account/buy', buyItems)
app.get('/user/:username', fetchUser)
app.post('/user/ban/:username', banUser)
app.post('/user/unban/:username', unbanUser)
app.get('/account/totp/generate', generateTotpSecret)
app.post('/account/totp/enable', enableTotp)
app.post('/account/totp/disable', disableTotp)

// torrent routes
app.post('/torrent/upload', uploadTorrent)
Expand Down
6 changes: 6 additions & 0 deletions api/src/schema/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const User = new mongoose.Schema({
remainingInvites: Number,
emailVerified: Boolean,
bonusPoints: Number,
totp: {
enabled: Boolean,
secret: String,
qr: String,
backup: [String],
},
})

export default mongoose.model('user', User)
1 change: 1 addition & 0 deletions api/src/utils/validateConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const configSchema = yup
.object({
SQ_SITE_NAME: yup.string().min(1).max(20).required(),
SQ_SITE_DESCRIPTION: yup.string().min(1).max(80).required(),
SQ_THEME_COLOUR: yup.string().matches(/#([a-f0-9]){6}/i),
SQ_ALLOW_REGISTER: yup
.string()
.oneOf(['open', 'invite', 'closed'])
Expand Down
167 changes: 167 additions & 0 deletions client/pages/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ const Account = ({ token, invites = [], user, userRole }) => {
const [invitesList, setInvitesList] = useState(invites)
const [showInviteModal, setShowInviteModal] = useState(false)
const [bonusPoints, setBonusPoints] = useState(user.bonusPoints ?? 0)
const [totpEnabled, setTotpEnabled] = useState(user.totp.enabled)
const [totpQrData, setTotpQrData] = useState()
const [totpBackupCodes, setTotpBackupCodes] = useState()

const { addNotification } = useContext(NotificationContext)
const { setLoading } = useContext(LoadingContext)
Expand Down Expand Up @@ -213,6 +216,72 @@ const Account = ({ token, invites = [], user, userRole }) => {
setLoading(false)
}

const handleToggleTotp = async (e) => {
e.preventDefault()
const form = new FormData(e.target)

try {
if (!totpEnabled) {
const totpToken = form.get('token')

if (totpToken) {
const enableRes = await fetch(`${SQ_API_URL}/account/totp/enable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token: totpToken }),
})
if (enableRes.status === 200) {
const backupCodes = await enableRes.text()
setTotpBackupCodes(backupCodes)
setTotpQrData(undefined)
setTotpEnabled(true)
addNotification('success', '2FA enabled')
} else {
const message = await enableRes.text()
addNotification('error', message)
}
} else {
const generateRes = await fetch(
`${SQ_API_URL}/account/totp/generate`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
)
const totpData = await generateRes.json()
setTotpQrData(totpData)
}
} else {
const totpToken = form.get('token')

const disableRes = await fetch(`${SQ_API_URL}/account/totp/disable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token: totpToken }),
})

if (disableRes.status === 200) {
setTotpEnabled(false)
addNotification('success', '2FA disabled')
} else {
const message = await disableRes.text()
addNotification('error', message)
}
}
} catch (e) {
addNotification('error', `Could not toggle 2FA: ${e.message}`)
console.error(e)
}
}

return (
<>
<SEO title="My account" />
Expand Down Expand Up @@ -350,6 +419,104 @@ const Account = ({ token, invites = [], user, userRole }) => {
]}
mb={5}
/>
<Box mb={5}>
<Text as="h2" mb={4}>
Two-factor authentication
</Text>
<Text mb={4}>
Use an authenticator app to add another layer of security to your
account.
</Text>
<form onSubmit={handleToggleTotp}>
{totpQrData ? (
<Box display="flex" alignItems="center">
<Box
border="1px solid"
borderColor="border"
borderRadius={1}
p={3}
mr={4}
>
<Box
as="img"
src={totpQrData.qr}
width="180px"
borderRadius={1}
display="block"
mx="auto"
mb={3}
/>
<Text color="grey" fontFamily="mono" fontSize={0}>
{totpQrData.secret}
</Text>
</Box>
<Box>
<Text color="grey" mb={4}>
Scan the QR code with your authenticator app and enter the
one-time code
</Text>
<Input
name="token"
type="number"
label="One-time code"
width="300px"
autoComplete="off"
required
mb={4}
/>
<Box display="flex" alignItems="center">
<Button mr={3}>Enable 2FA</Button>
<Button
type="button"
variant="secondary"
onClick={() => setTotpQrData(undefined)}
>
Cancel
</Button>
</Box>
</Box>
</Box>
) : (
<>
{totpBackupCodes ? (
<>
<Text mb={3}>
2FA enabled successfully. These backup codes can be used to
log in if you lose access to your authenticator app. Save
them now, they will not be visible again.
</Text>
<Box as="ul">
{totpBackupCodes.split(',').map((code) => (
<Text
as="li"
key={`totp-backup-${code}`}
fontFamily="mono"
>
{code}
</Text>
))}
</Box>
</>
) : (
<>
{totpEnabled && (
<Input
name="token"
type="number"
label="One-time code"
width="300px"
autoComplete="off"
required
mb={4}
/>
)}
<Button>{totpEnabled ? 'Disable' : 'Enable'} 2FA</Button>
</>
)}
</>
)}
</form>
</Box>
<Text as="h2" mb={4}>
Change password
</Text>
Expand Down
1 change: 1 addition & 0 deletions config.example.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
envs: {
SQ_SITE_NAME: 'sqtracker demo',
SQ_SITE_DESCRIPTION: 'A short description for your tracker site',
SQ_THEME_COLOUR: '#f45d48',
SQ_ALLOW_REGISTER: 'invite',
SQ_ALLOW_ANONYMOUS_UPLOADS: false,
SQ_MINIMUM_RATIO: 0.75,
Expand Down
Loading

0 comments on commit 977205f

Please sign in to comment.