mirror of
https://github.com/KeyZox71/knl_meowscendence.git
synced 2025-08-14 12:32:54 +02:00
「✨」 feat: added 2fa (closes #21)
This commit is contained in:
@ -4,6 +4,7 @@
|
|||||||
"@fastify/env": "^5.0.2",
|
"@fastify/env": "^5.0.2",
|
||||||
"@fastify/jwt": "^9.1.0",
|
"@fastify/jwt": "^9.1.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"base32.js": "^0.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"fastify": "^5.4.0",
|
"fastify": "^5.4.0",
|
||||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
|
base32.js:
|
||||||
|
specifier: ^0.1.0
|
||||||
|
version: 0.1.0
|
||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@ -496,6 +499,10 @@ packages:
|
|||||||
axios@1.10.0:
|
axios@1.10.0:
|
||||||
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
|
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:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@ -1629,6 +1636,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|
||||||
|
base32.js@0.1.0: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
bcrypt@6.0.0:
|
bcrypt@6.0.0:
|
||||||
|
@ -7,8 +7,12 @@ import { gRedir } from './gRedir.js';
|
|||||||
import authDB from '../../utils/authDB.js'
|
import authDB from '../../utils/authDB.js'
|
||||||
import { gLogCallback } from './gLogCallback.js';
|
import { gLogCallback } from './gLogCallback.js';
|
||||||
import { gRegisterCallback } from './gRegisterCallback.js';
|
import { gRegisterCallback } from './gRegisterCallback.js';
|
||||||
|
import { totpSetup } from './totpSetup.js';
|
||||||
|
import { totpDelete } from './totpDelete.js';
|
||||||
|
import { totpVerify } from './totpVerify.js';
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
export const appName = process.env.APP_NAME || 'knl_meowscendence';
|
||||||
|
|
||||||
authDB.prepareDB();
|
authDB.prepareDB();
|
||||||
|
|
||||||
@ -27,17 +31,20 @@ export default async function(fastify, options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
fastify.register(fastifyCookie);
|
fastify.register(fastifyCookie);
|
||||||
|
fastify.decorate("authenticate", async function(request, reply) {
|
||||||
fastify.get('/me', async (request, reply) => {
|
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.token;
|
const jwt = await request.jwtVerify();
|
||||||
const decoded = await fastify.jwt.verify(token);
|
request.user = jwt.user;
|
||||||
return { user: decoded.user };
|
} catch (err) {
|
||||||
} catch {
|
reply.code(401).send({ error: 'Unauthorized' });
|
||||||
return reply.code(401).send({ error: 'Unauthorized' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
fastify.get('/me', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
return { user: request.user };
|
||||||
|
});
|
||||||
|
|
||||||
// GOOGLE sign in
|
// GOOGLE sign in
|
||||||
fastify.get('/login/google', async (request, reply) => {
|
fastify.get('/login/google', async (request, reply) => {
|
||||||
return gRedir(request, reply, fastify, '/login/google/callback');
|
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) => {
|
fastify.get('/login/google/callback', async (request, reply) => {
|
||||||
return gLogCallback(request, reply, fastify);
|
return gLogCallback(request, reply, fastify);
|
||||||
})
|
});
|
||||||
fastify.get('/register/google/callback', async (request, reply) => {
|
fastify.get('/register/google/callback', async (request, reply) => {
|
||||||
return gRegisterCallback(request, reply, fastify);
|
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', {
|
fastify.post('/login', {
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
import authDB from '../../utils/authDB.js';
|
import authDB from '../../utils/authDB.js';
|
||||||
|
import { verifyTOTP } from "../../utils/totp.js";
|
||||||
|
|
||||||
var env = process.env.NODE_ENV || 'development';
|
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" });
|
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 });
|
const token = fastify.jwt.sign({ user });
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
|
@ -46,7 +46,7 @@ export async function register(request, reply, saltRounds, fastify) {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
})
|
})
|
||||||
.code(200)
|
.code(200)
|
||||||
.send({ msg: 'Register successfuly' });
|
.send({ msg: 'Register successfully' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return reply.code(500).send({ error: "Internal server error" });
|
return reply.code(500).send({ error: "Internal server error" });
|
||||||
|
18
src/api/auth/totpDelete.js
Normal file
18
src/api/auth/totpDelete.js
Normal file
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
34
src/api/auth/totpSetup.js
Normal file
34
src/api/auth/totpSetup.js
Normal file
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
34
src/api/auth/totpVerify.js
Normal file
34
src/api/auth/totpVerify.js
Normal file
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ var env = process.env.NODE_ENV || 'development';
|
|||||||
|
|
||||||
let database;
|
let database;
|
||||||
|
|
||||||
if (env === 'development') {
|
if (!env || env === 'development') {
|
||||||
database = new Database(":memory:", { verbose: console.log });
|
database = new Database(":memory:", { verbose: console.log });
|
||||||
} else {
|
} else {
|
||||||
var dbPath = process.env.DB_PATH || '/db/db.sqlite'
|
var dbPath = process.env.DB_PATH || '/db/db.sqlite'
|
||||||
|
@ -16,10 +16,12 @@ if (!env || env === 'development') {
|
|||||||
*/
|
*/
|
||||||
function prepareDB() {
|
function prepareDB() {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS credentials (
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
username TEXT PRIMARY KEY,
|
username TEXT PRIMARY KEY,
|
||||||
passwordHash TEXT
|
passwordHash TEXT,
|
||||||
) STRICT
|
totpHash TEXT DEFAULT NULL,
|
||||||
|
totpEnabled INTEGER DEFAULT 0
|
||||||
|
) STRICT
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,11 +51,48 @@ function passwordQuery(user) {
|
|||||||
return passwordQuery.get(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 = {
|
const authDB = {
|
||||||
prepareDB,
|
prepareDB,
|
||||||
checkUser,
|
checkUser,
|
||||||
addUser,
|
addUser,
|
||||||
passwordQuery,
|
passwordQuery,
|
||||||
|
setTOTPSecret,
|
||||||
|
isTOTPEnabled,
|
||||||
|
disableTOTP,
|
||||||
|
queryTOTP,
|
||||||
|
enableTOTP,
|
||||||
|
getUser,
|
||||||
RESERVED_USERNAMES
|
RESERVED_USERNAMES
|
||||||
};
|
};
|
||||||
|
|
||||||
|
43
src/utils/totp.js
Normal file
43
src/utils/totp.js
Normal file
@ -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');
|
||||||
|
}
|
Reference in New Issue
Block a user