Skip to content
Draft
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
8 changes: 8 additions & 0 deletions languages/en-GB/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@
"authn.modal.content": "A request to register your hardware key has been sent, please authenticate by activating the key now<br /><br /><i class=\"fa fa-spinner fa-spin\"></i>",
"authn.error": "Hardware key registration aborted.",
"authn.success": "Hardware key successfully registered.",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.lead": "Please activate your hardware key now.",
"authn.login.info": "This account is protected by two-factor authentication via a physical key. If you own the key for this account, please activate it now by pressing the button on the key.",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.login.error": "We cannot validate the integrity of this key, please use a backup code to log in, and re-register this key with your account.",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",

"login.text": "Enter the verification code generated by your mobile application.",
"login.verify": "Verify",
Expand Down
8 changes: 8 additions & 0 deletions languages/fr/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,13 @@
"admin.deactivate.search": "Recherche d&apos;utilisateurs ici ...",

"admin.force_2fa": "Enforce 2factor Authentication",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "Force the users belonging to the selected groups to activate 2FA. <br /><small>This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated.</small>"
}
8 changes: 8 additions & 0 deletions languages/it/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,13 @@
"admin.deactivate.search": "Cerca per utente...",

"admin.force_2fa": "Enforce 2factor Authentication",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "Force the users belonging to the selected groups to activate 2FA. <br /><small>This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated.</small>"
}
8 changes: 8 additions & 0 deletions languages/ko/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,13 @@
"admin.deactivate.search": "여기서 사용자 검색...",

"admin.force_2fa": "2단계 인증 강제 사용",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "선택한 그룹에 속한 사용자에게 2단계 인증(2FA)를 활성화하도록 강제합니다. <br /><small>이는 관리자 및 중재자와 같은 특권 사용자의 보안을 강화하는 데 유용합니다.</small>"
}
8 changes: 8 additions & 0 deletions languages/pl/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,13 @@
"admin.deactivate.search": "Wyszukaj użytkowników tutaj...",

"admin.force_2fa": "Wymuś weryfikację dwuetapową",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "Wymagaj od wybranych grup użytkowników aktywacji 2FA. <br /><small>Funkcja ta przekłada się na wyższy poziom bezpieczeństwa tam gdzie w grę wchodzą uprawnienia administratora czy moderatora.</small>"
}
8 changes: 8 additions & 0 deletions languages/ru/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,13 @@
"admin.deactivate.search": "Ищите пользователей здесь...",

"admin.force_2fa": "Принудительная двухфакторная аутентификация",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "Принудительно активируйте 2FA у пользователей, принадлежащих к выбранным группам. <br /><small>Это полезно для повышения безопасности, гарантируя, что у привилегированных пользователей, таких как администраторы и модераторы, эта функция активирована.</small>"
}
8 changes: 8 additions & 0 deletions languages/zh-CN/2factor.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,13 @@
"admin.deactivate.search": "在这里搜索用户...",

"admin.force_2fa": "Enforce 2factor Authentication",
"authn.register.prompt": "Enter a name for this device (optional):",
"authn.login.select": "Select your hardware key to authenticate with:",
"authn.rename": "Rename",
"authn.rename.prompt": "Enter new device name:",
"authn.renamed": "Device renamed successfully.",
"authn.remove": "Remove",
"authn.remove.confirm": "Are you sure you want to remove this device? You will not be able to use it for two-factor authentication.",
"authn.removed": "Device removed successfully.",
"admin.force_2fa.help": "Force the users belonging to the selected groups to activate 2FA. <br /><small>This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated.</small>"
}
9 changes: 5 additions & 4 deletions lib/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ Controllers.renderAuthnChallenge = async (req, res, next) => {
return next();
}

