diff --git a/package.json b/package.json index 63b55d8..1144baf 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@fastify/env": "^5.0.2", "@fastify/jwt": "^9.1.0", "axios": "^1.10.0", + "base32.js": "^0.1.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.2.0", "fastify": "^5.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 656f368..15413f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: axios: specifier: ^1.10.0 version: 1.10.0 + base32.js: + specifier: ^0.1.0 + version: 0.1.0 bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -496,6 +499,10 @@ packages: axios@1.10.0: resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + base32.js@0.1.0: + resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} + engines: {node: '>=0.12.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1629,6 +1636,8 @@ snapshots: transitivePeerDependencies: - debug + base32.js@0.1.0: {} + base64-js@1.5.1: {} bcrypt@6.0.0: diff --git a/src/api/auth/default.js b/src/api/auth/default.js index c6f8256..ee5928a 100644 --- a/src/api/auth/default.js +++ b/src/api/auth/default.js @@ -7,8 +7,12 @@ import { gRedir } from './gRedir.js'; import authDB from '../../utils/authDB.js' import { gLogCallback } from './gLogCallback.js'; import { gRegisterCallback } from './gRegisterCallback.js'; +import { totpSetup } from './totpSetup.js'; +import { totpDelete } from './totpDelete.js'; +import { totpVerify } from './totpVerify.js'; const saltRounds = 10; +export const appName = process.env.APP_NAME || 'knl_meowscendence'; authDB.prepareDB(); @@ -27,17 +31,20 @@ export default async function(fastify, options) { } }); fastify.register(fastifyCookie); - - fastify.get('/me', async (request, reply) => { + fastify.decorate("authenticate", async function(request, reply) { try { - const token = request.cookies.token; - const decoded = await fastify.jwt.verify(token); - return { user: decoded.user }; - } catch { - return reply.code(401).send({ error: 'Unauthorized' }); + const jwt = await request.jwtVerify(); + request.user = jwt.user; + } catch (err) { + reply.code(401).send({ error: 'Unauthorized' }); } }); + + fastify.get('/me', { preHandler: [fastify.authenticate] }, async (request, reply) => { + return { user: request.user }; + }); + // GOOGLE sign in fastify.get('/login/google', async (request, reply) => { return gRedir(request, reply, fastify, '/login/google/callback'); @@ -47,10 +54,32 @@ export default async function(fastify, options) { }); fastify.get('/login/google/callback', async (request, reply) => { return gLogCallback(request, reply, fastify); - }) + }); fastify.get('/register/google/callback', async (request, reply) => { return gRegisterCallback(request, reply, fastify); - }) + }); + + // TOTP + fastify.post('/2fa', { preHandler: [fastify.authenticate] }, async (request, reply) => { + return totpSetup(request, reply, fastify); + }); + fastify.post('/2fa/verify', { + preHandler: [fastify.authenticate], schema: { + body: { + type: 'object', + required: ['token'], + properties: { + token: { type: 'string', minLength: 6, maxLength: 6 } + } + } + } + }, async (request, reply) => { + return totpVerify(request, reply, fastify); + }); + fastify.delete('/2fa', { preHandler: [fastify.authenticate] }, async (request, reply) => { + return totpDelete(request, reply, fastify); + }); + fastify.post('/login', { schema: { diff --git a/src/api/auth/login.js b/src/api/auth/login.js index 9bb8380..2580383 100644 --- a/src/api/auth/login.js +++ b/src/api/auth/login.js @@ -1,6 +1,7 @@ import bcrypt from 'bcrypt'; import authDB from '../../utils/authDB.js'; +import { verifyTOTP } from "../../utils/totp.js"; var env = process.env.NODE_ENV || 'development'; @@ -34,6 +35,17 @@ export async function login(request, reply, fastify) { return reply.code(401).send({ error: "Incorrect password" }); } + const userTOTP = authDB.getUser(user); + if (userTOTP.totpEnabled == 1) { + if (!request.body.token){ + return reply.code(401).send({ error: 'Invalid 2FA token' }); + } + const isValid = verifyTOTP(userTOTP.totpHash, request.body.token); + if (!isValid) { + return reply.code(401).send({ error: 'Invalid 2FA token' }); + } + } + const token = fastify.jwt.sign({ user }); return reply diff --git a/src/api/auth/register.js b/src/api/auth/register.js index 986c8cd..7463452 100644 --- a/src/api/auth/register.js +++ b/src/api/auth/register.js @@ -46,7 +46,7 @@ export async function register(request, reply, saltRounds, fastify) { sameSite: 'lax', }) .code(200) - .send({ msg: 'Register successfuly' }); + .send({ msg: 'Register successfully' }); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: "Internal server error" }); diff --git a/src/api/auth/totpDelete.js b/src/api/auth/totpDelete.js new file mode 100644 index 0000000..d89c411 --- /dev/null +++ b/src/api/auth/totpDelete.js @@ -0,0 +1,18 @@ +import authDB from "../../utils/authDB.js"; + +/** + * @param {import("fastify").FastifyRequest} request + * @param {import("fastify").FastifyReply} reply + * @param {import("fastify").FastifyInstance} fastify + * + * @returns {import('fastify').FastifyReply} + */ +export function totpDelete(request, reply, fastify) { + try { + authDB.disableTOTP(request.user); + return reply.code(200).send({ msg: 'TOTP removed' }); + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ error: "Internal server error" }); + } +} diff --git a/src/api/auth/totpSetup.js b/src/api/auth/totpSetup.js new file mode 100644 index 0000000..3f0874e --- /dev/null +++ b/src/api/auth/totpSetup.js @@ -0,0 +1,34 @@ +import authDB from '../../utils/authDB.js'; +import { appName } from './default.js'; +import { generateRandomSecret } from '../../utils/totp.js'; + +/** + * @param {import("fastify").FastifyRequest} request + * @param {import("fastify").FastifyReply} reply + * @param {import("fastify").FastifyInstance} fastify + * + * @returns {import('fastify').FastifyReply} + */ +export async function totpSetup(request, reply, fastify) { + try { + const username = request.user; + + if (!authDB.checkUser(username)) { + return reply.code(404).send({ error: "User not found" }); + } + + const secret = generateRandomSecret(); + + const otpauthUrl = `otpauth://totp/${encodeURI(appName)}:${encodeURI(username)}?secret=${secret}&issuer=${encodeURI(appName)}`; + + authDB.setTOTPSecret(username, secret); + + return reply.send({ + secret, + otpauthUrl + }); + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: "Internal server error" }); + } +} diff --git a/src/api/auth/totpVerify.js b/src/api/auth/totpVerify.js new file mode 100644 index 0000000..3d5bbc0 --- /dev/null +++ b/src/api/auth/totpVerify.js @@ -0,0 +1,34 @@ +import authDB from "../../utils/authDB.js"; +import { verifyTOTP } from "../../utils/totp.js"; + +/** + * @param {import("fastify").FastifyRequest} request + * @param {import("fastify").FastifyReply} reply + * @param {import("fastify").FastifyInstance} fastify + * + * @returns {import('fastify').FastifyReply} + */ +export function totpVerify(request, reply, fastify) { + try { + const user = request.user; + if (!authDB.checkUser(user)) { + return reply.code(404).send({ error: 'User not found' }); + } + + const userTOTP = authDB.getUser(user); + if (!userTOTP || !userTOTP.totpHash) { + return reply.code(400).send({ error: '2FA not set up for this user' }); + } + const isValid = verifyTOTP(userTOTP.totpHash, request.body.token); + if (!isValid) { + return reply.code(401).send({ error: 'Invalid 2FA token' }); + } + + authDB.enableTOTP(user); // ensures it's flagged as active + + return reply.code(200).send({ msg: '2FA verified successfully' }); + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ error: "Internal server error" }); + } +} diff --git a/src/api/user/default.js b/src/api/user/default.js index 476d31c..73c1d6b 100644 --- a/src/api/user/default.js +++ b/src/api/user/default.js @@ -6,7 +6,7 @@ var env = process.env.NODE_ENV || 'development'; let database; -if (env === 'development') { +if (!env || env === 'development') { database = new Database(":memory:", { verbose: console.log }); } else { var dbPath = process.env.DB_PATH || '/db/db.sqlite' diff --git a/src/utils/authDB.js b/src/utils/authDB.js index 140be8b..a4f4595 100644 --- a/src/utils/authDB.js +++ b/src/utils/authDB.js @@ -16,10 +16,12 @@ if (!env || env === 'development') { */ function prepareDB() { database.exec(` - CREATE TABLE IF NOT EXISTS credentials ( - username TEXT PRIMARY KEY, - passwordHash TEXT - ) STRICT + CREATE TABLE IF NOT EXISTS credentials ( + username TEXT PRIMARY KEY, + passwordHash TEXT, + totpHash TEXT DEFAULT NULL, + totpEnabled INTEGER DEFAULT 0 + ) STRICT `); } @@ -49,11 +51,48 @@ function passwordQuery(user) { return passwordQuery.get(user) } +function setTOTPSecret(user, secret) { + let setTOTP = database.prepare('UPDATE credentials SET totpHash = ? WHERE username = ?'); + setTOTP.run(secret, user); +} + +function isTOTPEnabled(user) { + const stmt = database.prepare('SELECT totpHash, totpEnabled FROM credentials WHERE username = ?'); + const result = stmt.get(user); + return result && result.totpHash !== null && result.totpEnabled === 1; +} + +function disableTOTP(user) { + let stmt = database.prepare('UPDATE credentials SET totpHash = NULL, totpEnabled = 0 WHERE username = ?'); + stmt.run(user); +} + +function queryTOTP(user) { + let totpQuery = database.prepare('SELECT totpHash FROM credentials WHERE username = ?;'); + return totpQuery.get(user); +} + +function enableTOTP(user) { + let stmt = database.prepare('UPDATE credentials SET totpEnabled = 1 WHERE username = ?'); + stmt.run(user); +} + +function getUser(user) { + const stmt = database.prepare('SELECT * FROM credentials WHERE username = ?'); + return stmt.get(user); +} + const authDB = { prepareDB, checkUser, addUser, passwordQuery, + setTOTPSecret, + isTOTPEnabled, + disableTOTP, + queryTOTP, + enableTOTP, + getUser, RESERVED_USERNAMES }; diff --git a/src/utils/totp.js b/src/utils/totp.js new file mode 100644 index 0000000..da05744 --- /dev/null +++ b/src/utils/totp.js @@ -0,0 +1,43 @@ +import { randomBytes, createHmac } from 'crypto'; +import base32 from 'base32.js'; +const { Decoder, Encoder } = base32; + +const timeStep = 30; +const T0 = 0; + +export function generateRandomSecret() { + const buf = randomBytes(20); + return new Encoder().write(buf).finalize(); +} + +export function getTimeCounter() { + return Math.floor((Date.now() / 1000 - T0) / timeStep); +} + +export function verifyTOTP(base32Secret, userToken) { + const window = 1; + const decoder = new Decoder(); + const key = decoder.write(base32Secret).finalize(); + + const currentCounter = getTimeCounter(); + + for (let errorWindow = -window; errorWindow <= window; errorWindow++) { + const counter = currentCounter + errorWindow; + const generated = generateTOTP(key, counter); + if (generated === userToken) return true; + } + return false; +} + +function generateTOTP(key, counter) { + const buf = Buffer.alloc(8); + buf.writeUInt32BE(0, 0); + buf.writeUInt32BE(counter, 4); + + const hmac = createHmac('sha1', key).update(buf).digest(); + + const offset = hmac[hmac.length - 1] & 0xf; + const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000; + + return code.toString().padStart(6, '0'); +}