diff --git a/.env b/.env index 994466470..8d31d2f8d 100644 --- a/.env +++ b/.env @@ -63,4 +63,4 @@ PG_STATS_MIN_AGE_MS=1000 # Intended maximum number of long‑running active queries to log. # NOTE: Current SQL has a hard LIMIT 10; change only matters if code is updated # to parameterize the LIMIT. -PG_STATS_TOP_N=10 \ No newline at end of file +PG_STATS_TOP_N=10 diff --git a/backend/db/migrations/20260126144237-extend-user.js b/backend/db/migrations/20260126144237-extend-user.js new file mode 100644 index 000000000..3c7f0fecc --- /dev/null +++ b/backend/db/migrations/20260126144237-extend-user.js @@ -0,0 +1,79 @@ +"use strict"; + +const columns = [ + // Email OTP for 2FA + { + name: "twoFactorOtp", + type: "STRING", + allowNull: true, + defaultValue: null, + }, + { + name: "twoFactorOtpExpiresAt", + type: "DATE", + allowNull: true, + defaultValue: null, + }, + // LDAP support + { + name: "ldapUsername", + type: "STRING", + allowNull: true, + defaultValue: null, + }, + // ORCID support + { + name: "orcidId", + type: "STRING", + allowNull: true, + defaultValue: null, + unique: true, + }, + // SAML support + { + name: "samlNameId", + type: "STRING", + allowNull: true, + defaultValue: null, + unique: true, + }, + // Multi-method 2FA configuration + { + name: "twoFactorMethods", + type: "JSON", + allowNull: false, + defaultValue: [], + }, + { + name: "totpSecret", + type: "STRING", + allowNull: true, + defaultValue: null, + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.changeColumn("user", "email", { + type: Sequelize.STRING, + allowNull: true, + unique: true + }); + + for (const column of columns) { + await queryInterface.addColumn("user", column.name, { + type: Sequelize[column.type], + defaultValue: column.defaultValue, + allowNull: column.allowNull, + ...(column.unique ? { unique: true } : {}), + }); + } + }, + + async down(queryInterface, Sequelize) { + for (const column of columns) { + await queryInterface.removeColumn("user", column.name); + } + }, +}; diff --git a/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js b/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js new file mode 100644 index 000000000..10abdbfa3 --- /dev/null +++ b/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js @@ -0,0 +1,154 @@ +"use strict"; + +const settings = [ + { + key: "system.auth.orcid.enabled", + type: "boolean", + value: false, + description: "Enable ORCID login flow" + }, + { + key: "system.auth.ldap.enabled", + type: "boolean", + value: false, + description: "Enable LDAP login flow" + }, + { + key: "system.auth.saml.enabled", + type: "boolean", + value: false, + description: "Enable SAML login flow" + }, + { + key: "system.auth.local.2fa.required", + type: "boolean", + value: false, + description: "Require 2FA setup for local login users" + }, + { + key: "system.auth.orcid.2fa.required", + type: "boolean", + value: false, + description: "Require 2FA setup for ORCID login users" + }, + { + key: "system.auth.ldap.2fa.required", + type: "boolean", + value: false, + description: "Require 2FA setup for LDAP login users" + }, + { + key: "system.auth.saml.2fa.required", + type: "boolean", + value: false, + description: "Require 2FA setup for SAML login users" + }, + { + key: "system.auth.redirect.baseUrl", + type: "string", + value: "http://localhost:3000", + description: "Frontend base URL for authentication redirects (login success, OAuth callback, and 2FA verification pages)" + }, + { + key: "system.auth.orcid.clientId", + type: "string", + value: "", + description: "ORCID OAuth client id (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.orcid.clientSecret", + type: "string", + value: "", + description: "ORCID OAuth client secret (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.orcid.callbackUrl", + type: "string", + value: "", + description: "ORCID callback URL (changes require a restart of the server)" + }, + { + key: "system.auth.orcid.sandbox", + type: "boolean", + value: true, + description: "Use ORCID sandbox mode (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.url", + type: "string", + value: "", + description: "LDAP server URL (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.bindDN", + type: "string", + value: "", + description: "LDAP bind DN (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.ldap.bindCredentials", + type: "string", + value: "", + description: "LDAP bind password (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.ldap.searchBase", + type: "string", + value: "", + description: "LDAP user search base (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.searchFilter", + type: "string", + value: "(uid={{username}})", + description: "LDAP search filter template (changes require a restart of the server)" + }, + { + key: "system.auth.saml.entryPoint", + type: "string", + value: "", + description: "SAML identity provider entry point (changes require a restart of the server)" + }, + { + key: "system.auth.saml.issuer", + type: "string", + value: "", + description: "SAML service provider issuer (changes require a restart of the server)" + }, + { + key: "system.auth.saml.cert", + type: "text", + value: "", + description: "SAML identity provider certificate (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.saml.callbackUrl", + type: "string", + value: "", + description: "SAML callback URL (changes require a restart of the server)" + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert("setting", + settings.map((setting) => ({ + ...setting, + createdAt: new Date(), + updatedAt: new Date(), + })), + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete("setting", { + key: settings.map((setting) => setting.key) + }, {}); + } +}; diff --git a/backend/db/migrations/20260301134602-extend-setting-terms_2fa_email.js b/backend/db/migrations/20260301134602-extend-setting-terms_2fa_email.js new file mode 100644 index 000000000..01b1a7700 --- /dev/null +++ b/backend/db/migrations/20260301134602-extend-setting-terms_2fa_email.js @@ -0,0 +1,134 @@ +"use strict"; + +const previousTermsTemplate = { + ops: [ + { insert: "Einwilligungserklärung\n", attributes: { header: 2 } }, + { insert: "Ich willige ein, dass meine personenbezogenen Daten (z. B. Name, Benutzerkennung, eingereichte Inhalte, Bewertungen, Interaktionen und IP-Adresse) zum Zweck der Durchführung der Anwendung verarbeitet werden dürfen. Zusätzlich willige ich in die Verarbeitung meiner personenbezogenen Daten für Forschungszwecke ein, sofern ich separat zugestimmt habe.\n\n" }, + + { insert: "Informationen und Datenschutzerklärung\n", attributes: { header: 2 } }, + { insert: "Die Datenverarbeitung erfolgt unter Beachtung geltender Datenschutzgesetze (z. B. DSGVO). Alle Daten werden ausschließlich zu den im Informationsblatt beschriebenen Zwecken verwendet.\n\n" }, + + { insert: "1. Bezeichnung der Verarbeitungstätigkeit\n", attributes: { header: 3 } }, + { insert: "Verarbeitung personenbezogener Daten zur Durchführung der Anwendung und optional zur wissenschaftlichen Auswertung von Nutzungsverhalten und Feedback.\n\n" }, + + { insert: "2. Datenverantwortlicher\n", attributes: { header: 3 } }, + { insert: "Verantwortliche Organisation:\n", attributes: { bold: true } }, + { insert: "[Name der Organisation]\n[Adresse]\n[E-Mail-Adresse oder Kontaktlink]\n\n" }, + + { insert: "3. Datenschutzbeauftragter\n", attributes: { header: 3 } }, + { insert: "[Name/Titel des Datenschutzbeauftragten]\n[Organisation oder Kontaktstelle]\n[Adresse]\n[E-Mail oder Datenschutzlink]\n\n" }, + + { insert: "4. Zweck(e) und Rechtsgrundlage(n) der Verarbeitung\n", attributes: { header: 3 } }, + { insert: "Zwecke:\n", attributes: { bold: true } }, + { insert: "Bewertung und Durchführung der Anwendung\n", attributes: { list: "bullet" } }, + { insert: "Eindeutige Zuordnung über Benutzerkennungen\n", attributes: { list: "bullet" } }, + { insert: "Nutzung anonymisierter Daten für Forschung und Publikation (bei Zustimmung)\n", attributes: { list: "bullet" } }, + { insert: "Analyse des Nutzungsverhaltens (z. B. Klicks, Navigation, Zeitstempel)\n", attributes: { list: "bullet" } }, + { insert: "Technisch notwendige IP-Verarbeitung (nicht gespeichert)\n", attributes: { list: "bullet" } }, + { insert: "\nRechtsgrundlage:\n", attributes: { bold: true } }, + { insert: "Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)\n\n" }, + + { insert: "5. Dauer der Speicherung\n", attributes: { header: 3 } }, + { insert: "Personenbezogene Daten werden für einen definierten Zeitraum (z. B. zwei Jahre nach Abschluss) gespeichert und anschließend gelöscht oder anonymisiert.\n\n" }, + + { insert: "6. Rechte der betroffenen Personen\n", attributes: { header: 3 } }, + { insert: "Recht auf Auskunft\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Berichtigung\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Löschung oder Einschränkung\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Widerspruch\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Datenübertragbarkeit\n", attributes: { list: "bullet" } }, + { insert: "Beschwerderecht bei einer Aufsichtsbehörde\n", attributes: { list: "bullet" } }, + { insert: "\n" }, + + { insert: "7. Widerrufsrecht\n", attributes: { header: 3 } }, + { insert: "Die Einwilligung kann jederzeit widerrufen werden. Die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt unberührt. Nach Anonymisierung ist keine nachträgliche Löschung mehr möglich.\n\n" }, + + { insert: "8. Veröffentlichung anonymisierter Daten\n", attributes: { header: 3 } }, + { insert: "Anonymisierte Ergebnisse können in wissenschaftlichen Publikationen veröffentlicht werden. Die Daten werden unter einer offenen Lizenz (z. B. CC BY-NC 4.0) in geeigneten Repositorien veröffentlicht.\n" } + ] +}; + +const updatedTermsTemplate = { + ops: [ + { insert: "Einwilligungserklärung\n", attributes: { header: 2 } }, + { insert: "Ich willige ein, dass meine personenbezogenen Daten (z. B. Name, Benutzerkennung, eingereichte Inhalte, Bewertungen, Interaktionen und IP-Adresse) zum Zweck der Durchführung der Anwendung verarbeitet werden dürfen. Zusätzlich willige ich in die Verarbeitung meiner personenbezogenen Daten für Forschungszwecke ein, sofern ich separat zugestimmt habe.\n\n" }, + + { insert: "Informationen und Datenschutzerklärung\n", attributes: { header: 2 } }, + { insert: "Die Datenverarbeitung erfolgt unter Beachtung geltender Datenschutzgesetze (z. B. DSGVO). Alle Daten werden ausschließlich zu den im Informationsblatt beschriebenen Zwecken verwendet.\n\n" }, + + { insert: "1. Bezeichnung der Verarbeitungstätigkeit\n", attributes: { header: 3 } }, + { insert: "Verarbeitung personenbezogener Daten zur Durchführung der Anwendung und optional zur wissenschaftlichen Auswertung von Nutzungsverhalten und Feedback.\n\n" }, + + { insert: "2. Datenverantwortlicher\n", attributes: { header: 3 } }, + { insert: "Verantwortliche Organisation:\n", attributes: { bold: true } }, + { insert: "[Name der Organisation]\n[Adresse]\n[E-Mail-Adresse oder Kontaktlink]\n\n" }, + + { insert: "3. Datenschutzbeauftragter\n", attributes: { header: 3 } }, + { insert: "[Name/Titel des Datenschutzbeauftragten]\n[Organisation oder Kontaktstelle]\n[Adresse]\n[E-Mail oder Datenschutzlink]\n\n" }, + + { insert: "4. Zweck(e) und Rechtsgrundlage(n) der Verarbeitung\n", attributes: { header: 3 } }, + { insert: "Zwecke:\n", attributes: { bold: true } }, + { insert: "Bewertung und Durchführung der Anwendung\n", attributes: { list: "bullet" } }, + { insert: "Eindeutige Zuordnung über Benutzerkennungen\n", attributes: { list: "bullet" } }, + { insert: "Sicherstellung der Kontosicherheit durch Zwei-Faktor-Authentifizierung (2FA), einschließlich Versand einmaliger Verifizierungscodes per E-Mail\n", attributes: { list: "bullet" } }, + { insert: "Nutzung anonymisierter Daten für Forschung und Publikation (bei Zustimmung)\n", attributes: { list: "bullet" } }, + { insert: "Analyse des Nutzungsverhaltens (z. B. Klicks, Navigation, Zeitstempel)\n", attributes: { list: "bullet" } }, + { insert: "Technisch notwendige IP-Verarbeitung (nicht gespeichert)\n", attributes: { list: "bullet" } }, + { insert: "\nRechtsgrundlage:\n", attributes: { bold: true } }, + { insert: "Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)\n\n" }, + + { insert: "4a. Hinweise zur E-Mail-Verifizierung (2FA)\n", attributes: { header: 3 } }, + { insert: "Wenn E-Mail-2FA aktiviert ist, werden bei der Anmeldung einmalige Sicherheitscodes an die hinterlegte E-Mail-Adresse gesendet.\n" }, + { insert: "Verarbeitet werden dabei insbesondere:\n", attributes: { bold: true } }, + { insert: "E-Mail-Adresse\n", attributes: { list: "bullet" } }, + { insert: "Zeitpunkt des Versands und Ablaufzeitpunkt des Codes\n", attributes: { list: "bullet" } }, + { insert: "Technische Metadaten zur Missbrauchs- und Fehlerprävention\n", attributes: { list: "bullet" } }, + { insert: "Die Codes werden ausschließlich zur Anmeldung und Kontosicherheit verwendet, nicht für Werbung oder Newsletter.\n\n" }, + + { insert: "5. Dauer der Speicherung\n", attributes: { header: 3 } }, + { insert: "Personenbezogene Daten werden für einen definierten Zeitraum (z. B. zwei Jahre nach Abschluss) gespeichert und anschließend gelöscht oder anonymisiert.\n\n" }, + + { insert: "6. Rechte der betroffenen Personen\n", attributes: { header: 3 } }, + { insert: "Recht auf Auskunft\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Berichtigung\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Löschung oder Einschränkung\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Widerspruch\n", attributes: { list: "bullet" } }, + { insert: "Recht auf Datenübertragbarkeit\n", attributes: { list: "bullet" } }, + { insert: "Beschwerderecht bei einer Aufsichtsbehörde\n", attributes: { list: "bullet" } }, + { insert: "\n" }, + + { insert: "7. Widerrufsrecht\n", attributes: { header: 3 } }, + { insert: "Die Einwilligung kann jederzeit widerrufen werden. Die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt unberührt. Nach Anonymisierung ist keine nachträgliche Löschung mehr möglich.\n\n" }, + + { insert: "8. Veröffentlichung anonymisierter Daten\n", attributes: { header: 3 } }, + { insert: "Anonymisierte Ergebnisse können in wissenschaftlichen Publikationen veröffentlicht werden. Die Daten werden unter einer offenen Lizenz (z. B. CC BY-NC 4.0) in geeigneten Repositorien veröffentlicht.\n" } + ] +}; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkUpdate( + "setting", + { + value: updatedTermsTemplate, + updatedAt: new Date(), + }, + { + key: "app.register.terms", + }, + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkUpdate( + "setting", + { + value: previousTermsTemplate, + updatedAt: new Date(), + }, + { + key: "app.register.terms", + }, + ); + }, +}; diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 0806917e5..46356ee54 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -555,6 +555,20 @@ module.exports = (sequelize, DataTypes) => { emailVerificationToken: DataTypes.STRING, lastPasswordResetEmailSent: DataTypes.DATE, lastVerificationEmailSent: DataTypes.DATE, + // Email OTP for 2FA + twoFactorOtp: DataTypes.STRING, + twoFactorOtpExpiresAt: DataTypes.DATE, + // Multi-method 2FA configuration + twoFactorMethods: { + type: DataTypes.JSON, + defaultValue: [], + }, + // TOTP for 2FA + totpSecret: DataTypes.STRING, + // External login method identifiers + orcidId: DataTypes.STRING, + ldapUsername: DataTypes.STRING, + samlNameId: DataTypes.STRING, }, { sequelize, diff --git a/backend/package-lock.json b/backend/package-lock.json index e10f19b9d..478f27f97 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,8 +27,12 @@ "mustache-express": "^1.3.2", "nodemailer": "^6.9.16", "objects-to-csv": "^1.3.6", + "otpauth": "^9.2.2", "passport": "^0.6.0", + "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "passport-orcid": "^0.0.4", + "passport-saml": "^3.2.4", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.32.1", @@ -53,6 +57,18 @@ "node": ">=18.0.0" } }, + "../utils/modules/editor-delta-conversion": { + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "quill": "^2.0.2", + "quill-delta": "^5.1.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "jest": "^26.6.3" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1152,6 +1168,18 @@ "semver": "bin/semver.js" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -1285,6 +1313,15 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/ldapjs": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", + "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -1329,11 +1366,27 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1437,6 +1490,15 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -1456,6 +1518,15 @@ "node": ">=14.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1622,6 +1693,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/bagpipe": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", @@ -1640,6 +1723,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1653,6 +1745,12 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2204,6 +2302,12 @@ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2477,13 +2581,8 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/editor-delta-conversion": { - "version": "1.0.0", - "resolved": "file:../utils/modules/editor-delta-conversion", - "license": "Apache-2.0", - "dependencies": { - "quill": "^2.0.2", - "quill-delta": "^5.1.0" - } + "resolved": "../utils/modules/editor-delta-conversion", + "link": true }, "node_modules/editorconfig": { "version": "1.0.4", @@ -2816,11 +2915,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3035,6 +3129,15 @@ "type": "^2.7.2" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", @@ -4634,6 +4737,62 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapauth-fork": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz", + "integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==", + "license": "MIT", + "dependencies": { + "@types/ldapjs": "^2.2.2", + "bcryptjs": "^2.4.0", + "ldapjs": "^2.2.1", + "lru-cache": "^7.10.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ldapauth-fork/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4666,11 +4825,6 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -5081,6 +5235,12 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5158,6 +5318,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -5199,11 +5371,6 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, - "node_modules/parchment": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", - "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5247,6 +5414,19 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-ldapauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz", + "integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==", + "license": "MIT", + "dependencies": { + "ldapauth-fork": "^5.0.1", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/passport-local": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", @@ -5258,6 +5438,54 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-orcid": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/passport-orcid/-/passport-orcid-0.0.4.tgz", + "integrity": "sha512-swqn1PIQpzAz0qHXwlBlBaRFkfYXsXJ9o33T11QykCuuxR/UppbHGPgBOnrZaIf/Mytq6uYn8s5C4lAahaMYxQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "^1.5.0" + } + }, + "node_modules/passport-saml": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-3.2.4.tgz", + "integrity": "sha512-JSgkFXeaexLNQh1RrOvJAgjLnZzH/S3HbX/mWAk+i7aulnjqUe7WKnPl1NPnJWqP7Dqsv0I2Xm6KIFHkftk0HA==", + "deprecated": "For versions >= 4, please use scopped package @node-saml/passport-saml", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.6", + "debug": "^4.3.2", + "passport-strategy": "^1.0.0", + "xml-crypto": "^2.1.3", + "xml-encryption": "^2.0.0", + "xml2js": "^0.4.23", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -5540,6 +5768,14 @@ "node": ">=0.10.0" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5631,20 +5867,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/quill": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", - "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", - "dependencies": { - "eventemitter3": "^5.0.1", - "lodash-es": "^4.17.21", - "parchment": "^3.0.0", - "quill-delta": "^5.1.0" - }, - "engines": { - "npm": ">=8.2.3" - } - }, "node_modules/quill-delta": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", @@ -5807,6 +6029,15 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6635,6 +6866,12 @@ "node": ">= 0.8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -6748,6 +6985,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -6928,6 +7205,64 @@ } } }, + "node_modules/xml-crypto": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.6.tgz", + "integrity": "sha512-jjvpO8vHNV8QFhW5bMypP+k4BjBqHe/HrpIwpPcdUnUTIJakSIuN96o3Sdah4tKu2z64kM/JHEH8iEHGCc6Gyw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.9", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xml-encryption": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-2.0.0.tgz", + "integrity": "sha512-4Av83DdvAgUQQMfi/w8G01aJshbEZP9ewjmZMpS9t3H+OCZBDvyK4GJPnHGfWiXlArnPbYvR58JB9qF2x9Ds+Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.0", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -6936,6 +7271,15 @@ "node": ">=0.4.0" } }, + "node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 84e3b86e0..97d11f4ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,12 +36,16 @@ "nodemailer": "^6.9.16", "objects-to-csv": "^1.3.6", "passport": "^0.6.0", + "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "passport-orcid": "^0.0.4", + "passport-saml": "^3.2.4", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.32.1", "sequelize-cli": "^6.4.1", "sequelize-mock": "^0.10.2", + "sequelize-simple-cache": "^1.3.5", "session-file-store": "^1.5.0", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", @@ -51,10 +55,10 @@ "winston": "^3.8.1", "winston-transport": "^4.5.0", "yauzl": "^3.2.0", - "sequelize-simple-cache": "^1.3.5" + "otpauth": "^9.2.2" }, "devDependencies": { "cross-env": "^7.0.3", "jest": "^29.0.1" } -} +} \ No newline at end of file diff --git a/backend/utils/auth.js b/backend/utils/auth.js index b70387526..fedb69629 100644 --- a/backend/utils/auth.js +++ b/backend/utils/auth.js @@ -127,3 +127,13 @@ exports.decodeToken = function decodeToken(token) { return { isValid: false, expired: false, expiryTime: null }; } } + +/** + * Generate a 6-digit OTP (One-Time Password) + * @returns {string} 6-digit OTP + */ +exports.generateOTP = function generateOTP() { + // Generate a random 6-digit number (000000 to 999999) + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + return otp; +} diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 7fd46df6f..01551653c 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -12,6 +12,9 @@ const session = require('express-session'); const bodyParser = require('body-parser'); const Sequelize = require('sequelize'); const LocalStrategy = require("passport-local"); +const OrcidStrategy = require('passport-orcid').Strategy; +const LdapStrategy = require('passport-ldapauth'); +const SamlStrategy = require('passport-saml').Strategy; const {relevantFields} = require("../utils/auth"); const crypto = require("crypto"); const SequelizeStore = require('connect-session-sequelize')(session.Store); @@ -48,6 +51,12 @@ module.exports = class Server { this.availSockets = {}; this.services = {}; this.documentQueues = new Map(); + this.authProviderStatus = { + local: { ready: false, reason: "not-initialized" }, + orcid: { ready: false, reason: "not-initialized" }, + ldap: { ready: false, reason: "not-initialized" }, + saml: { ready: false, reason: "not-initialized" }, + }; // No Caching this.app.disable('etag'); @@ -72,7 +81,9 @@ module.exports = class Server { this.logger.debug("Initializing Passport..."); this.app.use(bodyParser.urlencoded({extended: false})); this.app.use(bodyParser.json()); - this.#loginManagement(); + this.#loginManagement().catch((error) => { + this.logger.error("Failed to initialize login management: " + error); + }); this.app.use(passport.initialize()); this.app.use(passport.session()); @@ -198,42 +209,266 @@ module.exports = class Server { /** * Set the login management (routes and passport) */ - #loginManagement() { - this.logger.debug("Initialize Routes for auth..."); + async #loginManagement() { + this.logger.debug("Initializing Auth Strategies..."); - passport.use(new LocalStrategy(async (username, password, cb) => { + // 1. Setup Passport Session Handling + // Storing the user object + passport.serializeUser(function (user, done) { + done(null, user); + }); - const user = await this.db.models['user'].find(username); - if (!user) { - return cb(null, false, {message: 'Incorrect username or password.'}); - } + // When the session is authenticated, deserializeUser is called + // to retrieve the full user object from DB + passport.deserializeUser(function (user, done) { + done(null, user); + }); - crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => { - if (err) { - return cb(err); - } + // 2. Initialize Strategies + await this.#setupLocalStrategy(); + await this.#setupOrcidStrategy(); + await this.#setupLdapStrategy(); + await this.#setupSamlStrategy(); + } - if (!crypto.timingSafeEqual(Buffer.from(user.passwordHash, 'hex'), hashedPassword)) { - return cb(null, false, {message: 'Incorrect username or password.'}); - } + /** + * Local Strategy Logic + */ + async #setupLocalStrategy() { + passport.use('local-login', new LocalStrategy(async (username, password, cb) => { + try { + const user = await this.db.models['user'].find(username); + if (!user) return cb(null, false, { message: 'Incorrect username or password.' }); - // filter row object, because not everything is the right information for website - return cb(null, relevantFields(user)); - }); + crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => { + if (err) return cb(err); + if (!crypto.timingSafeEqual(Buffer.from(user.passwordHash, 'hex'), hashedPassword)) { + return cb(null, false, { message: 'Incorrect username or password.' }); + } + // filter row object, because not everything is the right information for website + return cb(null, relevantFields(user)); + }); + } catch (e) { return cb(e); } })); + this.authProviderStatus.local = { ready: true, reason: "ready" }; + } + + /** + * ORCID Strategy Logic + */ + async #setupOrcidStrategy() { + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.orcid.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.orcid = { ready: false, reason: "disabled" }; + return; + } + const clientID = await this.db.models['setting'].get("system.auth.orcid.clientId"); + const clientSecret = await this.db.models['setting'].get("system.auth.orcid.clientSecret"); + const callbackURL = await this.db.models['setting'].get("system.auth.orcid.callbackUrl"); + const sandbox = (await this.db.models['setting'].get("system.auth.orcid.sandbox")) === "true"; + + const missing = []; + if (!clientID) missing.push("system.auth.orcid.clientId"); + if (!clientSecret) missing.push("system.auth.orcid.clientSecret"); + if (!callbackURL) missing.push("system.auth.orcid.callbackUrl"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.orcid = { ready: false, reason }; + this.logger.warn(`[Auth] ORCID login enabled but not ready (${reason}).`); + return; + } - // required to work -- defines strategy for storing user information - passport.serializeUser(function (user, done) { - done(null, user); - }); + const config = { + sandbox, + clientID, + clientSecret, + callbackURL, + passReqToCallback: true + }; - // required to work -- defines strategy for loading user information - passport.deserializeUser(function (user, done) { - done(null, user); - }); + try { + passport.use("orcid-login", new OrcidStrategy(config, async (req, accessToken, refreshToken, params, profile, done) => { + try { + const orcidId = params.orcid; + let user = await this.db.models['user'].findOne({ where: { orcidId }, raw: true }); + + if (!user) { + const nameParts = (params.name || "").trim().split(/\s+/); + user = await this.db.models['user'].add({ + orcidId, + firstName: nameParts[0] || null, + lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null, + }, {}); + if (user && user.get) user = user.get({ plain: true }); + } + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.orcid = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.orcid = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize ORCID strategy: ${e.message}`); + } + } + + /** + * LDAP Strategy Logic + */ + async #setupLdapStrategy() { + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.ldap.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.ldap = { ready: false, reason: "disabled" }; + return; + } + + const url = await this.db.models['setting'].get("system.auth.ldap.url"); + const bindDN = await this.db.models['setting'].get("system.auth.ldap.bindDN"); + const bindCredentials = await this.db.models['setting'].get("system.auth.ldap.bindCredentials") || process.env.LDAP_BIND_CREDENTIALS; + const searchBase = await this.db.models['setting'].get("system.auth.ldap.searchBase") || process.env.LDAP_SEARCH_BASE; + const searchFilter = (await this.db.models['setting'].get("system.auth.ldap.searchFilter")) || process.env.LDAP_SEARCH_FILTER || "(uid={{username}})"; + + const missing = []; + if (!url) missing.push("system.auth.ldap.url"); + if (!bindDN) missing.push("system.auth.ldap.bindDN"); + if (!bindCredentials) missing.push("system.auth.ldap.bindCredentials"); + if (!searchBase) missing.push("system.auth.ldap.searchBase"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.ldap = { ready: false, reason }; + this.logger.warn(`[Auth] LDAP login enabled but not ready (${reason}).`); + return; + } + + const serverConfig = { + url, + bindDN, + bindCredentials, + searchBase, + searchFilter + }; + + try { + passport.use('ldap-login', new LdapStrategy({ server: serverConfig, passReqToCallback: true }, + async (req, ldapUser, done) => { + try { + const username = Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid; + const email = [].concat(ldapUser?.mail || ldapUser?.email || [])[0]; + + if (!username) return done(new Error('LDAP user missing UID')); + + let user = await this.db.models['user'].findOne({ where: { ldapUsername: username }, raw: true }); + + if (!user && email) { + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ ldapUsername: username }, { where: { id: user.id } }); + user.ldapUsername = username; // Update local reference + } + } + + if (!user) { + user = await this.db.models['user'].add({ + ldapUsername: username, + email, + firstName: [].concat(ldapUser?.givenName || ldapUser?.cn || 'New')[0], + lastName: [].concat(ldapUser?.sn || 'User')[0] + }); + if (user && user.get) user = user.get({ plain: true }); + } + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.ldap = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.ldap = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize LDAP strategy: ${e.message}`); + } + } + + /** + * SAML Strategy Logic + */ + async #setupSamlStrategy() { + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.saml.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.saml = { ready: false, reason: "disabled" }; + return; + } + + const entryPoint = await this.db.models['setting'].get("system.auth.saml.entryPoint"); + const issuer = await this.db.models['setting'].get("system.auth.saml.issuer"); + const callbackUrl = await this.db.models['setting'].get("system.auth.saml.callbackUrl"); + const rawCert = (await this.db.models['setting'].get("system.auth.saml.cert")); + const cert = typeof rawCert === "string" ? rawCert.replace(/\\n/g, "\n") : rawCert; + + const missing = []; + if (!entryPoint) missing.push("system.auth.saml.entryPoint"); + if (!issuer) missing.push("system.auth.saml.issuer"); + if (!callbackUrl) missing.push("system.auth.saml.callbackUrl"); + if (!cert) missing.push("system.auth.saml.cert"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.saml = { ready: false, reason }; + this.logger.warn(`[Auth] SAML login enabled but not ready (${reason}).`); + return; + } + + const config = { + entryPoint, + issuer, + callbackUrl, + cert + }; + + try { + passport.use('saml-login', new SamlStrategy(config, async (profile, done) => { + try { + const nameId = profile?.nameID; + if (!nameId) return done(null, false, { message: "Missing SAML NameID." }); + + let user = await this.db.models['user'].findOne({ where: { samlNameId: nameId }, raw: true }); + + if (!user) { + const emailAttr = profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; + const email = Array.isArray(emailAttr) ? emailAttr[0] : emailAttr; + if (email) { + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ samlNameId: nameId, email }, { where: { id: user.id } }); + user.samlNameId = nameId; + } + } + } + if (!user) return done(null, false, { message: "SAML account not linked." }); + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.saml = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.saml = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize SAML strategy: ${e.message}`); + } + } + + /** + * Returns true if provider strategy is initialized and ready. + * @param {string} provider - local|orcid|ldap|saml + * @returns {boolean} + */ + isAuthProviderReady(provider) { + return !!this.authProviderStatus?.[provider]?.ready; + } + + /** + * Get detailed provider status. + * @param {string} provider + * @returns {{ready:boolean,reason:string}|{ready:false,reason:string}} + */ + getAuthProviderStatus(provider) { + return this.authProviderStatus?.[provider] || { ready: false, reason: "unknown-provider" }; } /** @@ -331,12 +566,18 @@ module.exports = class Server { this.io.use(wrap(passport.session())); this.io.use((socket, next) => { const session = socket.request.session; - if (session && "passport" in session) { + if (session && "passport" in session && !session.twoFactorPending) { socket.request.session.touch(); socket.request.session.save(); next(); + } else if (session && session.twoFactorPending) { + socket.emit("logout"); // force client back to auth flow + this.logger.warn("Websocket blocked: 2FA verification pending."); + socket.disconnect(); } else { - socket.request.session.destroy(); + if (socket.request.session) { + socket.request.session.destroy(); + } socket.emit("logout"); //force logout on client side this.logger.warn("Session in websocket not available! Send logout..."); socket.disconnect(); @@ -502,4 +743,4 @@ module.exports = class Server { } } -} \ No newline at end of file +} diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index fc10d7ac4..cb37a5765 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -7,7 +7,8 @@ * @author Nils Dycke, Dennis Zyska */ const passport = require('passport'); -const { generateToken, decodeToken } = require('../../utils/auth'); +const { TOTP, Secret } = require('otpauth'); +const { generateToken, decodeToken, generateOTP } = require('../../utils/auth'); /** * Route for user management @@ -15,6 +16,10 @@ const { generateToken, decodeToken } = require('../../utils/auth'); */ module.exports = function (server) { + // ========================================== + // HELPER FUNCTIONS + // ========================================== + /** * Helper function to get the base URL from settings */ @@ -23,6 +28,44 @@ module.exports = function (server) { return baseUrl || "localhost:3000"; // fallback to default if not set } + /** + * Helper function to normalize base URLs. + * Ensures protocol is present and strips trailing slashes. + * @param {string} value + * @param {string} fallback + * @returns {string} + */ + function normalizeBaseUrl(value, fallback) { + const rawValue = (value || fallback || "").trim(); + const withProtocol = /^https?:\/\//i.test(rawValue) ? rawValue : `http://${rawValue}`; + return withProtocol.replace(/\/+$/, ""); + } + + /** + * Helper function to get frontend base URL used for auth redirects. + */ + async function getFrontendBaseUrl() { + const frontendBaseUrl = await server.db.models['setting'].get("system.auth.redirect.baseUrl"); + return normalizeBaseUrl(frontendBaseUrl, "http://localhost:3000"); + } + + /** + * Build a frontend URL from base URL, path, and optional query object. + * @param {string} frontendBaseUrl + * @param {string} path + * @param {Object} query + * @returns {string} + */ + function buildFrontendUrl(frontendBaseUrl, path, query = {}) { + const safePath = `/${(path || "").replace(/^\/+/, "")}`; + const queryString = new URLSearchParams( + Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== "") + ).toString(); + return queryString + ? `${frontendBaseUrl}${safePath}?${queryString}` + : `${frontendBaseUrl}${safePath}`; + } + /** * Helper function to get password reset token expiry from settings */ @@ -76,11 +119,266 @@ module.exports = function (server) { return { allowed: true }; } + /** + * Get enabled 2FA methods from a user record. + * @param {Object} user - The user object + * @returns {string[]} array of enabled 2FA methods + */ + function getTwoFactorMethods(user) { + if (!user) return []; + + if (Array.isArray(user.twoFactorMethods)) { + return user.twoFactorMethods.filter((m) => !!m); + } + + return []; + } + + async function isLoginMethodEnabled(method) { + return (await server.db.models['setting'].get(`system.auth.loginMethods.${method}.enabled`)) === "true"; + } + + async function sendEmailOtp(userRecord) { + if (!userRecord || !userRecord.email) { + throw new Error("Email address missing for email 2FA."); + } + + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: userRecord.id } } + ); + + await server.sendMail( + userRecord.email, + "CARE - Two-Factor Authentication Code", + `Hello ${userRecord.userName}, +Your two-factor authentication code is: ${otp} + +This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + +Thanks, +The CARE Team` + ); + } + + /** + * Request OTP again for 2FA verification + * Called after password verification when user has 2FA enabled + * Uses session to track 2FA state + */ + async function resendEmailOtp(req, res) { + // Check if session has 2FA pending state + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + // Must have selected email 2FA + if (req.session.twoFactorPending.method !== 'email') { + return res.status(400).json({ message: "Email 2FA is not the selected method. Please select email first." }); + } + + try { + const { userId } = req.session.twoFactorPending; + + // Get user details + const user = await server.db.models['user'].findOne({ + where: { id: userId } + }); + + const methods = getTwoFactorMethods(user); + + if (!user || !methods.includes('email')) { + // Clear invalid session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "Email 2FA is not enabled for this user." }); + } + + if (!user.email) { + return res.status(400).json({ message: "User email not found. Cannot send OTP." }); + } + + await sendEmailOtp(user); + + return res.status(200).json({ + message: "OTP has been sent to your email address.", + expiresIn: 10 // minutes + }); + + } catch (error) { + server.logger.error("Failed to request OTP: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + } + + /** + * Start 2FA login flow. + * - If exactly 1 method is configured: start it immediately (send email OTP if needed). + * - If multiple methods are configured: require the client to select one via /auth/2fa/select. + * + * Returns true if a response has been sent (2FA flow started / selection required). + */ + async function startTwoFactorLogin(req, res, userId, options = { mode: 'json' }) { + const mode = options.mode || 'json'; + + // 1.Get user record and 2fa methods + const dbUser = await server.db.models['user'].findByPk(userId, { raw: true }); + if (!dbUser) return false; + + const methods = getTwoFactorMethods(dbUser); + if (!methods || methods.length === 0) return false; + + // 2. Initialize 2FA Session + req.session.twoFactorPending = { + userId: dbUser.id, + methods: methods, + method: null + }; + + let responseData = { requiresTwoFactor: true, methods }; + let redirectPath = "/2fa/select"; + + // 3. Handle single method vs multiple methods + if (methods.length === 1) { + const method = methods[0]; + req.session.twoFactorPending.method = method; + + try { + // Execute 2fa relevant operation + await performTwoFactorAction(dbUser, method); + + // Set results for a single 2FA method + responseData.selectionRequired = false; + responseData.method = method; + redirectPath = getTwoFactorRedirectPath(method); + } catch (err) { + server.logger.error(`2FA Initialization failed: ${err.message}`); + return res.status(err.status || 500).json({ message: err.message }); + } + } else { + // Multiple methods available; requiring frontend to display selection page + responseData.selectionRequired = true; + } + + // 4. Unified handling of session persistence and response + const frontendBaseUrl = mode === "redirect" ? await getFrontendBaseUrl() : null; + + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session: " + err); + return res.status(500).json({ message: "Session error during 2FA." }); + } + + if (mode === 'redirect') { + const finalUrl = buildFrontendUrl(frontendBaseUrl, redirectPath); + return res.redirect(finalUrl); + } + return res.status(200).json(responseData); + }); + + return true; + } + + async function performTwoFactorAction(user, method) { + if (method === 'email') { + if (!user.email) { + throw { status: 400, message: "Email not found for this user." }; + } + await sendEmailOtp(user); + } else if (method === 'totp') { + if (!user.totpSecret) { + throw { status: 400, message: "TOTP is not configured." }; + } + } else { + throw { status: 400, message: `Unsupported 2FA method: ${method}` }; + } + } + + /** + * Map redirect paths + */ + function getTwoFactorRedirectPath(method) { + const paths = { + email: "/2fa/verify/email", + totp: "/2fa/verify/totp" + }; + return paths[method] || "/login?error=unsupported-method"; + } + + /** + * Finalizes the authentication process by establishing a passport session, + * updating login records, and handling the HTTP response. + * @param {Object} req - The Express request object. + * @param {Object} res - The Express response object. + * @param {Object} user - The user object to be logged in. + * @param {Object} options - Configuration for the response. + * @param {string} [options.mode='json'] - The response mode: 'json' or 'redirect'. + * @param {string} [options.redirectPath='/dashboard'] - The path to redirect to if mode is 'redirect'. + * @returns {Promise} + */ + async function finalizeLogin(req, res, user, options = { mode: 'json', redirectPath: '/dashboard' }) { + const mode = options.mode || 'json'; + + // 1. Establish the Passport session + req.logIn(user, async (err) => { + if (err) { + server.logger.error(`[Auth] Passport login failed for user ${user.id}: ${err}`); + return res.status(500).json({ message: "Failed to establish login session." }); + } + + // 2. Cleanup: Remove 2FA pending state if it exists + if (req.session.twoFactorPending) { + delete req.session.twoFactorPending; + } + + // 3. Post-login activities (Non-blocking database updates) + try { + // Standardizing the login registration logic + await server.db.models['user'].registerUserLogin(user.id); + } catch (dbError) { + server.logger.error(`[Auth] Failed to record login activity for user ${user.id}: ${dbError}`); + } + + // 4. Ensure session is persisted before responding + req.session.save(async (saveErr) => { + if (saveErr) { + server.logger.error(`[Auth] Session save failed for user ${user.id}: ${saveErr}`); + } + + if (mode === 'redirect') { + const frontendBaseUrl = await getFrontendBaseUrl(); + const finalUrl = buildFrontendUrl(frontendBaseUrl, options.redirectPath); + return res.redirect(finalUrl); + } + + return res.status(200).json({ user }); + }); + }); + } + + function ensureAuthenticated(req, res, next) { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ + message: 'Authentication required' + }); + } + + // ========================================== + // AUTHENTICATION ROUTES + // ========================================== + /** * Login Procedure */ server.app.post('/auth/login', function (req, res, next) { - passport.authenticate('local', async function (err, user, info) { + passport.authenticate('local-login', async function (err, user, info) { if (err) { server.logger.error("Login failed: " + err); return res.status(500).send("Failed to login"); @@ -101,26 +399,125 @@ module.exports = function (server) { }); } - req.logIn(user, async function (err) { - if (err) { - return next(err); - } + + // Start 2FA if configured for this user + const twoFactorHandled = await startTwoFactorLogin(req, res, user.id, { mode: 'json' }); + // 2FA response has been sent; stop normal login flow + if (twoFactorHandled) return; + + // No 2FA required, proceed with normal login + return finalizeLogin(req, res, user, { mode: 'json' }); + })(req, res, next); + }); - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); + /** + * LDAP login method (JSON-based, similar to /auth/login) + */ + server.app.post('/auth/login/ldap', async function (req, res, next) { + if (!(await isLoginMethodEnabled('ldap'))) { + return res.status(403).json({ message: "LDAP login is disabled by the administrator." }); + } + if (!server.isAuthProviderReady('ldap')) { + const status = server.getAuthProviderStatus('ldap'); + return res.status(503).json({ + message: "LDAP login is enabled but not fully configured. Please contact an administrator.", + reason: status.reason + }); + } + passport.authenticate('ldap-login', async function (err, user, info) { + if (err) { + server.logger.error("LDAP login failed: " + err); + return res.status(500).send("Failed to login"); + } + if (!user) { + return res.status(401).send(info || { message: "LDAP login failed." }); + } - await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); - await transaction.commit(); - } catch (e) { - await transaction.rollback(); - } + const handled = await startTwoFactorLogin(req, res, user.id, { mode: 'json' }); + if (handled) return; - res.status(200).send({user: user}); - }); + return finalizeLogin(req, res, user, { mode:'json'}); })(req, res, next); }); + /** + * ORCID login method + */ + server.app.get('/auth/login/orcid', async function (req, res, next) { + const frontendBaseUrl = await getFrontendBaseUrl(); + if (!(await isLoginMethodEnabled('orcid'))) { + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "orcid-login-disabled" })); + } + if (!server.isAuthProviderReady('orcid')) { + const status = server.getAuthProviderStatus('orcid'); + server.logger.warn(`[Auth] ORCID requested but provider is not ready (${status.reason}).`); + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "orcid-login-not-ready" })); + } + return passport.authenticate('orcid-login')(req, res, next); + }); + + server.app.get('/auth/2fa/orcid/callback', + async function (req, res, next) { + const frontendBaseUrl = await getFrontendBaseUrl(); + if (!(await isLoginMethodEnabled('orcid'))) { + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "orcid-login-disabled" })); + } + if (!server.isAuthProviderReady('orcid')) { + const status = server.getAuthProviderStatus('orcid'); + server.logger.warn(`[Auth] ORCID callback hit but provider is not ready (${status.reason}).`); + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "orcid-login-not-ready" })); + } + const failureRedirect = buildFrontendUrl(frontendBaseUrl, "/login", { error: "orcid-login-failed" }); + return passport.authenticate('orcid-login', { failureRedirect })(req, res, next); + }, + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorLogin(req, res, user.id, { mode: 'redirect'}); + if (handled) return; + + return finalizeLogin(req, res, user, { mode:'redirect'}); + } + ); + + /** + * SAML login method + */ + server.app.get('/auth/login/saml', async function (req, res, next) { + const frontendBaseUrl = await getFrontendBaseUrl(); + if (!(await isLoginMethodEnabled('saml'))) { + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "saml-login-disabled" })); + } + if (!server.isAuthProviderReady('saml')) { + const status = server.getAuthProviderStatus('saml'); + server.logger.warn(`[Auth] SAML requested but provider is not ready (${status.reason}).`); + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "saml-login-not-ready" })); + } + return passport.authenticate('saml-login')(req, res, next); + }); + + server.app.post('/auth/login/saml/callback', + async function (req, res, next) { + const frontendBaseUrl = await getFrontendBaseUrl(); + if (!(await isLoginMethodEnabled('saml'))) { + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "saml-login-disabled" })); + } + if (!server.isAuthProviderReady('saml')) { + const status = server.getAuthProviderStatus('saml'); + server.logger.warn(`[Auth] SAML callback hit but provider is not ready (${status.reason}).`); + return res.redirect(buildFrontendUrl(frontendBaseUrl, "/login", { error: "saml-login-not-ready" })); + } + const failureRedirect = buildFrontendUrl(frontendBaseUrl, "/login", { error: "saml-login-failed" }); + return passport.authenticate('saml-login', { failureRedirect })(req, res, next); + }, + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorLogin(req, res, user.id, { mode: 'redirect'}); + if (handled) return; + + return finalizeLogin(req, res, user, { mode: 'redirect'}); + } + ); + /** * Logout Procedure, no feedback needed since vuex also deletes the session */ @@ -148,6 +545,10 @@ module.exports = function (server) { server.logger.debug(`req.user: ${JSON.stringify(req.user)}`); }); + // ========================================== + // REGISTRATION & EMAIL VERIFICATION ROUTES + // ========================================== + /** * Register Procedure */ @@ -519,4 +920,437 @@ The CARE Team` return res.status(500).json({message: "Internal server error"}); } }); + + // ========================================== + // 2FA VERIFICATION ROUTES (Login Flow) + // ========================================== + + /** + * Select a 2FA method when multiple are configured. + * If email is selected, the OTP will be sent after selection. + */ + server.app.post('/auth/2fa/select', async function (req, res) { + const { method } = req.body; + + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + const pending = req.session.twoFactorPending; + if (!method || !pending.methods || !Array.isArray(pending.methods)) { + return res.status(400).json({ message: "Missing 2FA method selection." }); + } + + if (!pending.methods.includes(method)) { + return res.status(400).json({ message: "Selected 2FA method is not enabled for this user." }); + } + + // Load user for any required side effects (email OTP send / totp secret existence) + const userRecord = await server.db.models['user'].findOne({ + where: { id: pending.userId }, + raw: true, + }); + + if (!userRecord) { + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } + + pending.method = method; + req.session.twoFactorPending = pending; + + try { + if (method === 'email') { + if (!userRecord.email) { + return res.status(400).json({ message: "Email address not found. Cannot use email 2FA." }); + } + await sendEmailOtp(userRecord); + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'email' }); + }); + return; + } + + if (method === 'totp') { + if (!userRecord.totpSecret) { + return res.status(400).json({ message: "TOTP is enabled but not configured (missing secret)." }); + } + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'totp' }); + }); + return; + } + + return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); + } catch (e) { + server.logger.error("Failed to apply 2FA selection: " + e); + return res.status(500).json({ message: "Failed to start selected 2FA method." }); + } + }); + + /** + * Verify OTP and complete login + * Uses session to track 2FA state + */ + server.app.post('/auth/2fa/email/verify', async function (req, res) { + const { otp } = req.body; + + if (!otp) { + return res.status(400).json({ message: "OTP is required." }); + } + + // Check if session has 2FA pending state + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + if (req.session.twoFactorPending.method !== 'email') { + return res.status(400).json({ message: "Email 2FA is not the selected method." }); + } + + try { + const { userId } = req.session.twoFactorPending; + + // Get user details + const user = await server.db.models['user'].findOne({ + where: { id: userId } + }); + + if (!user) { + // Clear invalid session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } + + // Verify OTP + if (!user.twoFactorOtp || user.twoFactorOtp !== otp) { + return res.status(401).json({ message: "Invalid OTP code." }); + } + + // Check if OTP has expired + if (!user.twoFactorOtpExpiresAt || new Date() > new Date(user.twoFactorOtpExpiresAt)) { + await server.db.models['user'].update( + { twoFactorOtp: null, twoFactorOtpExpiresAt: null }, + { where: { id: user.id } } + ); + // Clear session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "OTP has expired. Please request a new one." }); + } + + // OTP is valid - clear OTP from database + await server.db.models['user'].update( + { twoFactorOtp: null, twoFactorOtpExpiresAt: null }, + { where: { id: user.id } } + ); + + // Complete login + return finalizeLogin(req, res, user, { mode: 'json' }); + } catch (error) { + server.logger.error("Failed to verify OTP: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); + + server.app.post('/auth/2fa/otp/resend', resendEmailOtp); + + /** + * Verify TOTP and complete login + */ + server.app.post('/auth/2fa/totp/verify', + async function (req, res, next) { + const token = String(req.body.token || '').replace(/\s/g, ''); + if (!token) { + return res.status(400).json({ message: 'TOTP token is required.' }); + } + + const pending = req.session?.twoFactorPending; + if (!pending?.userId) { + return res.status(401).json({ message: 'Invalid TOTP code' }); + } + + try { + const user = await server.db.models['user'].findOne({ + where: { id: pending.userId }, + raw: true, + }); + if (!user || !user.totpSecret) { + return res.status(401).json({ message: 'Invalid TOTP code' }); + } + + const totp = new TOTP({ secret: user.totpSecret, digits: 6, period: 30 }); + if (totp.validate({ token, window: 1 }) === null) { + return res.status(401).json({ message: 'Invalid TOTP code' }); + } + + // Complete login + return finalizeLogin(req, res, user, { mode: 'json' }); + } catch (err) { + return next(err); + } + } + ); + + /** + * Initiate TOTP setup (authenticated user). + */ + server.app.post('/auth/2fa/totp/setup/initiate', ensureAuthenticated, async function (req, res) { + const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const secret = new Secret({ size: 20 }); + const secretBase32 = secret.base32; + const totp = new TOTP({ + issuer: 'CARE', + label: `CARE (${user.userName})`, + secret, + digits: 6, + period: 30, + }); + const otpauthUrl = totp.toString(); + + req.session.totpSetupPending = { + secretBase32: secretBase32, + }; + + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session for TOTP setup: " + err); + return res.status(500).json({ message: "Failed to initiate TOTP setup." }); + } + return res.status(200).json({ + otpauthUrl: otpauthUrl, + secretBase32: secretBase32, + }); + }); + }); + + /** + * Verify TOTP setup (authenticated user) and persist secret. + */ + server.app.post('/auth/2fa/totp/setup/verify', + ensureAuthenticated, + async function (req, res, next) { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ message: "TOTP token is required." }); + } + + if (!req.session?.totpSetupPending?.secretBase32) { + return res.status(400).json({ + message: "No pending TOTP setup found." + }); + } + + const secretBase32 = req.session.totpSetupPending.secretBase32; + const originalUserId = req.user.id; + + const totp = new TOTP({ secret: secretBase32, digits: 6, period: 30 }); + if (totp.validate({ token: String(token).trim(), window: 1 }) === null) { + return res.status(401).json({ message: "Invalid TOTP code." }); + } + + try { + const dbUser = await server.db.models['user'].findOne({ + where: { id: originalUserId } + }); + + if (!dbUser) { + return res.status(404).json({ message: "User not found." }); + } + + const currentMethods = Array.isArray(dbUser.twoFactorMethods) + ? [...dbUser.twoFactorMethods] + : []; + if (!currentMethods.includes('totp')) { + currentMethods.push('totp'); + } + + await server.db.models['user'].update( + { + totpSecret: secretBase32, + twoFactorMethods: currentMethods + }, + { where: { id: dbUser.id } } + ); + + delete req.session.totpSetupPending; + + return res.status(200).json({ + message: "TOTP configured successfully.", + twoFactorMethods: currentMethods + }); + } catch (err) { + return next(err); + } + } + ); + + /** + * Get 2FA status for current user + */ + server.app.get('/auth/2fa/status', async function (req, res) { + if (!req.user) { + return res.status(401).json({ message: "You must be logged in to check 2FA status." }); + } + + try { + const user = await server.db.models['user'].findOne({ + where: { id: req.user.id }, + attributes: [ + 'twoFactorMethods', + 'totpSecret', + 'email', + 'orcidId' + ] + }); + + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const methods = getTwoFactorMethods(user); + const hasTotp = methods.includes('totp') && !!user.totpSecret; + + return res.status(200).json({ + twoFactorMethods: methods, + hasEmail: methods.includes('email'), + hasTotp: hasTotp, + email: user.email || null, + orcidId: user.orcidId || null, + }); + + } catch (error) { + server.logger.error("Failed to get 2FA status: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); + + /** + * Enable 2FA for a user + */ + server.app.post('/auth/2fa/enable', async function (req, res) { + if (!req.user) { + return res.status(401).json({ message: "You must be logged in to enable 2FA." }); + } + + const { method } = req.body; + + // Supported 2FA methods: email and totp + if (!method || !['email', 'totp'].includes(method)) { + return res.status(400).json({ message: "Valid 2FA method is required (email or totp)." }); + } + + try { + const user = await server.db.models['user'].findOne({ + where: { id: req.user.id } + }); + + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + if (method === 'email' && !user.email) { + return res.status(400).json({ message: "Email address is required to enable email 2FA." }); + } + + if (method === 'totp') { + return res.status(400).json({ message: "Use /auth/2fa/totp/setup/initiate and /auth/2fa/totp/setup/verify to enable TOTP (requires setup + verification)." }); + } + + // Compute updated list of 2FA methods + const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; + if (!currentMethods.includes(method)) { + currentMethods.push(method); + } + + const updateData = { + twoFactorMethods: currentMethods, + }; + + // Enable 2FA + await server.db.models['user'].update( + updateData, + { where: { id: user.id } } + ); + + return res.status(200).json({ + message: `2FA has been enabled with ${method} method.`, + twoFactorMethods: currentMethods, + }); + + } catch (error) { + server.logger.error("Failed to enable 2FA: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); + + /** + * Disable a specific 2FA method for the current user. + * If this was the last enabled method, 2FA is fully disabled and related fields are cleared. + */ + server.app.post('/auth/2fa/disable/:method', ensureAuthenticated, async function (req, res) { + const method = req.params.method; + + if (!['email', 'totp'].includes(method)) { + return res.status(400).json({ message: "Valid 2FA method is required (email or totp)." }); + } + + try { + const user = await server.db.models['user'].findOne({ + where: { id: req.user.id } + }); + + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const currentMethods = getTwoFactorMethods(user); + + if (!currentMethods.includes(method)) { + return res.status(400).json({ message: `2FA method '${method}' is not enabled for this user.` }); + } + + const updatedMethods = currentMethods.filter((m) => m !== method); + + const updateData = { + twoFactorMethods: updatedMethods, + }; + + // If we remove email, clear any pending email OTP state + if (method === 'email') { + updateData.twoFactorOtp = null; + updateData.twoFactorOtpExpiresAt = null; + } + + // If we remove TOTP, clear the TOTP secret + if (method === 'totp') { + updateData.totpSecret = null; + } + + // If no methods remain, fully disable 2FA state + if (updatedMethods.length === 0) { + updateData.twoFactorOtp = null; + updateData.twoFactorOtpExpiresAt = null; + updateData.totpSecret = null; + } + + await server.db.models['user'].update( + updateData, + { where: { id: user.id } } + ); + + return res.status(200).json({ + message: `2FA method '${method}' has been disabled.`, + twoFactorMethods: updatedMethods, + }); + + } catch (error) { + server.logger.error("Failed to disable 2FA method: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); } diff --git a/backend/webserver/routes/config.js b/backend/webserver/routes/config.js index a86a1e996..363fdc493 100644 --- a/backend/webserver/routes/config.js +++ b/backend/webserver/routes/config.js @@ -28,6 +28,9 @@ module.exports = function (server) { "app.login.guest": await server.db.models['setting'].get("app.login.guest"), "app.login.forgotPassword": await server.db.models['setting'].get("app.login.forgotPassword"), "app.register.emailVerification": await server.db.models['setting'].get("app.register.emailVerification"), + "system.auth.loginMethods.orcid.enabled": await server.db.models['setting'].get("system.auth.loginMethods.orcid.enabled"), + "system.auth.loginMethods.ldap.enabled": await server.db.models['setting'].get("system.auth.loginMethods.ldap.enabled"), + "system.auth.loginMethods.saml.enabled": await server.db.models['setting'].get("system.auth.loginMethods.saml.enabled"), "app.landing.showDocs": await server.db.models['setting'].get("app.landing.showDocs"), "app.landing.linkDocs": await server.db.models['setting'].get("app.landing.linkDocs"), "app.landing.showProject": await server.db.models['setting'].get("app.landing.showProject"), diff --git a/frontend/package.json b/frontend/package.json index 20325331d..0047492f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "pg": "^8.11.0", "pg-hstore": "^2.3.4", "quill": "^2.0.2", + "qrcode": "^1.5.4", "scroll-into-view": "^1.16.2", "socket.io-client": "^4.6.2", "uuid": "^8.3.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b96a43102..bf54f7a3b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,6 +25,10 @@
+
@@ -42,6 +46,7 @@ import {createTable} from "@/store/utils"; import axios from "axios"; import getServerURL from "@/assets/serverUrl"; import ConsentModal from "@/auth/ConsentModal.vue"; +import TwoFactorSettingsModal from "@/auth/TwoFactorSettingsModal.vue"; import BehaviorLogger from "@/assets/behaviorLogger"; import {computed} from "vue"; @@ -52,7 +57,7 @@ import {computed} from "vue"; */ export default { name: "App", - components: {TopBar, Toast, Loader, ConsentModal}, + components: {TopBar, Toast, Loader, ConsentModal, TwoFactorSettingsModal}, provide() { return { acceptStats: computed(() => this.acceptStats), @@ -67,8 +72,8 @@ export default { systemRoles: false, }, disconnected: false, - isTermsConsented: false, behaviorLogger: null, + postLoginModalFlowToken: 0, } }, sockets: { @@ -83,6 +88,7 @@ export default { }, logout: function () { // if not authenticated, backend will always send logout event + this.resetAppLoadState(); this.$socket.disconnect(); this.$router.push({ name: "login", @@ -98,7 +104,6 @@ export default { appUser: function (data) { this.$store.commit("auth/SET_USER", data); this.loaded.users = true; - this.isTermsConsented = data.acceptTerms; }, appSettings: function (data) { this.$store.commit("settings/setSettings", data); @@ -144,6 +149,27 @@ export default { return false; } }, + hasTwoFactorConfigured() { + const user = this.$store.getters["auth/getUser"]; + const methods = Array.isArray(user?.twoFactorMethods) ? user.twoFactorMethods : []; + return methods.length > 0; + }, + isTwoFactorRequired() { + return this.$store.getters["settings/getValue"]("system.auth.2fa.required") === "true"; + }, + isTermsConsented() { + return !!this.$store.getters["auth/getUser"]?.acceptTerms; + }, + shouldShowConsentModal() { + return this.requireAuth && this.appLoaded && !this.isTermsConsented; + }, + shouldForceTwoFactorSetup() { + return this.requireAuth && + this.appLoaded && + this.isTermsConsented && + this.isTwoFactorRequired && + !this.hasTwoFactorConfigured; + }, requireAuth() { return ( this.$route.meta.requireAuth !== undefined && @@ -161,21 +187,23 @@ export default { } }, "$route.meta.requireAuth"(newValue, oldValue) { - if (newValue !== oldValue) { - this.connect(); + if (newValue === oldValue) return; + if (newValue) { + this.resetAppLoadState(); // Call this method only when transitioning into protected area, false -> true } + this.connect(); }, - '$data': { - handler() { - if (this.appLoaded && !this.isTermsConsented) { - this.$nextTick(() => { - if (this.$refs.consentModal) { - this.$refs.consentModal.open(); - } - }); - } - }, - deep: true + shouldForceTwoFactorSetup() { + this.syncPostLoginModalFlow(); + }, + shouldShowConsentModal() { + this.syncPostLoginModalFlow(); + }, + isTermsConsented() { + this.syncPostLoginModalFlow(); + }, + appLoaded() { + this.syncPostLoginModalFlow(); }, // Initialize logger after settings are loaded because we access the settings table 'loaded.settings': { @@ -207,9 +235,24 @@ export default { } }, methods: { + resetAppLoadState() { + this.loaded = { + users: false, + tables: false, + settings: false, + systemRoles: false, + }; + }, connect() { - if (this.$route.meta.requireAuth && !this.$socket.connected) { + if (!this.$route.meta.requireAuth) return; + + if (!this.$socket.connected) { this.$socket.connect(); + return; + } + + if (!this.appLoaded) { + this.$socket.emit("appInit"); } }, initializeBehaviorLogger() { @@ -218,6 +261,40 @@ export default { this.behaviorLogger.init(); } }, + syncPostLoginModalFlow() { + if (!this.requireAuth || !this.appLoaded) { + return; + } + + this.$nextTick(() => { + if (this.shouldShowConsentModal) { + if (this.$refs.twoFactorSettingsModal?.isVisible()) { + this.$refs.twoFactorSettingsModal.close(); + } + if (this.$refs.consentModal) { + this.$refs.consentModal.open(); + } + return; + } + + if (this.shouldForceTwoFactorSetup) { + if (this.$refs.consentModal?.isVisible()) { + this.$refs.consentModal.close(); + } + if (this.$refs.twoFactorSettingsModal) { + this.$refs.twoFactorSettingsModal.open(); + } + return; + } + + if (this.$refs.consentModal?.isVisible()) { + this.$refs.consentModal.close(); + } + if (this.$refs.twoFactorSettingsModal?.isVisible()) { + this.$refs.twoFactorSettingsModal.close(); + } + }); + }, }, }; @@ -258,4 +335,4 @@ html, body { position: absolute; margin: 0; } - \ No newline at end of file + diff --git a/frontend/src/auth/ConsentModal.vue b/frontend/src/auth/ConsentModal.vue index be55cfac3..47f860b64 100644 --- a/frontend/src/auth/ConsentModal.vue +++ b/frontend/src/auth/ConsentModal.vue @@ -5,6 +5,8 @@ name="terms" disable-keyboard remove-close + @show="handleModalShow" + @hide="handleModalHide" >