const keyIds = await parent.getAuthnKeyIds(uid);
const devices = await parent.getAuthnDevices(uid);
let authnOptions;
if (keyIds.length) {
if (devices.length) {
authnOptions = await parent._f2l.assertionOptions();
authnOptions.allowCredentials = keyIds.map(keyId => ({
id: keyId,
authnOptions.allowCredentials = devices.map(d => ({
id: d.id,
type: 'public-key',
transports: ['usb', 'ble', 'nfc'],
}));
Expand All @@ -130,6 +130,7 @@ Controllers.renderAuthnChallenge = async (req, res, next) => {
res.render('login-authn', {
single,
authnOptions,
devices,
next: req.query.next,
});
};
Expand Down
61 changes: 50 additions & 11 deletions library.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,25 @@ plugin.addRoutes = async ({ router, middleware, helpers }) => {
helpers.formatApiResponse(200, res, registrationRequest);
});

routeHelpers.setupApiRoute(router, 'get', '/2factor/authn/devices', middlewares, async (req, res) => {
const devices = await plugin.getAuthnDevices(req.uid);
helpers.formatApiResponse(200, res, { devices });
});

routeHelpers.setupApiRoute(router, 'patch', '/2factor/authn/device', middlewares, async (req, res) => {
const { id, name } = req.body;
if (typeof name !== 'string' || !name.trim()) {
return helpers.formatApiResponse(400, res);
}
await plugin.renameDevice(req.uid, id, name.trim());
helpers.formatApiResponse(200, res);
});

routeHelpers.setupApiRoute(router, 'delete', '/2factor/authn/device/:id', middlewares, async (req, res) => {
await plugin.removeDevice(req.uid, req.params.id);
helpers.formatApiResponse(200, res);
});

routeHelpers.setupApiRoute(router, 'post', '/2factor/authn/register', middlewares, async (req, res) => {
const attestationExpectations = {
challenge: req.session.registrationRequest.challenge,
Expand All @@ -139,7 +158,8 @@ plugin.addRoutes = async ({ router, middleware, helpers }) => {
};
req.body.rawId = Uint8Array.from(atob(base64url.toBase64(req.body.rawId)), c => c.charCodeAt(0)).buffer;
const regResult = await plugin._f2l.attestationResult(req.body, attestationExpectations);
plugin.saveAuthn(req.uid, regResult.authnrData);
const deviceName = typeof req.body.deviceName === 'string' && req.body.deviceName.trim() ? req.body.deviceName.trim() : undefined;
plugin.saveAuthn(req.uid, regResult.authnrData, deviceName);
delete req.session.registrationRequest;
req.session.tfa = true; // eliminate re-challenge on registration

Expand Down Expand Up @@ -177,14 +197,7 @@ plugin.addRoutes = async ({ router, middleware, helpers }) => {
});
});

routeHelpers.setupApiRoute(router, 'delete', '/2factor/authn', middlewares, async (req, res) => {
const { uid } = req;
const keyIds = await db.getObjectKeys(`2factor:webauthn:${uid}`);
await db.sortedSetRemove('2factor:webauthn:counters', keyIds);
await db.delete(`2factor:webauthn:${uid}`);

helpers.formatApiResponse(200, res);
});

routeHelpers.setupApiRoute(router, 'delete', '/2factor/totp', middlewares, async (req, res) => {
await db.deleteObjectField('2factor:uid:key', req.uid);
Expand Down Expand Up @@ -235,6 +248,15 @@ plugin.getAuthnKeyIds = async (uid) => {
return Object.keys(keys);
};

plugin.getAuthnDevices = async (uid) => {
const keyIds = await plugin.getAuthnKeyIds(uid);
const names = await db.getObject(`2factor:webauthn:${uid}:names`) || {};
return keyIds.map(id => ({
id,
name: names[id] || `Device ${keyIds.indexOf(id) + 1}`,
}));
};

plugin.getAuthnPublicKey = async (uid, id) => db.getObjectField(`2factor:webauthn:${uid}`, id);

plugin.getAuthnCount = async id => db.sortedSetScore(`2factor:webauthn:counters`, id);
Expand All @@ -245,12 +267,15 @@ plugin.save = function (uid, key, callback) {
db.setObjectField('2factor:uid:key', uid, key, callback);
};

plugin.saveAuthn = (uid, authnrData) => {
plugin.saveAuthn = async (uid, authnrData, deviceName) => {
const counter = authnrData.get('counter');
const publicKey = authnrData.get('credentialPublicKeyPem');
const id = base64url(authnrData.get('credId'));
db.setObjectField(`2factor:webauthn:${uid}`, id, publicKey);
db.sortedSetAdd(`2factor:webauthn:counters`, counter, id);
await db.setObjectField(`2factor:webauthn:${uid}`, id, publicKey);
await db.sortedSetAdd(`2factor:webauthn:counters`, counter, id);
if (deviceName) {
await db.setObjectField(`2factor:webauthn:${uid}:names`, id, deviceName);
}
};

plugin.hasAuthn = async (uid) => {
Expand Down Expand Up @@ -352,6 +377,20 @@ plugin.disassociate = async (uid) => {
const keyIds = await db.getObjectKeys(`2factor:webauthn:${uid}`);
await db.sortedSetRemove('2factor:webauthn:counters', keyIds);
await db.delete(`2factor:webauthn:${uid}`);
await db.delete(`2factor:webauthn:${uid}:names`);
};

plugin.removeDevice = async (uid, id) => {
const counters = await db.getObjectKeys(`2factor:webauthn:counters`);
if (counters.includes(id)) {
await db.sortedSetRemove('2factor:webauthn:counters', id);
}
await db.deleteObjectField(`2factor:webauthn:${uid}`, id);
await db.deleteObjectField(`2factor:webauthn:${uid}:names`, id);
};

plugin.renameDevice = async (uid, id, newName) => {
await db.setObjectField(`2factor:webauthn:${uid}:names`, id, newName);
};

plugin.overrideUid = async ({ req, locals }) => {
Expand Down
105 changes: 76 additions & 29 deletions static/lib/authn.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,89 @@ define('forum/login-authn', ['api', 'alerts', 'hooks'], function (api, alerts, h
var Plugin = {};

Plugin.init = async () => {
try {
const abortController = new AbortController();
hooks.on('action:ajaxify.start', () => {
abortController.abort();
});
const deviceSelect = document.getElementById('deviceSelect');
const authBtn = document.getElementById('authBtn');

if (deviceSelect && authBtn) {
// Multiple devices - show device selection
authBtn.addEventListener('click', async () => {
const selectedId = deviceSelect.value;
authBtn.disabled = true;
try {
const abortController = new AbortController();
hooks.on('action:ajaxify.start', () => {
abortController.abort();
});

// Build assertion options for the selected device only
const authnOptions = JSON.parse(JSON.stringify(ajaxify.data.authnOptions || {}));
authnOptions.allowCredentials = [{
id: selectedId,
type: 'public-key',
transports: ['usb', 'ble', 'nfc'],
}];

const authResponse = await navigator.credentials.get({
publicKey: ajaxify.data.authnOptions,
signal: abortController.signal,
const authResponse = await navigator.credentials.get({
publicKey: authnOptions,
signal: abortController.signal,
});

api.post(`/plugins/2factor/authn/verify${document.location.search}`, { authResponse }).then(({ next }) => {
ajaxify.go(next.replace(config.relative_path, ''));
}).catch((err) => {
alerts.error(err);
ajaxify.refresh();
});
} catch (e) {
if (e.code !== 20) { // 20 is user canceled
alerts.alert({
title: '[[2factor:title]]',
message: e.message,
timeout: 2500,
});
}
authBtn.disabled = false;
}
});
} else {
// Single device or no device selection - proceed directly
try {
const abortController = new AbortController();
hooks.on('action:ajaxify.start', () => {
abortController.abort();
});

const authResponse = await navigator.credentials.get({
publicKey: ajaxify.data.authnOptions,
signal: abortController.signal,
});

api.post(`/plugins/2factor/authn/verify${document.location.search}`, { authResponse }).then(({ next }) => {
const iconEl = document.getElementById('statusIcon');
iconEl.classList.remove('fa-spinner');
iconEl.classList.remove('fa-spin');
iconEl.classList.add('fa-check');
iconEl.classList.add('text-success');
document.location = next;
}).catch((err) => {
alerts.error(err);
ajaxify.refresh();
});
} catch (e) {
if (e.code !== 20) { // 20 is user canceled
alerts.alert({
title: '[[2factor:title]]',
message: e.message,
timeout: 2500,
});
}

api.post(`/plugins/2factor/authn/verify${document.location.search}`, { authResponse }).then(({ next }) => {
const iconEl = document.getElementById('statusIcon');
iconEl.classList.remove('fa-spinner');
iconEl.classList.remove('fa-spin');
iconEl.classList.add('fa-check');
iconEl.classList.add('text-success');
document.location = next;
}).catch((err) => {
alerts.error(err);
ajaxify.refresh();
});
} catch (e) {
if (e.code !== 20) { // 20 is user canceled
alerts.alert({
title: '[[2factor:title]]',
message: e.message,
timeout: 2500,
});
iconEl.classList.add('fa-times');
iconEl.classList.add('text-danger');
}

const iconEl = document.getElementById('statusIcon');
iconEl.classList.remove('fa-spinner');
iconEl.classList.remove('fa-spin');
iconEl.classList.add('fa-times');
iconEl.classList.add('text-danger');
}
};

Expand Down
Loading