diff --git a/languages/en-GB/2factor.json b/languages/en-GB/2factor.json
index 4bf5649..f3f864d 100644
--- a/languages/en-GB/2factor.json
+++ b/languages/en-GB/2factor.json
@@ -43,9 +43,17 @@
"authn.modal.content": "A request to register your hardware key has been sent, please authenticate by activating the key now
",
"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",
diff --git a/languages/fr/2factor.json b/languages/fr/2factor.json
index 65bfc96..9d15ec6 100644
--- a/languages/fr/2factor.json
+++ b/languages/fr/2factor.json
@@ -48,5 +48,13 @@
"admin.deactivate.search": "Recherche d'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.
This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated."
}
diff --git a/languages/it/2factor.json b/languages/it/2factor.json
index acd207f..1e04a19 100644
--- a/languages/it/2factor.json
+++ b/languages/it/2factor.json
@@ -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.
This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated."
}
diff --git a/languages/ko/2factor.json b/languages/ko/2factor.json
index eacf655..76939d8 100644
--- a/languages/ko/2factor.json
+++ b/languages/ko/2factor.json
@@ -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)를 활성화하도록 강제합니다.
이는 관리자 및 중재자와 같은 특권 사용자의 보안을 강화하는 데 유용합니다."
}
diff --git a/languages/pl/2factor.json b/languages/pl/2factor.json
index b362417..3f226e0 100644
--- a/languages/pl/2factor.json
+++ b/languages/pl/2factor.json
@@ -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.
Funkcja ta przekłada się na wyższy poziom bezpieczeństwa tam gdzie w grę wchodzą uprawnienia administratora czy moderatora."
}
diff --git a/languages/ru/2factor.json b/languages/ru/2factor.json
index 8caa8ac..9feead8 100644
--- a/languages/ru/2factor.json
+++ b/languages/ru/2factor.json
@@ -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 у пользователей, принадлежащих к выбранным группам.
Это полезно для повышения безопасности, гарантируя, что у привилегированных пользователей, таких как администраторы и модераторы, эта функция активирована."
}
diff --git a/languages/zh-CN/2factor.json b/languages/zh-CN/2factor.json
index 41618d1..5b4154e 100644
--- a/languages/zh-CN/2factor.json
+++ b/languages/zh-CN/2factor.json
@@ -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.
This is useful to enhance security by ensuring privileged users such as administrators and moderators have this activated."
}
diff --git a/lib/controllers.js b/lib/controllers.js
index 4884b89..ab7dec7 100644
--- a/lib/controllers.js
+++ b/lib/controllers.js
@@ -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'],
}));
@@ -130,6 +130,7 @@ Controllers.renderAuthnChallenge = async (req, res, next) => {
res.render('login-authn', {
single,
authnOptions,
+ devices,
next: req.query.next,
});
};
diff --git a/library.js b/library.js
index a698fd4..361782a 100644
--- a/library.js
+++ b/library.js
@@ -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,
@@ -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
@@ -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);
@@ -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);
@@ -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) => {
@@ -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 }) => {
diff --git a/static/lib/authn.js b/static/lib/authn.js
index 2686621..4d17684 100644
--- a/static/lib/authn.js
+++ b/static/lib/authn.js
@@ -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');
}
};
diff --git a/static/lib/settings.js b/static/lib/settings.js
index 992712f..86b513c 100644
--- a/static/lib/settings.js
+++ b/static/lib/settings.js
@@ -12,6 +12,11 @@ define('forum/account/2factor', ['api', 'alerts', 'bootbox'], function (api, ale
const action = e.target.getAttribute('data-action');
Settings[action].call(e.target);
});
+
+ // Render device list if WebAuthn is enabled
+ if (ajaxify.data.hasAuthn) {
+ Settings.renderDevicesList();
+ }
};
Settings.setupTotp = function () {
@@ -51,6 +56,86 @@ define('forum/account/2factor', ['api', 'alerts', 'bootbox'], function (api, ale
});
};
+ Settings.getAuthnDevices = async () => {
+ try {
+ const response = await api.get('/plugins/2factor/authn/devices');
+ return response.devices || [];
+ } catch (e) {
+ alerts.error(e);
+ return [];
+ }
+ };
+
+ Settings.renderDevicesList = async () => {
+ const devices = await Settings.getAuthnDevices();
+ const itemEl = document.querySelector('[data-action="setupAuthn"]').closest('.list-group-item');
+ let devicesHtml = '';
+ if (devices.length > 0) {
+ devicesHtml = '
[[2factor:authn.login.select]]
+[[2factor:authn.login.lead]]
[[2factor:authn.login.info]]
+