From a3bf0cfdebf1dd97d8d520571d4a3cbe6fd9acc6 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 13 Jan 2026 14:14:46 +0000 Subject: [PATCH] Server: Add support for MFA (#14081) --- .../ios/Joplin.xcodeproj/project.pbxproj | 2 - packages/app-mobile/ios/Podfile.lock | 4 +- packages/lib/services/rest/routes/notes.ts | 2 - packages/server/.gitignore | 1 + packages/server/jest.setup.js | 2 +- packages/server/package.json | 6 +- packages/server/public/css/main.css | 29 +- packages/server/schema.sqlite | Bin 344064 -> 380928 bytes packages/server/src/db.replication.test.ts | 5 +- packages/server/src/db.ts | 6 + packages/server/src/env.ts | 14 + .../server/src/middleware/ownerHandler.ts | 3 +- ...20230811161334_add_totp_secret_to_users.ts | 14 + .../migrations/20231024095410_applications.ts | 38 +++ .../20240122182559_recovery_codes.ts | 18 ++ .../src/models/ApplicationModel.test.ts | 48 ++++ .../server/src/models/ApplicationModel.ts | 256 ++++++++++++++++++ packages/server/src/models/BaseModel.ts | 14 +- packages/server/src/models/EmailModel.ts | 7 +- packages/server/src/models/ItemModel.test.ts | 59 ---- packages/server/src/models/ItemModel.ts | 88 ++++-- .../server/src/models/ItemResourceModel.ts | 5 +- .../server/src/models/RecoveryCodeModel.ts | 161 +++++++++++ .../server/src/models/SessionModel.test.ts | 8 +- packages/server/src/models/SessionModel.ts | 44 ++- packages/server/src/models/ShareModel.ts | 9 +- packages/server/src/models/ShareUserModel.ts | 9 +- packages/server/src/models/TokenModel.ts | 9 + packages/server/src/models/UserModel.test.ts | 159 ++++++++--- packages/server/src/models/UserModel.ts | 145 +++++++--- packages/server/src/models/factory.ts | 9 + packages/server/src/models/utils/user.ts | 5 + .../server/src/routes/admin/users.test.ts | 72 ++++- packages/server/src/routes/admin/users.ts | 57 ++-- .../admin/utils/users/impersonate.test.ts | 18 +- .../routes/admin/utils/users/impersonate.ts | 10 +- .../src/routes/api/application_auth.test.ts | 105 +++++++ .../server/src/routes/api/application_auth.ts | 16 ++ packages/server/src/routes/api/items.test.ts | 1 + packages/server/src/routes/api/items.ts | 5 +- .../server/src/routes/api/sessions.test.ts | 90 ++++++ packages/server/src/routes/api/sessions.ts | 25 +- .../server/src/routes/api/share_users.test.ts | 17 +- .../src/routes/api/shares.folder.test.ts | 114 ++++---- packages/server/src/routes/api/shares.ts | 4 +- packages/server/src/routes/api/users.test.ts | 9 + packages/server/src/routes/api/users.ts | 6 +- packages/server/src/routes/api/utils/types.ts | 11 + .../src/routes/index/applications.test.ts | 132 +++++++++ .../server/src/routes/index/applications.ts | 126 +++++++++ packages/server/src/routes/index/home.ts | 2 +- .../server/src/routes/index/login.test.ts | 127 ++++++++- packages/server/src/routes/index/login.ts | 87 +++++- packages/server/src/routes/index/mfa.test.ts | 105 +++++++ packages/server/src/routes/index/mfa.ts | 118 ++++++++ .../server/src/routes/index/password.test.ts | 7 +- packages/server/src/routes/index/privacy.ts | 8 +- .../src/routes/index/recovery_codes.test.ts | 173 ++++++++++++ .../server/src/routes/index/recovery_codes.ts | 106 ++++++++ .../server/src/routes/index/stripe.test.ts | 30 +- packages/server/src/routes/index/stripe.ts | 85 ++---- packages/server/src/routes/index/terms.ts | 6 +- packages/server/src/routes/index/upgrade.ts | 9 +- .../server/src/routes/index/users.test.ts | 36 ++- packages/server/src/routes/index/users.ts | 35 ++- packages/server/src/routes/routes.ts | 9 + .../server/src/services/MustacheService.ts | 9 +- packages/server/src/services/TaskService.ts | 2 + .../src/services/UserDeletionService.test.ts | 29 +- .../src/services/UserDeletionService.ts | 1 + .../server/src/services/database/types.ts | 150 ++++++---- .../server/src/tools/debug/createTestUsers.ts | 24 +- .../src/tools/debug/populateDatabase.ts | 6 +- .../src/tools/debug/truncateUserDataTables.ts | 24 ++ packages/server/src/tools/generateTypes.ts | 1 + packages/server/src/utils/crypto.test.ts | 19 ++ packages/server/src/utils/crypto.ts | 49 +++- packages/server/src/utils/errors.ts | 1 + packages/server/src/utils/joplinUtils.ts | 18 +- packages/server/src/utils/routeUtils.ts | 25 +- packages/server/src/utils/stripe.ts | 66 ++++- packages/server/src/utils/testing/apiUtils.ts | 2 +- .../server/src/utils/testing/shareApiUtils.ts | 8 + .../server/src/utils/testing/testUtils.ts | 62 ++++- packages/server/src/utils/time.ts | 12 + packages/server/src/utils/types.ts | 1 + packages/server/src/utils/urlUtils.test.ts | 10 + packages/server/src/utils/urlUtils.ts | 42 ++- packages/server/src/utils/uuid.ts | 38 +++ packages/server/src/utils/validation.ts | 8 + packages/server/src/views/admin/user.mustache | 1 + .../server/src/views/admin/users.mustache | 2 +- .../emails/recoveryCodesAccessedTemplate.ts | 24 ++ .../index/applications/applications.mustache | 33 +++ .../views/index/applications/confirm.mustache | 22 ++ packages/server/src/views/index/help.md | 79 +++++- packages/server/src/views/index/home.mustache | 16 +- .../src/views/index/items/note.mustache | 4 +- .../server/src/views/index/login.mustache | 82 ++++-- packages/server/src/views/index/mfa.mustache | 60 ++++ .../src/views/index/recovery_codes.mustache | 46 ++++ .../views/index/recovery_codes/auth.mustache | 39 +++ .../server/src/views/index/upgrade.mustache | 4 + packages/server/src/views/index/user.mustache | 25 +- .../server/src/views/layouts/default.mustache | 2 +- packages/tools/cspell/dictionary1.txt | 1 + packages/tools/cspell/dictionary2.txt | 2 + packages/tools/cspell/dictionary3.txt | 1 + packages/tools/cspell/dictionary4.txt | 15 + packages/tools/git-changelog.ts | 9 +- packages/utils/url.test.ts | 13 +- packages/utils/url.ts | 7 +- yarn.lock | 138 +++++++++- 113 files changed, 3589 insertions(+), 583 deletions(-) create mode 100644 packages/server/src/migrations/20230811161334_add_totp_secret_to_users.ts create mode 100644 packages/server/src/migrations/20231024095410_applications.ts create mode 100644 packages/server/src/migrations/20240122182559_recovery_codes.ts create mode 100644 packages/server/src/models/ApplicationModel.test.ts create mode 100644 packages/server/src/models/ApplicationModel.ts create mode 100644 packages/server/src/models/RecoveryCodeModel.ts create mode 100644 packages/server/src/routes/api/application_auth.test.ts create mode 100644 packages/server/src/routes/api/application_auth.ts create mode 100644 packages/server/src/routes/api/utils/types.ts create mode 100644 packages/server/src/routes/index/applications.test.ts create mode 100644 packages/server/src/routes/index/applications.ts create mode 100644 packages/server/src/routes/index/mfa.test.ts create mode 100644 packages/server/src/routes/index/mfa.ts create mode 100644 packages/server/src/routes/index/recovery_codes.test.ts create mode 100644 packages/server/src/routes/index/recovery_codes.ts create mode 100644 packages/server/src/tools/debug/truncateUserDataTables.ts create mode 100644 packages/server/src/utils/crypto.test.ts create mode 100644 packages/server/src/utils/urlUtils.test.ts create mode 100644 packages/server/src/utils/uuid.ts create mode 100644 packages/server/src/utils/validation.ts create mode 100644 packages/server/src/views/emails/recoveryCodesAccessedTemplate.ts create mode 100644 packages/server/src/views/index/applications/applications.mustache create mode 100644 packages/server/src/views/index/applications/confirm.mustache create mode 100644 packages/server/src/views/index/mfa.mustache create mode 100644 packages/server/src/views/index/recovery_codes.mustache create mode 100644 packages/server/src/views/index/recovery_codes/auth.mustache diff --git a/packages/app-mobile/ios/Joplin.xcodeproj/project.pbxproj b/packages/app-mobile/ios/Joplin.xcodeproj/project.pbxproj index fc8d7f3361..843d01007b 100644 --- a/packages/app-mobile/ios/Joplin.xcodeproj/project.pbxproj +++ b/packages/app-mobile/ios/Joplin.xcodeproj/project.pbxproj @@ -345,7 +345,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf", @@ -365,7 +364,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", diff --git a/packages/app-mobile/ios/Podfile.lock b/packages/app-mobile/ios/Podfile.lock index 9d3f84e416..ce6e5ac966 100644 --- a/packages/app-mobile/ios/Podfile.lock +++ b/packages/app-mobile/ios/Podfile.lock @@ -2306,7 +2306,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8 Expo: c8f323f74218c45c46e27eed40d8a53ba50667c3 @@ -2319,7 +2319,7 @@ SPEC CHECKSUMS: fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd - glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7 JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860 diff --git a/packages/lib/services/rest/routes/notes.ts b/packages/lib/services/rest/routes/notes.ts index 957db0bb32..26556d59a1 100644 --- a/packages/lib/services/rest/routes/notes.ts +++ b/packages/lib/services/rest/routes/notes.ts @@ -214,12 +214,10 @@ async function tryToGuessExtFromMimeType(response: any, mediaPath: string) { return newMediaPath; } - const getFileExtension = (url: string, isDataUrl: boolean) => { let fileExt = isDataUrl ? mimeUtils.toFileExtension(mimeUtils.fromDataUrl(url)) : safeFileExtension(fileExtension(url).toLowerCase()); if (!mimeUtils.fromFileExtension(fileExt)) fileExt = ''; // If the file extension is unknown - clear it. if (fileExt) fileExt = `.${fileExt}`; - return fileExt; }; diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 822b19ef76..fa0aefa3a6 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -7,4 +7,5 @@ db-*.sqlite logs/ tests/temp/ temp/ +resource/ .env \ No newline at end of file diff --git a/packages/server/jest.setup.js b/packages/server/jest.setup.js index 4e757c3cb3..d2df5420d2 100644 --- a/packages/server/jest.setup.js +++ b/packages/server/jest.setup.js @@ -8,6 +8,6 @@ shimInit({ nodeSqlite }); // tests can take more time since we do integration testing too. The share tests // in particular can take a while. -jest.setTimeout(60 * 1000); +jest.setTimeout(120 * 1000); process.env.JOPLIN_IS_TESTING = '1'; diff --git a/packages/server/package.json b/packages/server/package.json index d2bee5b050..93adc88a23 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,6 +29,7 @@ "@joplin/renderer": "~3.5", "@joplin/utils": "~3.5", "@koa/cors": "3.4.3", + "@types/qrcode": "1.5.6", "@types/uuid": "10.0.0", "bcryptjs": "2.4.3", "bulma": "1.0.4", @@ -47,15 +48,17 @@ "node-os-utils": "1.3.7", "nodemailer": "6.10.1", "nodemon": "3.1.10", + "otplib": "12.0.1", "pg": "8.16.3", "pretty-bytes": "5.6.0", "prettycron": "0.10.0", + "qrcode": "1.5.3", "query-string": "7.1.3", "rate-limiter-flexible": "7.2.0", "raw-body": "3.0.1", "samlify": "2.10.1", "sqlite3": "5.1.6", - "stripe": "8.222.0", + "stripe": "13.9.0", "uuid": "11.1.0", "yargs": "17.7.2", "zxcvbn": "4.4.2" @@ -82,6 +85,7 @@ "jest-expect-message": "1.1.3", "jsdom": "26.1.0", "node-mocks-http": "1.17.2", + "short-uuid": "4.2.0", "source-map-support": "0.5.21", "typescript": "5.8.3" } diff --git a/packages/server/public/css/main.css b/packages/server/public/css/main.css index c7cfed6538..16d70ce2db 100644 --- a/packages/server/public/css/main.css +++ b/packages/server/public/css/main.css @@ -6,12 +6,17 @@ html { font-size: 14px; } +h2 { + border-bottom: 1px solid #ddd; + padding-bottom: 0.5em; +} + .is-admin-page div.main-container, .is-admin-page div.navbar-container { max-width: none !important; } -div.navbar-container { +.is-admin-page div.navbar-container { padding: 0 3rem; } @@ -102,3 +107,25 @@ abbr[title] { text-underline-offset: 2px; text-decoration: underline dotted; } + +#login-form { + max-width: 400px; +} + +#recovery-codes li { + opacity: 0.8; +} + +#recovery-codes li>span { + font-size: 1.3em; + font-family: monospace; +} + +#recovery-codes li>span[data-is-code-used="1"] { + text-decoration: line-through; + opacity: 0.6; +} + +.application-item:nth-of-type(odd) { + background-color: #f9f9f9; +} \ No newline at end of file diff --git a/packages/server/schema.sqlite b/packages/server/schema.sqlite index 0a4cc803a4f0dae17fc57ea210bb10bf680a1c7c..a5719fb18fde95821e04f703817ea36760d5125f 100644 GIT binary patch delta 3758 zcmc&%Yj6|S72c~|Y4!BlU|W{u$2!5-!ba@MlCYByFfrj_lNwtFo7k+@@(P4yNoZwk z96*Sa4yDsZnVsfl+RlU_G|gnvwAd6Gl8`c;(*A*vK0@0G#epelcntBhO@EM1dw18y zQfe~OX*ykdug{*xJ?EZt&v&m694I<))S9*zY{M|D54{HTYS4TB`T7RiHq06PSc|?0 z>LwboKI$IzDfJ0;^TcSJDo2dAR6ZKLDOp(AjfOIZ1&vPfghb}I6|@*^`rG=``lP;1 zUrGLnJTbxNZzPj0rxttKpmi}W56d#kS+;>;7%mtZ zZl+1Yx}s7I@K~wJ&M1^q+H7B>P)ntxomQwN(s8@$ z%^_W!k=9Cox2yBjNDr4itmxH9tz`~{S}g4;^UhEw%hXt^r8{Ly6y8HpS-ES5>MU3N z($eAbI)!JKCd*YloAgn+8bOIJX$82)Af@vP$C+YNT=ncBUJt2NzC=^*0jt2Pfj;NSekp|6CnWZ1Mxp ztH(?jMj}FoUhPBybCD{2@bbpf-+5`ZYeUmhV?zxIp}Tus_n!8So}rPoW9{?jV}4!u z;UrOF&W>pOz)h-ne&sI^ooWXUG&|)7pxuKOWd$*eYR~mn?+{0UrUzgB1qST3!ymO2H*>fy>qYEPUWX1AaQN zgOArXcpY?PI2@)2gdjg0hUpBjXKGJ?l_2xMIA}3s3f~5AEGX`3i3J;dL#?4spZkdo z!p45z&?wUifA=mZH)Rz@UaN0@T^X}Wu*Wg#8+qT$!!6HQ?B-t64~+MWYYkWRZaqo9 zMn0+gKsQQE5&Mb7_*s0jW+#|N2FjE9O&?ybfqSOF?aaRG;EzP+S094cL1yY>U@U$> zbjQ-G#*doljLEOL$`#LF-%~%;fb=r+3CyHDhne0sEj6|o?i#*rcvK(No}+$C?WL;c ze)4{q*JYX3ZbJDj1xCv>F=Dk^!E@U)mp9?nxM>zp5uQR%7hVlN^Wm=&mT4>o@17*W z#G$%sz*G&=@V4MKye=a+f#{Wji<#@%Z9B9sCY5%dK`Qw*N^vk9+ zgAze}`jsSc3pMZsTz5-1sw>nUC&Kt;ybJVV7c74!-yvJgQ>J%JD-5q{U(+)Bus$rO zpInI=txBy#)`(_0XkA+lQZ=E-fG{cwq9~s|*#{BYXk3nP%f=@<)-?Op(CeRSS<_AX zztG4}Mt3~^1);(j%$D0x&F#>r(o;z{uRVIh1NtNiVJq>Vr5|kvl!H6)*AxVtm z!wDh2ywT0E^Dujn-@jU$t3k@HEIO;>MCE+FpN?$F=G=^Ai&P`{K8NSEX7lA#$mg}( z$Bsm*fV4Itr`CUWiqbzE3GEsdvaP#d)k#p4%x1}fsfudOvt_a~+0U~DseIB}T@4Ni zN~*GTvT5JUdVI~R+t$$8B@<_+QlJM8ZpHKAXH%d~ALB)FBpM%(l9uv9bl;J;k)Php z#}$ZTy$;B}2^PXt*8!oAh53YhbA#{Q1Vx%q4DQ{A+u((pz$Sg7S+TU9_J?^f!SR6r z+KyZzG$i=x{?PVNB$17C3A%==J$KD6Ne7FVH|Yp^F`+{tu^`diLJH zs2gY-O;`?Gq1^L>mJFpPvIe8RPMPzLSO(3v&2jypekHp7rpOwEP6iU+lCr}Wdhu>e zsubSs#rriW8|>(lze^O{s8z0e9IO24J&dQUqylDYX%((AJe~Py6aI__2F?--%uEeR zuMm;@WB^|5#7p3dKD-R3uK_(AJWCkic_01|{Cy{0Sds;XbCpDQTKL{+qQn#u(Ipx+ zQiKn~sxw3ttnEVc{Q5LeT=9Ub_Dm5Wf0ihO-|WJzcy5Dl@a|0&k=7Lskg8PLAouD# zx4(1wJ!&iDGUt@lX2_M!w!=Yts*tog9N^hqN;~9g<$iVNwnVPbPeXnyUaGW0f&34~ zT*7j;z7}O|ScLoEBKnib)I#zi;ltpY_vPf5 z=jELL^PFefpmy7~8CiW^0K+i9@c;Lm-fNqu3t*+2?vR2BXYY%SI`$F!kiD-Q{H!;{ z8dKqPG}#dp>=j#t#Vn>9tcat%iMu3Pelb zNdwa2eGyI=<|11$G#Fiov~aaCcP<5Y8D9`>o@?;55yg4N;IBrc!1E6 zV^KWW5{|beyVFIkaFNqd6t65VZ=Y{Z^i(cSz1r36^=xXkyGu7_>?=~WEw%3U*xH8n zR5DuHQ0ZQ$!`<}sd&`9h1MCDQd>A&&)L&z9A#ivi_%{MLk<{|UAi0PA-?VkNN7_2Q zE7xVJ#WKZ#U9vy7=CqAt?6{zfXB^-ftC_xvq3rwnYc};nT&af5;l@~FdCi(-tu4Mr zU(?#@6Q+-aRb+wkNd+Sg@Efa{KKPlw`e)>Y0H~iYX~ndl1Y@gJld5G(i(*o-o7PZw zseM$Q>=Wr(=`N{;I7qbNe=x^E3DbkWF8PVcm$bqz^*w&?5U2u#(31=|FbTT&%fnzX z4bRFS@cCbWHaH4^k2`k@6QKL(LR!0`j9L2opgh`Ux2~d6KB) zZ=53909PEuPta5Vqfs#D<%ydT>l-;?-qQptk#z811@M>z@99@_nM { expect(result.items.length).toBe(0); // But we still get the item because it doesn't use the slave database - expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 1'); + expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 1'); // After sync, we should get the change await sqliteSyncSlave(db(), dbSlave()); @@ -130,7 +131,7 @@ describe('db.replication', () => { expect(result.items.length).toBe(0); // But we get the latest item if requesting it directly - expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 2'); + expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 2'); // After sync, we should get the change await sqliteSyncSlave(db(), dbSlave()); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 27d8d617d8..6e15cc0d3b 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -158,6 +158,12 @@ export const isSqlite = (db: DbConnection) => { return clientType(db) === DatabaseConfigClient.SQLite; }; +export const getEmptyIp = (db: DbConnection): string | null => { + // PostgreSQL uses inet type which doesn't accept empty strings, only null or valid IPs + // SQLite uses string type with NOT NULL constraint, so we use empty strings + return isPostgres(db) ? null : ''; +}; + export const setCollateC = async (db: DbConnection, tableName: string, columnName: string): Promise => { if (!isPostgres(db)) return; await db.raw(`ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET DATA TYPE character varying(32) COLLATE "C"`); diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index 4c992c2c03..cfef1592e8 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -56,6 +56,8 @@ const defaultEnvValues: EnvVariables = { USER_CONTENT_BASE_URL: '', API_BASE_URL: '', JOPLINAPP_BASE_URL: 'https://joplinapp.org', + TERMS_URL: '', + PRIVACY_URL: '', // ================================================== // Database config @@ -130,6 +132,13 @@ const defaultEnvValues: EnvVariables = { USER_DATA_AUTO_DELETE_ENABLED: false, USER_DATA_AUTO_DELETE_AFTER_DAYS: 90, + // ================================================== + // ================================================== + // MFA - 32+ bytes hex string + // ================================================== + MFA_ENCRYPTION_KEY: '', + MFA_ENABLED: 0, + // ================================================== // Events deletion // ================================================== @@ -205,6 +214,8 @@ export interface EnvVariables { USER_CONTENT_BASE_URL: string; API_BASE_URL: string; JOPLINAPP_BASE_URL: string; + TERMS_URL: string; + PRIVACY_URL: string; DB_CLIENT: string; DB_SLOW_QUERY_LOG_ENABLED: boolean; @@ -253,6 +264,9 @@ export interface EnvVariables { USER_DATA_AUTO_DELETE_ENABLED: boolean; USER_DATA_AUTO_DELETE_AFTER_DAYS: number; + MFA_ENCRYPTION_KEY: string; + MFA_ENABLED: number; + EVENTS_AUTO_DELETE_ENABLED: boolean; EVENTS_AUTO_DELETE_AFTER_DAYS: number; diff --git a/packages/server/src/middleware/ownerHandler.ts b/packages/server/src/middleware/ownerHandler.ts index 10af240ec9..324c67548b 100644 --- a/packages/server/src/middleware/ownerHandler.ts +++ b/packages/server/src/middleware/ownerHandler.ts @@ -2,8 +2,9 @@ import { AppContext, KoaNext } from '../utils/types'; import { contextSessionId } from '../utils/requestUtils'; export default async function(ctx: AppContext, next: KoaNext): Promise { + const models = ctx.joplin.models; const sessionId = contextSessionId(ctx, false); - const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null; + const owner = sessionId ? await models.session().sessionUser(sessionId) : null; ctx.joplin.owner = owner; return next(); } diff --git a/packages/server/src/migrations/20230811161334_add_totp_secret_to_users.ts b/packages/server/src/migrations/20230811161334_add_totp_secret_to_users.ts new file mode 100644 index 0000000000..0a2b9cbe5d --- /dev/null +++ b/packages/server/src/migrations/20230811161334_add_totp_secret_to_users.ts @@ -0,0 +1,14 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export const up = async (db: DbConnection) => { + await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => { + table.string('totp_secret').defaultTo('').notNullable(); + }); +}; + +export const down = async (db: DbConnection) => { + await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => { + table.dropColumn('totp_secret'); + }); +}; diff --git a/packages/server/src/migrations/20231024095410_applications.ts b/packages/server/src/migrations/20231024095410_applications.ts new file mode 100644 index 0000000000..44b6ff4d55 --- /dev/null +++ b/packages/server/src/migrations/20231024095410_applications.ts @@ -0,0 +1,38 @@ +import { Knex } from 'knex'; +import { DbConnection, isPostgres } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.createTable('applications', (table: Knex.CreateTableBuilder) => { + table.uuid('id').unique().notNullable(); + table.string('user_id', 32).notNullable().defaultTo(''); + table.text('password', 'mediumtext').notNullable().defaultTo(''); + + table.string('version', 16).notNullable().defaultTo(''); + table.integer('platform').notNullable(); + if (isPostgres(db)) { + table.specificType('ip', 'inet'); + } else { + table.string('ip', 64).notNullable(); + } + table.integer('type').notNullable(); + + table.bigInteger('updated_time').notNullable(); + table.bigInteger('created_time').notNullable(); + table.bigInteger('last_access_time').nullable().defaultTo(0); + + table.index('user_id'); + }); + + await db.schema.alterTable('sessions', (table: Knex.CreateTableBuilder) => { + table.uuid('application_id').nullable().defaultTo(null); + + table.index('application_id'); + }); +} + +export async function down(db: DbConnection): Promise { + await db.schema.dropTable('applications'); + await db.schema.alterTable('sessions', (table: Knex.CreateTableBuilder) => { + table.dropColumn('application_id'); + }); +} diff --git a/packages/server/src/migrations/20240122182559_recovery_codes.ts b/packages/server/src/migrations/20240122182559_recovery_codes.ts new file mode 100644 index 0000000000..8065d35dab --- /dev/null +++ b/packages/server/src/migrations/20240122182559_recovery_codes.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.createTable('recovery_codes', (table: Knex.CreateTableBuilder) => { + table.uuid('id').unique().notNullable(); + table.string('user_id', 32).notNullable().defaultTo(''); + table.string('code', 16).notNullable().defaultTo(''); + table.specificType('is_used', 'smallint').defaultTo(1).notNullable(); + + table.bigInteger('updated_time').notNullable(); + table.bigInteger('created_time').notNullable(); + }); +} + +export async function down(db: DbConnection): Promise { + await db.schema.dropTable('recovery_codes'); +} diff --git a/packages/server/src/models/ApplicationModel.test.ts b/packages/server/src/models/ApplicationModel.test.ts new file mode 100644 index 0000000000..4bd49b21d9 --- /dev/null +++ b/packages/server/src/models/ApplicationModel.test.ts @@ -0,0 +1,48 @@ +import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils'; +import { AccountType } from './UserModel'; + +describe('ApplicationModel', () => { + + beforeAll(async () => { + await beforeAllDb('ApplicationModel'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should throw if applicationAuthId is not an uuid', async () => { + expect(models().application().createAppPassword('not-uuid')).rejects.toThrow('Application not authorized yet.'); + }); + + test('should generate a notification after an application is authorized', async () => { + const user = await models().user().save({ + email: 'test@example.com', + password: '111111', + }); + await models().application().createPreLoginRecord('mock-application-id', '127.0.0.1', 'mock-version', 'mock-platform', 'mock-type'); + await models().application().onAuthorizeUse('mock-application-id', user.id); + + const notifications = await models().notification().allUnreadByUserId(user.id); + expect(notifications.length).toBe(1); + expect(notifications[0].message).toBe('You have successfully authorised your application'); + }); + + test('should register the application with the subscription when creating the app password', async () => { + const { user } = await models().subscription().saveUserAndSubscription( + 'toto@example.com', + 'Toto', + AccountType.Pro, + 'STRIPE_USER_ID', + 'STRIPE_SUB_ID', + ); + + const appId = 'mock-application-id'; + await models().application().createPreLoginRecord(appId, '127.0.0.1', 'mock-version', 'mock-platform', 'mock-type'); + await models().application().onAuthorizeUse(appId, user.id); + }); +}); diff --git a/packages/server/src/models/ApplicationModel.ts b/packages/server/src/models/ApplicationModel.ts new file mode 100644 index 0000000000..65bb88bd2e --- /dev/null +++ b/packages/server/src/models/ApplicationModel.ts @@ -0,0 +1,256 @@ + +import BaseModel, { AclAction, UuidType } from './BaseModel'; +import { Application, NotificationLevel, User, Uuid } from '../services/database/types'; +import { createSecureRandom } from '@joplin/lib/uuid'; +import { hashPassword, checkPassword } from '../utils/auth'; +import { ErrorBadRequest, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors'; +import { ApplicationPlatform, ApplicationType } from '@joplin/lib/types'; +import { validate } from 'uuid'; +import Logger from '@joplin/utils/Logger'; +import { NotificationKey } from './NotificationModel'; +import { getEmptyIp } from '../db'; + +const logger = Logger.create('ApplicationModel'); + +export type ActiveApplication = Pick; + +export type AppAuthResponse = { + password: string; + id: string; +}; + + +type Client = { + ip: string; + version?: string; + platform?: ApplicationPlatform; + type?: ApplicationType; +}; + +type ApplicationNotFound = { + status: 'unfinished'; + message: string; +}; + +type ApplicationCredential = { + id: string; + password: string; + status: 'finished'; +}; + +export type CreateAppPasswordResponse = ApplicationNotFound | ApplicationCredential; + +const getPlatform = (platform: string) => { + const platformAsInt = parseInt(platform, 10); + if (ApplicationPlatform.Linux === platformAsInt) return ApplicationPlatform.Linux; + if (ApplicationPlatform.Windows === platformAsInt) return ApplicationPlatform.Windows; + if (ApplicationPlatform.MacOs === platformAsInt) return ApplicationPlatform.MacOs; + if (ApplicationPlatform.Android === platformAsInt) return ApplicationPlatform.Android; + if (ApplicationPlatform.Ios === platformAsInt) return ApplicationPlatform.Ios; + return ApplicationPlatform.Unknown; +}; + +const getType = (type: string) => { + const typeAsInt = parseInt(type, 10); + if (ApplicationType.Desktop === typeAsInt) return ApplicationType.Desktop; + if (ApplicationType.Mobile === typeAsInt) return ApplicationType.Mobile; + if (ApplicationType.Cli === typeAsInt) return ApplicationType.Cli; + return ApplicationType.Unknown; +}; + +export default class ApplicationModel extends BaseModel { + + protected get tableName(): string { + return 'applications'; + } + + protected uuidType(): UuidType { + return UuidType.Native; + } + + private applicationAuthIdKey = (applicationAuthId: string) => `ApplicationAuthId::${applicationAuthId}`; + + public async createPreLoginRecord(applicationAuthId: string, ip: string, version?: string, platform?: string, type?: string) { + const client: Client = { + ip: ip, + version: version || '', + platform: getPlatform(platform), + type: getType(type), + }; + return this.models().keyValue().setValue( + this.applicationAuthIdKey(applicationAuthId), + JSON.stringify(client), + ); + } + + private async getByApplicationAuthId(applicationAuthId: string) { + const clientUnparsed = await this.models().keyValue().value(this.applicationAuthIdKey(applicationAuthId)); + + let client = null; + + try { + client = JSON.parse(clientUnparsed); + } catch (error) { + // Mostly likely this is failing because the application was already authorized + // and the value stored in the keyValue now is the ID to an application record + throw new ErrorUnprocessableEntity(`Application Auth Id has already been used, go back to the Joplin application to finish the login process: ${applicationAuthId}`); + } + + return client as Client; + } + + // Joplin Cloud now has 2 methods of login, the one where the user uses + // his email as the identifier and other where the client application + // will use a generate id as the identifier + // + // If the id is a uuid means that is an application login + public isApplicationId(id: string) { + return validate(id); + } + + private async createApplicationRecord(userId: Uuid, client: Client) { + return this.save({ + user_id: userId, + ip: client.ip || getEmptyIp(this.db), + version: client.version, + platform: client.platform, + type: client.type, + }); + } + + // if password is already set it means that the credentials retrieval + // for this application has already happened + private async getValidApplicationBeforeFirstLogin(applicationAuthId: string): Promise { + const applicationAuthIdInformation = await this.models().keyValue().value(this.applicationAuthIdKey(applicationAuthId)); + if (!validate(applicationAuthIdInformation)) throw new ErrorForbidden('Application not authorized yet.'); + + const application = await this.db(this.tableName) + .select(this.defaultFields) + .where({ id: applicationAuthIdInformation, password: '' }) + .first(); + + return application; + } + + private generatePassword() { + return createSecureRandom(); + } + + public async createAppPassword(applicationAuthId: string): Promise { + return this.withTransaction(async () => { + const application = await this.getValidApplicationBeforeFirstLogin(applicationAuthId); + if (!application) return { status: 'unfinished', message: 'Application not found from Application Auth Id.' }; + + const password = this.generatePassword(); + const hashedPassword = await hashPassword(password); + await this.db(this.tableName) + .update({ password: hashedPassword }) + .where({ id: application.id }); + + await this.models().keyValue().deleteValue(this.applicationAuthIdKey(applicationAuthId)); + + return { id: application.id, password, status: 'finished' }; + }, 'ApplicationModel::createAppPassword'); + } + + public async updateOnNewLogin(applicationId: string, client: Client) { + if (!this.isApplicationId(applicationId)) return; + + const ip = client.ip; + const platform = client.platform ?? ApplicationPlatform.Unknown; + const type = client.type ?? ApplicationType.Unknown; + const version = client.version ?? ''; + await this.db(this.tableName) + .update({ last_access_time: Date.now(), ip, platform, type, version }) + .where({ id: applicationId }); + } + + public async login(id: string, password: string) { + const application = await this.load(id, { fields: ['id', 'password', 'user_id'] }); + + if (!application) { + throw new ErrorForbidden(`Could not find application with id: "${id}"`); + } + + if (!(await checkPassword(password, application.password))) { + throw new ErrorForbidden('Invalid application or application password', { details: { application: id } }); + } + + const user = await this.models().user().load(application.user_id); + if (!user) { + logger.error(`Login was successful, but user was not found. User id: ${application.user_id}`); + throw new ErrorUnprocessableEntity('Login was successful, but user was not found'); + } + + return { user, application }; + } + + public async onAuthorizeUse(applicationAuthId: string, userId: string) { + return this.withTransaction(async () => { + const client = await this.getByApplicationAuthId(applicationAuthId); + + if (!client) { + throw new ErrorBadRequest(`Check if you are not already logged in on your Joplin application, client associated with this application auth id not found: ${applicationAuthId}`); + } + + const application = await this.createApplicationRecord(userId, client); + + await this.models().keyValue().setValue(this.applicationAuthIdKey(applicationAuthId), application.id); + await this.models().notification().add(userId, NotificationKey.Any, NotificationLevel.Important, 'You have successfully authorised your application'); + + return application; + }, 'ApplicationModel::onAuthorizeUse'); + } + + public async activeApplications(userId: Uuid): Promise { + if (!userId) return []; + + const result = await this.db + .select( + 'a.id', + 'a.version', + 'a.platform', + 'a.ip', + 'a.created_time', + 'a.last_access_time', + ) + .from('applications as a') + .where('a.user_id', userId) + .orderBy('a.last_access_time', 'desc'); + + return result; + } + + public async delete(applicationId: Uuid) { + await this.withTransaction(async () => { + await super.delete(applicationId); + await super.models().session().deleteByApplicationId(applicationId); + }, 'ApplicationModel::delete'); + } + + public getPlatformName(platform: number) { + if (ApplicationPlatform.Linux === platform) return 'Linux'; + if (ApplicationPlatform.Windows === platform) return 'Windows'; + if (ApplicationPlatform.MacOs === platform) return 'MacOS'; + if (ApplicationPlatform.Android === platform) return 'Android'; + if (ApplicationPlatform.Ios === platform) return 'iOS'; + return 'Unknown'; + } + + public getTypeName(type: number) { + if (ApplicationType.Desktop === type) return 'Desktop'; + if (ApplicationType.Mobile === type) return 'Mobile'; + if (ApplicationType.Cli === type) return 'Cli'; + return 'Unknown'; + } + + public async checkIfAllowed(user: User, _action: AclAction, resource: Application = null): Promise { + if (user.is_admin) return; + if (resource.user_id !== user.id) throw new ErrorForbidden(); + } + + public async deleteByUserId(userId: Uuid) { + const query = this.db(this.tableName).where('user_id', '=', userId); + await query.delete(); + } +} diff --git a/packages/server/src/models/BaseModel.ts b/packages/server/src/models/BaseModel.ts index 9c8a9d494c..2b872e3d53 100644 --- a/packages/server/src/models/BaseModel.ts +++ b/packages/server/src/models/BaseModel.ts @@ -126,6 +126,10 @@ export default abstract class BaseModel { return this.defaultFields_.slice(); } + protected get defaultFieldsWithPrefix(): string[] { + return this.defaultFields.map(f => `${this.tableName}.${f}`); + } + public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise { throw new Error('Must be overriden'); } @@ -302,13 +306,17 @@ export default abstract class BaseModel { return output; } - protected objectToApiOutput(object: T): T { + protected async objectToApiOutput(object: T): Promise { return { ...object }; } - public toApiOutput(object: T | T[]): T | T[] { + public async toApiOutput(object: T | T[]): Promise { if (Array.isArray(object)) { - return object.map(f => this.objectToApiOutput(f)); + const output: T[] = []; + for (let i = 0; i < object.length; i++) { + output.push(await this.objectToApiOutput(object[i])); + } + return output; } else { return this.objectToApiOutput(object); } diff --git a/packages/server/src/models/EmailModel.ts b/packages/server/src/models/EmailModel.ts index 6292b36c59..121b409db1 100644 --- a/packages/server/src/models/EmailModel.ts +++ b/packages/server/src/models/EmailModel.ts @@ -2,7 +2,7 @@ import { Uuid, Email, EmailSender } from '../services/database/types'; import BaseModel from './BaseModel'; export interface EmailToSend { - sender_id: EmailSender; + sender_id?: EmailSender; recipient_email: string; subject: string; body: string; @@ -28,6 +28,11 @@ export default class EmailModel extends BaseModel { } public async push(email: EmailToSend): Promise { + email = { + sender_id: EmailSender.NoReply, + ...email, + }; + if (email.key) { const existingEmail = await this.byRecipientAndKey(email.recipient_email, email.key); if (existingEmail) return null; // noop - the email has already been sent diff --git a/packages/server/src/models/ItemModel.test.ts b/packages/server/src/models/ItemModel.test.ts index e819005a0e..b06eb377f9 100644 --- a/packages/server/src/models/ItemModel.test.ts +++ b/packages/server/src/models/ItemModel.test.ts @@ -23,65 +23,6 @@ describe('ItemModel', () => { await beforeEachDb(); }); - // test('should find exclusively owned items 1', async function() { - // const { user: user1 } = await createUserAndSession(1, true); - // const { session: session2, user: user2 } = await createUserAndSession(2); - - // const tree: any = { - // '000000000000000000000000000000F1': { - // '00000000000000000000000000000001': null, - // }, - // }; - - // await createItemTree(user1.id, '', tree); - // await createItem(session2.id, 'root:/test.txt:', 'testing'); - - // { - // const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id); - // expect(itemIds.length).toBe(2); - - // const item1 = await models().item().load(itemIds[0]); - // const item2 = await models().item().load(itemIds[1]); - - // expect([item1.jop_id, item2.jop_id].sort()).toEqual(['000000000000000000000000000000F1', '00000000000000000000000000000001'].sort()); - // } - - // { - // const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id); - // expect(itemIds.length).toBe(1); - // } - // }); - - // test('should find exclusively owned items 2', async function() { - // const { session: session1, user: user1 } = await createUserAndSession(1, true); - // const { session: session2, user: user2 } = await createUserAndSession(2); - - // await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', { - // '000000000000000000000000000000F1': { - // '00000000000000000000000000000001': null, - // }, - // }); - - // await createFolder(session2.id, { id: '000000000000000000000000000000F2' }); - - // { - // const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id); - // expect(itemIds.length).toBe(0); - // } - - // { - // const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id); - // expect(itemIds.length).toBe(1); - // } - - // await models().user().delete(user2.id); - - // { - // const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id); - // expect(itemIds.length).toBe(2); - // } - // }); - test('should find all items within a shared folder', async () => { const { user: user1, session: session1 } = await createUserAndSession(1); const { session: session2 } = await createUserAndSession(2); diff --git a/packages/server/src/models/ItemModel.ts b/packages/server/src/models/ItemModel.ts index 6379c0490d..517805c3c0 100644 --- a/packages/server/src/models/ItemModel.ts +++ b/packages/server/src/models/ItemModel.ts @@ -119,16 +119,18 @@ export default class ItemModel extends BaseModel { } public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise { - if (action === AclAction.Create) { - if (!(await this.models().shareUser().isShareParticipant(resource.jop_share_id, user.id))) throw new ErrorForbidden('user has no access to this share'); - } + if ([AclAction.Create, AclAction.Update, AclAction.Delete].includes(action) && resource.jop_share_id) { + const share = await this.models().share().load(resource.jop_share_id, { fields: ['id', 'owner_id'] }); - // if (action === AclAction.Delete) { - // const share = await this.models().share().byItemId(resource.id); - // if (share && share.type === ShareType.JoplinRootFolder) { - // if (user.id !== share.owner_id) throw new ErrorForbidden('only the owner of the shared notebook can delete it'); - // } - // } + if (!share) { + modelLogger.warn('cannot find the share associated with this item. Action:', action, 'User:', user, 'Resource:', resource); + } else { + if (share.owner_id !== user.id) { + const shareUser = await this.models().shareUser().byShareAndUserId(share.id, user.id); + if (!shareUser) throw new ErrorForbidden('user has no access to this share'); + } + } + } } public fromApiInput(item: Item): Item { @@ -141,7 +143,7 @@ export default class ItemModel extends BaseModel { return output; } - protected objectToApiOutput(object: Item): Item { + protected async objectToApiOutput(object: Item): Promise { const output: Item = {}; const propNames = ['id', 'name', 'updated_time', 'created_time']; for (const k of Object.keys(object)) { @@ -562,10 +564,9 @@ export default class ItemModel extends BaseModel { return item; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public async loadAsJoplinItem(id: Uuid): Promise { + public async loadAsJoplinItem(id: Uuid): Promise { const raw = await this.loadWithContent(id); - return this.itemToJoplinItem(raw); + return this.itemToJoplinItem(raw) as T; } public async saveFromRawContent(user: User, rawContentItemOrItems: SaveFromRawContentItem[] | SaveFromRawContentItem, options: ItemSaveOption = null): Promise { @@ -590,14 +591,35 @@ export default class ItemModel extends BaseModel { interface ExistingItem { id: Uuid; name: string; + owner_id: string; + jop_share_id: string; } return this.withTransaction(async () => { - const existingItems = await this.loadByNames(user.id, rawContentItems.map(i => i.name), { fields: ['id', 'name'] }) as ExistingItem[]; + const existingItems = await this.loadByNames(user.id, rawContentItems.map(i => i.name), { fields: ['id', 'name', 'owner_id', 'jop_share_id', 'jop_type', 'jop_parent_id'] }) as ExistingItem[]; const itemsToProcess: Record = {}; for (const rawItem of rawContentItems) { try { + const existingItem = existingItems.find(i => i.name === rawItem.name); + + // Check if the user is allowed to modify the item - in + // particular it would be disabled if the user has only + // read-only access to a share. Later, once we have + // unserialized the content, and got all the relevant + // information, we check if the user is allowed to create + // the item. + // + // Normally only one such check is needed to know if the + // item can be updated... except if share_id is changed, in + // which case we need to check again with the new share_id + // (see below) + let previousShareId = ''; + if (existingItem) { + await this.checkIfAllowed(user, AclAction.Update, existingItem); + previousShareId = existingItem.jop_share_id; + } + const isJoplinItem = isJoplinItemName(rawItem.name); let isNote = false; @@ -636,11 +658,34 @@ export default class ItemModel extends BaseModel { item.content = rawItem.body; } - const existingItem = existingItems.find(i => i.name === rawItem.name); if (existingItem) item.id = existingItem.id; if (options.shareId) item.jop_share_id = options.shareId; + // Check if the user is allowed to create an item here - in + // particular it would be disabled if the user has only + // read-only access to a share. + + const itemToCheck = { ...item }; + if (!isJoplinItem) { + // The checked item must have these properties, + // otherwise isRootSharedFolder() will fail. If it's not + // a Joplin item, it means it's a regular file, such as + // info.json or the content of a resource, so we set the + // type to `ModelType.Resource`, which is not strictly + // correct but will make it work with the + // isRootSharedFolder() check. + if (!itemToCheck.jop_parent_id) itemToCheck.jop_parent_id = ''; + if (!itemToCheck.jop_type) itemToCheck.jop_type = ModelType.Resource; + } + + if (!existingItem) { + await this.checkIfAllowed(user, AclAction.Create, itemToCheck); + } else { + const newShareId = item.jop_share_id || ''; + if (previousShareId !== newShareId) await this.checkIfAllowed(user, AclAction.Update, itemToCheck); + } + await this.models().user().checkMaxItemSizeLimit(user, rawItem.body, item, joplinItem); itemsToProcess[rawItem.name] = { @@ -851,6 +896,11 @@ export default class ItemModel extends BaseModel { } public isRootSharedFolder(item: Item): boolean { + if (!('jop_type' in item) || !('jop_parent_id' in item) || !('jop_share_id' in item)) { + const itemInfo = { ...item }; + delete itemInfo.content; + throw new Error(`Missing jop_type, jop_parent_id or jop_share_id property: ${JSON.stringify(itemInfo)}`); + } return item.jop_type === ModelType.Folder && item.jop_parent_id === '' && !!item.jop_share_id; } @@ -912,7 +962,13 @@ export default class ItemModel extends BaseModel { public async deleteForUser(userId: Uuid, item: Item, options: DeleteOptions = {}): Promise { if (this.isRootSharedFolder(item)) { const share = await this.models().share().byItemId(item.id); - if (!share) throw new Error(`Cannot find share associated with item ${item.id}`); + if (!share) { + // In that case we don't do anything - the item is going to be + // deleted locally anyway. And we can't delete a root folder, + // otherwise it will potentially delete it for other users too. + modelLogger.warn(`Trying to delete a root folder associated with a share that no longer exists: ${item.id}`); + return; + } const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId); if (userShare) { diff --git a/packages/server/src/models/ItemResourceModel.ts b/packages/server/src/models/ItemResourceModel.ts index 732d94eb20..211c9c67a5 100644 --- a/packages/server/src/models/ItemResourceModel.ts +++ b/packages/server/src/models/ItemResourceModel.ts @@ -1,6 +1,7 @@ import { resourceBlobPath } from '../utils/joplinUtils'; import { Item, ItemResource, Uuid } from '../services/database/types'; import BaseModel from './BaseModel'; +import { ItemLoadOptions } from './ItemModel'; export interface TreeItem { item_id: Uuid; @@ -63,9 +64,9 @@ export default class ItemResourceModel extends BaseModel { return rows.map(r => r.item_id); } - public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[]): Promise { + public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[], options: ItemLoadOptions = {}): Promise { const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id)); - return this.models().item().loadByNames(userIds, resourceBlobNames); + return this.models().item().loadByNames(userIds, resourceBlobNames, options); } public async itemTree(rootItemId: Uuid, rootJopId: string, currentItemIds: string[] = []): Promise { diff --git a/packages/server/src/models/RecoveryCodeModel.ts b/packages/server/src/models/RecoveryCodeModel.ts new file mode 100644 index 0000000000..cf4c87aaf6 --- /dev/null +++ b/packages/server/src/models/RecoveryCodeModel.ts @@ -0,0 +1,161 @@ + +import BaseModel, { UuidType } from './BaseModel'; +import { EmailSender, RecoveryCode, Uuid } from '../services/database/types'; +import { createSecureRandom, customAlphabetSecure } from '@joplin/lib/uuid'; +import { ErrorForbidden } from '../utils/errors'; +import { isValidMFACode } from '../utils/crypto'; +import recoveryCodesAccessedTemplate from '../views/emails/recoveryCodesAccessedTemplate'; +import { forgotPasswordUrl } from '../utils/urlUtils'; +import { formatDateOnServer } from '../utils/time'; +import { DbConnection } from '../db'; +import { NewModelFactoryHandler } from './factory'; +import { Config } from '../utils/types'; + +type RecoveryCodeAccess = { + isValid: boolean; + isNewlyCreated: boolean; +}; + +export default class RecoveryCodeModel extends BaseModel { + + private readonly nanoid; + + public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) { + super(db, dbSlave, modelFactory, config); + + this.nanoid = customAlphabetSecure('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 10); + } + + protected get tableName(): string { + return 'recovery_codes'; + } + + protected uuidType(): UuidType { + return UuidType.Native; + } + + public generateNewCodes() { + const quantity = 10; + const codes = []; + for (let i = 0; i < quantity; i++) { + const code = this.nanoid(); + codes.push(code); + } + return codes; + } + + public async saveCodes(codes: string[], userId: Uuid) { + await this.withTransaction(async () => { + await super.db(this.tableName) + .where({ user_id: userId }) + .delete(); + + for (const code of codes) { + await super.save({ + user_id: userId, + code, + is_used: 0, + }); + } + }, 'RecoveryCodeModel::saveCodes'); + } + + private userFriendlyFormat(codeRecords: Partial[]) { + return codeRecords + .map(record => { + return { + ...record, + code: `${record.code.slice(0, 5)}-${record.code.slice(5)}`.toUpperCase(), + }; + }) + .sort((a, b) => { + return a.is_used - b.is_used; + }); + } + + public async loadByUserId(userId: Uuid) { + const codes: Partial[] = await this.db(this.tableName).select(['user_id', 'code', 'is_used']).where({ user_id: userId }); + return this.userFriendlyFormat(codes); + } + + private normalizeRecoveryCode(recoveryCode: string) { + return recoveryCode + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); + } + + public async verify(userId: Uuid, recoveryCode: string) { + const normalized = this.normalizeRecoveryCode(recoveryCode); + await this.withTransaction(async () => { + const code = await super.db(this.tableName) + .select(['id', 'user_id', 'code', 'is_used']) + .where({ user_id: userId, is_used: 0, code: normalized }) + .first(); + + if (!code) throw new ErrorForbidden('The recovery code is not valid or has already been used.'); + + await super.db(this.tableName).update({ is_used: 1 }).where({ user_id: userId, code: normalized }); + }, 'RecoveryCode::verify'); + } + + public async checkCredentials(userId: Uuid, password?: string, mfaCode?: string) { + const user = await this.models().user().load(userId, { fields: ['totp_secret'] }); + + if (password) { + const isPasswordValid = await this.models().user().isPasswordValid(userId, password); + if (isPasswordValid) return; + } + + if (mfaCode) { + const isMfaCodeValid = await isValidMFACode(user.totp_secret, mfaCode); + if (isMfaCodeValid) return; + } + + throw new ErrorForbidden('Invalid password or authentication code'); + } + + public async saveRecoveryCodeAccessKey(userId: Uuid) { + const accessKey = createSecureRandom(); + await this.models().keyValue().setValue(`RecoveryCode::accessKey::${userId}`, accessKey); + return accessKey; + } + + public async isRecoveryCodeAccessKeyValid(userId: Uuid, accessKey: string) { + const recoveryCodeAccess = await this.withTransaction(async () => { + const record = await super.models().keyValue().value(`RecoveryCode::accessKey::${userId}`); + + if (record !== accessKey) return { isValid: false, isNewlyCreated: false }; + + const isNewlyCreated = await super.models().keyValue().value(`RecoveryCode::isNewlyCreated::${userId}`); + + await super.models().keyValue().deleteValue(`RecoveryCode::accessKey::${userId}`); + await super.models().keyValue().deleteValue(`RecoveryCode::isNewlyCreated::${userId}`); + + return { isValid: true, isNewlyCreated: !!isNewlyCreated }; + }, 'RecoveryCode::isRecoveryCodeAccessKeyValid'); + + if (!recoveryCodeAccess.isValid) return recoveryCodeAccess; + + // We don't send email notification if it is just after MFA was enabled + if (recoveryCodeAccess.isNewlyCreated) return recoveryCodeAccess; + + const user = await this.models().user().load(userId, { fields: ['email', 'full_name'] }); + await this.models().email().push({ + ...recoveryCodesAccessedTemplate({ + accessTime: formatDateOnServer(Date.now()), + changePasswordUrl: forgotPasswordUrl(), + }), + recipient_email: user.email, + recipient_name: user.full_name, + recipient_id: userId, + sender_id: EmailSender.NoReply, + }); + + return recoveryCodeAccess; + } + + public async regenerate(userId: Uuid) { + const codes = this.generateNewCodes(); + await this.saveCodes(codes, userId); + } +} diff --git a/packages/server/src/models/SessionModel.test.ts b/packages/server/src/models/SessionModel.test.ts index 15263a95db..d1af5f010a 100644 --- a/packages/server/src/models/SessionModel.test.ts +++ b/packages/server/src/models/SessionModel.test.ts @@ -21,12 +21,14 @@ describe('SessionModel', () => { const t0 = new Date('2020-01-01T00:00:00').getTime(); jest.setSystemTime(t0); + const mfaCode = ''; + const { user, password } = await createUserAndSession(1); - await models().session().authenticate(user.email, password); + await models().session().authenticate(user.email, password, mfaCode); jest.setSystemTime(new Date(t0 + defaultSessionTtl + 10)); - const lastSession = await models().session().authenticate(user.email, password); + const lastSession = await models().session().authenticate(user.email, password, mfaCode); expect(await models().session().count()).toBe(3); @@ -35,7 +37,7 @@ describe('SessionModel', () => { expect(await models().session().count()).toBe(1); expect((await models().session().all())[0].id).toBe(lastSession.id); - await models().session().authenticate(user.email, password); + await models().session().authenticate(user.email, password, mfaCode); await models().session().deleteExpiredSessions(); expect(await models().session().count()).toBe(2); diff --git a/packages/server/src/models/SessionModel.ts b/packages/server/src/models/SessionModel.ts index fe8a266bb0..209c3173bf 100644 --- a/packages/server/src/models/SessionModel.ts +++ b/packages/server/src/models/SessionModel.ts @@ -3,6 +3,8 @@ import { User, Session, Uuid } from '../services/database/types'; import { uuidgen } from '@joplin/lib/uuid'; import { ErrorForbidden } from '../utils/errors'; import { Hour } from '../utils/time'; +import { isValidMFACode } from '../utils/crypto'; +import { getIsMFAEnabled } from './utils/user'; export const defaultSessionTtl = 12 * Hour; @@ -26,12 +28,44 @@ export default class SessionModel extends BaseModel { }, { isNew: true }); } - public async authenticate(email: string, password: string): Promise { + public async createApplicationSession(userId: string, applicationId: Uuid): Promise { + return this.save({ + id: uuidgen(), + user_id: userId, + application_id: applicationId, + }, { isNew: true }); + } + + public async authenticate(emailOrApplicationId: string, password: string, mfaCode?: string, recoveryCode?: string) { + if (this.models().application().isApplicationId(emailOrApplicationId)) { + return this.authenticateApplication(emailOrApplicationId, password); + } else { + return this.authenticateUser(emailOrApplicationId, password, mfaCode, recoveryCode); + } + } + + private async authenticateUser(email: string, password: string, mfaCode?: string, recoveryCode?: string) { const user = await this.models().user().login(email, password); - if (!user) throw new ErrorForbidden('Invalid username or password', { details: { email } }); + if (!user) throw new ErrorForbidden('Invalid email or password', { details: { email } }); + + if (getIsMFAEnabled(user)) { + if (!mfaCode && !recoveryCode) throw new ErrorForbidden('Invalid authentication code', { details: { mfaCode } }); + + if (mfaCode) { + const isValidCode = await isValidMFACode(user.totp_secret, mfaCode); + if (!isValidCode) throw new ErrorForbidden('Invalid authentication code', { details: { mfaCode } }); + } else if (recoveryCode) { + await this.models().recoveryCode().verify(user.id, recoveryCode); + } + } return this.createUserSession(user.id); } + public async authenticateApplication(id: string, password: string) { + const result = await this.models().application().login(id, password); + return this.createApplicationSession(result.user.id, result.application.id); + } + public async logout(sessionId: string) { if (!sessionId) return; await this.delete(sessionId); @@ -48,4 +82,10 @@ export default class SessionModel extends BaseModel { await this.db(this.tableName).where('created_time', '<', cutOffTime).delete(); } + public async deleteByApplicationId(applicationId: Uuid) { + await this.db(this.tableName) + .where('application_id', '=', applicationId) + .delete(); + } + } diff --git a/packages/server/src/models/ShareModel.ts b/packages/server/src/models/ShareModel.ts index 2ffe739be9..eede0693e4 100644 --- a/packages/server/src/models/ShareModel.ts +++ b/packages/server/src/models/ShareModel.ts @@ -3,7 +3,7 @@ import { Change, ChangeType, Item, Share, ShareType, ShareUserStatus, User, Uuid import { unique } from '../utils/array'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from '../utils/errors'; import { setQueryParameters } from '../utils/urlUtils'; -import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseModel'; +import BaseModel, { AclAction, DeleteOptions, LoadOptions, ValidateOptions } from './BaseModel'; import { userIdFromUserContentUrl } from '../utils/routeUtils'; import { getCanShareFolder } from './utils/user'; import { isUniqueConstraintError } from '../db'; @@ -43,6 +43,7 @@ export default class ShareModel extends BaseModel { } public checkShareUrl(share: Share, shareUrl: string) { + if (this.userContentBaseUrl === 'http://joplinusercontent.local:22300') return; // OK - testing if (this.baseUrl === this.userContentBaseUrl) return; // OK const userId = userIdFromUserContentUrl(shareUrl); @@ -55,7 +56,7 @@ export default class ShareModel extends BaseModel { } } - protected objectToApiOutput(object: Share): Share { + protected async objectToApiOutput(object: Share): Promise { const output: Share = {}; if (object.id) output.id = object.id; @@ -88,10 +89,10 @@ export default class ShareModel extends BaseModel { return this.save(toSave); } - public async itemShare(shareType: ShareType, itemId: string): Promise { + public async itemShare(shareType: ShareType, itemId: string, options: LoadOptions = null): Promise { return this .db(this.tableName) - .select(this.defaultFields) + .select(this.selectFields(options)) .where('item_id', '=', itemId) .where('type', '=', shareType) .first(); diff --git a/packages/server/src/models/ShareUserModel.ts b/packages/server/src/models/ShareUserModel.ts index 0bb5d1c61d..53a0bcdbf6 100644 --- a/packages/server/src/models/ShareUserModel.ts +++ b/packages/server/src/models/ShareUserModel.ts @@ -24,7 +24,14 @@ export default class ShareUserModel extends BaseModel { } if (action === AclAction.Update) { - if (user.id !== resource.user_id) throw new ErrorForbidden('cannot change share user'); + if (user.id === resource.user_id) { + // OK - a share recipient can modify its own object + } else { + // Otherwise check if the user owns the share + const share = await this.models().share().load(resource.share_id); + if (!share) throw new ErrorBadRequest(`No such share: ${resource.share_id}`); + if (share.owner_id !== user.id) throw new ErrorForbidden('cannot change someone else\'s share'); + } } if (action === AclAction.Delete) { diff --git a/packages/server/src/models/TokenModel.ts b/packages/server/src/models/TokenModel.ts index 593802f47d..c8554f1bb5 100644 --- a/packages/server/src/models/TokenModel.ts +++ b/packages/server/src/models/TokenModel.ts @@ -24,6 +24,15 @@ export default class TokenModel extends BaseModel { return token.value; } + public async generateAnonymous(): Promise { + const token = await this.save({ + value: uuidgen(32), + user_id: '', + }); + + return token.value; + } + public async checkToken(userId: string, tokenValue: string): Promise { if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token'); } diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index 0173d8a493..229943dd68 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -1,4 +1,4 @@ -import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow, createUser } from '../utils/testing/testUtils'; +import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow, createUser, expectHttpError } from '../utils/testing/testUtils'; import { EmailSender, UserFlagType } from '../services/database/types'; import { ErrorBadRequest, ErrorUnprocessableEntity } from '../utils/errors'; import { betaUserDateRange, stripeConfig } from '../utils/stripe'; @@ -11,7 +11,11 @@ import config from '../config'; describe('UserModel', () => { beforeAll(async () => { - await beforeAllDb('UserModel'); + const envValues = { + MFA_ENCRYPTION_KEY: '42bb51d708f1f7bfe43f074b03dfcd5f6c10fe337744d2e73deb4ee46d2b1038', + }; + + await beforeAllDb('UserModel', { envValues }); }); afterAll(async () => { @@ -66,27 +70,6 @@ describe('UserModel', () => { ).toBe(null); }); - // test('should delete a user', async () => { - // const { session: session1, user: user1 } = await createUserAndSession(2, false); - - // const userModel = models().user(); - - // const allUsers: User[] = await userModel.all(); - // const beforeCount: number = allUsers.length; - - // await createItem(session1.id, 'root:/test.txt:', 'testing'); - - // // Admin can delete any user - // expect(!!(await models().session().load(session1.id))).toBe(true); - // expect((await models().item().all()).length).toBe(1); - // expect((await models().userItem().all()).length).toBe(1); - // await models().user().delete(user1.id); - // expect((await userModel.all()).length).toBe(beforeCount - 1); - // expect(!!(await models().session().load(session1.id))).toBe(false); - // expect((await models().item().all()).length).toBe(0); - // expect((await models().userItem().all()).length).toBe(0); - // }); - test('should push an email when creating a new user', async () => { const { user: user1 } = await createUserAndSession(1); const { user: user2 } = await createUserAndSession(2); @@ -222,7 +205,7 @@ describe('UserModel', () => { stripeConfig().enabled = false; }); - test('should disable disable the account and send an email if payment failed for good', async () => { + test('should disable the account and send an email if payment failed for good', async () => { stripeConfig().enabled = true; const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111'); @@ -288,31 +271,35 @@ describe('UserModel', () => { { // Now check that the 100% email is sent too - await models().user().save({ - id: user2.id, - total_item_size: Math.round(accountByType(AccountType.Pro).max_total_item_size * 1.1), - }); + for (const u of [user2]) { + const user = await models().user().load(u.id); - // User upload should be enabled at this point - expect((await models().user().load(user2.id)).can_upload).toBe(1); + await models().user().save({ + id: user.id, + total_item_size: Math.round(accountByType(user.account_type).max_total_item_size * 1.1), + }); - const emailBeforeCount = (await models().email().all()).length; - await models().user().handleOversizedAccounts(); - const emailAfterCount = (await models().email().all()).length; + // User upload should be enabled at this point + expect((await models().user().load(user.id)).can_upload).toBe(1); - // User upload should be disabled - expect((await models().user().load(user2.id)).can_upload).toBe(0); - expect(await models().userFlag().byUserId(user2.id, UserFlagType.AccountOverLimit)).toBeTruthy(); + const emailBeforeCount = (await models().email().all()).length; + await models().user().handleOversizedAccounts(); + const emailAfterCount = (await models().email().all()).length; - expect(emailAfterCount).toBe(emailBeforeCount + 1); - const email = (await models().email().all()).pop(); + // User upload should be disabled + expect((await models().user().load(user.id)).can_upload).toBe(0); + expect(await models().userFlag().byUserId(user.id, UserFlagType.AccountOverLimit)).toBeTruthy(); - expect(email.recipient_id).toBe(user2.id); - expect(email.subject).toContain('100%'); + expect(emailAfterCount).toBe(emailBeforeCount + 1); + const email = (await models().email().all()).pop(); - // Running it again should not send a second email - await models().user().handleOversizedAccounts(); - expect((await models().email().all()).length).toBe(emailBeforeCount + 1); + expect(email.recipient_id).toBe(user.id); + expect(email.subject).toContain('100%'); + + // Running it again should not send a second email + await models().user().handleOversizedAccounts(); + expect((await models().email().all()).length).toBe(emailBeforeCount + 1); + } } }); @@ -456,6 +443,92 @@ describe('UserModel', () => { expect(error instanceof ErrorBadRequest).toBe(true); }); + test('should not allow the creation of user records with invalid email address', async () => { + + const error = await checkThrowAsync( + async () => await models().user().save({ + email: 'invalid_email.com', + }), + ); + + expect(error.message).toBe('Should include @ in email address, email: invalid_email.com'); + expect(error instanceof ErrorUnprocessableEntity).toBe(true); + }); + + test('should generate a notification when MFA is enabled', async () => { + const user = await models().user().save({ + email: 'test@example.com', + password: '111111', + }); + // cSpell:disable + await models().user().enableMFA(user.id, 'KUKNC5O2CGOLU6EVKT2PRAHE3AK7MKY3', ''); + // cSpell:enable + + const notifications = await models().notification().allUnreadByUserId(user.id); + expect(notifications.length).toBe(1); + expect(notifications[0].message).toBe('Multi-factor authentication has been enabled for your account. Please remember to copy and save your recovery codes'); + }); + + test('should create a isNewlyCreated keyValue record after MFA is enabled', async () => { + const user = await models().user().save({ + email: 'test@example.com', + password: '111111', + }); + // cSpell:disable + await models().user().enableMFA(user.id, 'KUKNC5O2CGOLU6EVKT2PRAHE3AK7MKY3', ''); + // cSpell:enable + + const isNewlyCreated = await models().keyValue().value(`RecoveryCode::isNewlyCreated::${user.id}`); + expect(isNewlyCreated).toBe(1); + }); + + test('should delete all other sessions when MFA is enabled', async () => { + const user = await models().user().save({ + email: 'test@example.com', + password: '111111', + }); + await models().session().createUserSession(user.id); + await models().session().createApplicationSession(user.id, '00000000-0000-0000-0000-000000000001'); + await models().session().createApplicationSession(user.id, '00000000-0000-0000-0000-000000000002'); + + const session = await models().session().createUserSession(user.id); + + let sessions = await models().session().all(); + expect(sessions.length).toBe(4); + // cSpell:disable + await models().user().enableMFA(user.id, 'KUKNC5O2CGOLU6EVKT2PRAHE3AK7MKY3', session.id); + // cSpell:enable + sessions = await models().session().all(); + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(session.id); + }); + + test('should throw error if password is empty', async () => { + await expectHttpError(() => models().user().isPasswordValid('', undefined as string), 400); + }); + + test('should delete all applications when password is reset', async () => { + const user = await models().user().save({ + email: 'test@example.com', + password: '111111', + }); + + await models().application().createPreLoginRecord('random-string', ''); + await models().application().onAuthorizeUse('random-string', user.id); + await models().application().createPreLoginRecord('random-string2', ''); + await models().application().onAuthorizeUse('random-string2', user.id); + + let applications = await models().application().all(); + expect(applications.length).toBe(2); + + const url = await models().user().generateLinkForPasswordReset(user.id); + const token = url.split('?token=')[1]; + await models().user().resetPassword(token, { password: '111111', password2: '111111' }); + + applications = await models().application().all(); + expect(applications.length).toBe(0); + }); + test('should not log in an user using a email/password combo when the local auth is disabled', async () => { config().LOCAL_AUTH_ENABLED = false; diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index ad1712e211..baf1ff9e73 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -1,4 +1,4 @@ -import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel'; +import BaseModel, { AclAction, LoadOptions, SaveOptions, ValidateOptions } from './BaseModel'; import { EmailSender, Item, NotificationLevel, Subscription, User, UserFlagType, Uuid } from '../services/database/types'; import { isHashedPassword, hashPassword, checkPassword } from '../utils/auth'; import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound, ErrorBadRequest } from '../utils/errors'; @@ -6,7 +6,7 @@ import { ModelType } from '@joplin/lib/BaseModel'; import { _ } from '@joplin/lib/locale'; import { formatBytes, GB, MB } from '../utils/bytes'; import { itemIsEncrypted } from '../utils/joplinUtils'; -import { getMaxItemSize, getMaxTotalItemSize } from './utils/user'; +import { getIsMFAEnabled, getMaxItemSize, getMaxTotalItemSize } from './utils/user'; import * as zxcvbn from 'zxcvbn'; import { confirmUrl, resetPasswordUrl } from '../utils/urlUtils'; import { checkRepeatPassword, CheckRepeatPasswordInput } from '../routes/index/users'; @@ -27,10 +27,13 @@ import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirma import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate'; import { NotificationKey } from './NotificationModel'; import prettyBytes = require('pretty-bytes'); +import { validateEmail } from '../utils/validation'; import { Config, Env, LdapConfig } from '../utils/types'; -import ldapLogin from '../utils/ldapLogin'; import { DbConnection } from '../db'; import { NewModelFactoryHandler } from './factory'; +import { encryptMFASecret } from '../utils/crypto'; +import ldapLogin from '../utils/ldapLogin'; +const thirtyTwo = require('thirty-two'); import config, { isUsingExternalAuth } from '../config'; import { randomInt } from 'node:crypto'; import { samlOwnedUserProperties } from '../utils/saml'; @@ -44,6 +47,8 @@ interface UserEmailDetails { recipient_name: string; } +export type GetUsersApiResponse = User; + export enum AccountType { Default = 0, Basic = 1, @@ -58,37 +63,42 @@ export interface Account { max_total_item_size: number; } +const accountMetadata: Record = { + // The "default" account is the account that would be used on a self-hosted + // Joplin Server, or a user that can be created from the admin UI (or API). + // In general, it should have all permissions and infinite storage. + [AccountType.Default]: { + account_type: AccountType.Default, + can_share_folder: 1, + can_receive_folder: 1, + max_item_size: 0, + max_total_item_size: 0, + }, + + // The Basic, Pro and Team account is what is available to Joplin Cloud users. + [AccountType.Basic]: { + account_type: AccountType.Basic, + can_share_folder: 0, + can_receive_folder: 1, + max_item_size: 10 * MB, + max_total_item_size: 2 * GB, + }, + [AccountType.Pro]: { + account_type: AccountType.Pro, + can_share_folder: 1, + can_receive_folder: 1, + max_item_size: 200 * MB, + max_total_item_size: 30 * GB, + }, +}; + interface AccountTypeSelectOptions { value: number; label: string; } export function accountByType(accountType: AccountType): Account { - const types: Account[] = [ - { - account_type: AccountType.Default, - can_share_folder: 1, - can_receive_folder: 1, - max_item_size: 0, - max_total_item_size: 0, - }, - { - account_type: AccountType.Basic, - can_share_folder: 0, - can_receive_folder: 1, - max_item_size: 10 * MB, - max_total_item_size: 1 * GB, - }, - { - account_type: AccountType.Pro, - can_share_folder: 1, - can_receive_folder: 1, - max_item_size: 200 * MB, - max_total_item_size: 10 * GB, - }, - ]; - - const type = types.find(a => a.account_type === accountType); + const type = accountMetadata[accountType]; if (!type) throw new Error(`Invalid account type: ${accountType}`); return type; } @@ -118,12 +128,14 @@ export function accountTypeToString(accountType: AccountType): string { } export default class UserModel extends BaseModel { + private mfaEncryptionKey_: string = null; private authCodeTtl = 600000; // 10 minutes private ldapConfig_: LdapConfig[]; private isUsingExternalAuth_ = false; public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) { super(db, dbSlave, modelFactory, config); + this.mfaEncryptionKey_ = config.MFA_ENCRYPTION_KEY; this.ldapConfig_ = config.ldap; this.isUsingExternalAuth_ = isUsingExternalAuth(config); } @@ -132,9 +144,11 @@ export default class UserModel extends BaseModel { return 'users'; } - public async loadByEmail(email: string): Promise { - const user: User = this.formatValues({ email: email }); - return this.db(this.tableName).where(user).first(); + public async loadByEmail(email: string, options: LoadOptions = {}): Promise { + return this.db(this.tableName) + .select(this.selectFields(options)) + .where(this.formatValues({ email: email })) + .first(); } public async loadBySsoAuthCode(code: string): Promise { @@ -257,9 +271,16 @@ export default class UserModel extends BaseModel { return user; } - protected objectToApiOutput(object: User): User { - const output: User = { ...object }; - delete output.password; + protected async objectToApiOutput(object: User): Promise { + const output: GetUsersApiResponse = { }; + + if ('account_type' in object) output.account_type = object.account_type; + if ('created_time' in object) output.created_time = object.created_time; + if ('email' in object) output.email = object.email; + if ('full_name' in object) output.full_name = object.full_name; + if ('id' in object) output.id = object.id; + if ('updated_time' in object) output.updated_time = object.updated_time; + return output; } @@ -373,9 +394,11 @@ export default class UserModel extends BaseModel { // been hashed by then. if (options.isNew) { if (!user.email) throw new ErrorUnprocessableEntity('email must be set'); + if ('email' in user && !user.email.includes('@')) throw new ErrorUnprocessableEntity(`Should include @ in email address, email: ${user.email}`); if (!user.password && !user.must_set_password) throw new ErrorUnprocessableEntity('password must be set'); } else { if ('email' in user && !user.email) throw new ErrorUnprocessableEntity('email must be set'); + if ('email' in user && !user.email.includes('@')) throw new ErrorUnprocessableEntity(`Should include @ in email address, email: ${user.email}`); if ('password' in user && !user.password) throw new ErrorUnprocessableEntity('password must be set'); } @@ -384,7 +407,7 @@ export default class UserModel extends BaseModel { if (existingUser && existingUser.id !== user.id) throw new ErrorUnprocessableEntity(`there is already a user with this email: ${user.email}`); // See https://www.rfc-editor.org/errata_search.php?rfc=3696&eid=1690 (found via https://stackoverflow.com/a/574698) if (user.email.length > 254) throw new ErrorUnprocessableEntity('Please enter an email address between 0 and 254 characters'); - if (!this.validateEmail(user.email)) throw new ErrorUnprocessableEntity(`Invalid email: ${user.email}`); + validateEmail(user.email); } if ('full_name' in user && user.full_name.length > 256) throw new ErrorUnprocessableEntity('Full name must be at most 256 characters'); @@ -392,12 +415,6 @@ export default class UserModel extends BaseModel { return super.validate(user, options); } - private validateEmail(email: string): boolean { - const s = email.split('@'); - if (s.length !== 2) return false; - return !!s[0].length && !!s[1].length; - } - // public async delete(id: string): Promise { // const shares = await this.models().share().sharesByUser(id); @@ -502,12 +519,16 @@ export default class UserModel extends BaseModel { }); } + public async generateLinkForPasswordReset(userId: Uuid) { + const validationToken = await this.models().token().generate(userId); + return resetPasswordUrl(validationToken); + } + public async sendResetPasswordEmail(email: string) { const user = await this.loadByEmail(email); if (!user) throw new ErrorNotFound(`No such user: ${email}`); - const validationToken = await this.models().token().generate(user.id); - const url = resetPasswordUrl(validationToken); + const url = await this.generateLinkForPasswordReset(user.id); await this.models().email().push({ ...resetPasswordTemplate({ url }), @@ -522,6 +543,7 @@ export default class UserModel extends BaseModel { await this.withTransaction(async () => { await this.models().user().save({ id: user.id, password: fields.password }); await this.models().session().deleteByUserId(user.id); + await this.models().application().deleteByUserId(user.id); await this.models().token().deleteByValue(user.id, token); }, 'UserModel::resetPassword'); } @@ -791,14 +813,13 @@ export default class UserModel extends BaseModel { return this.withTransaction(async () => { const savedUser = await super.save(user, options); - if (isNew) { + if (isNew && (object.email_confirmed !== 1 || object.must_set_password !== 0)) { await this.sendAccountConfirmationEmail(savedUser); } return savedUser; }, 'UserModel::save'); } - public async saveMulti(users: User[], options: SaveOptions = {}): Promise { await this.withTransaction(async () => { for (const user of users) { @@ -806,4 +827,38 @@ export default class UserModel extends BaseModel { } }, 'UserModel::saveMulti'); } + + public async hasMFAEnabled(email: string) { + const user = await this.loadByEmail(email, { fields: ['totp_secret'] }); + if (!user) throw new ErrorForbidden('Invalid email or password', { details: { email } }); + return getIsMFAEnabled(user); + } + + public async disableMFA(userId: Uuid) { + await this.save({ id: userId, totp_secret: '' }); + } + + public async isPasswordValid(userId: Uuid, password: string) { + if (!password) throw new ErrorBadRequest('Password cannot be empty'); + const user = await this.load(userId, { fields: ['password'] }); + return checkPassword(password, user.password); + } + + public async enableMFA(userId: Uuid, totpSecret: string, currentSessionId: string) { + const decodedTotpSecret = thirtyTwo.decode(totpSecret); + const encryptedTotpSecret = encryptMFASecret(decodedTotpSecret, this.mfaEncryptionKey_); + + await this.withTransaction(async () => { + await super.save({ id: userId, totp_secret: encryptedTotpSecret }); + + const codes = this.models().recoveryCode().generateNewCodes(); + await super.models().recoveryCode().saveCodes(codes, userId); + await super.models().keyValue().setValue(`RecoveryCode::isNewlyCreated::${userId}`, 1); + await super.models().session().deleteByUserId(userId, currentSessionId); + await super.models().application().deleteByUserId(userId); + }, 'UserModel::enableMFA'); + + await this.models().notification().add(userId, NotificationKey.Any, NotificationLevel.Important, 'Multi-factor authentication has been enabled for your account. Please remember to copy and save your recovery codes'); + } + } diff --git a/packages/server/src/models/factory.ts b/packages/server/src/models/factory.ts index fc4118f436..71df3100fd 100644 --- a/packages/server/src/models/factory.ts +++ b/packages/server/src/models/factory.ts @@ -77,6 +77,8 @@ import StorageModel from './StorageModel'; import UserDeletionModel from './UserDeletionModel'; import BackupItemModel from './BackupItemModel'; import TaskStateModel from './TaskStateModel'; +import ApplicationModel from './ApplicationModel'; +import RecoveryCodeModel from './RecoveryCodeModel'; export type NewModelFactoryHandler = (db: DbConnection)=> Models; @@ -182,6 +184,13 @@ export class Models { return new TaskStateModel(this.db_, this.dbSlave_, this.newModelFactory, this.config_); } + public application() { + return new ApplicationModel(this.db_, this.dbSlave_, this.newModelFactory, this.config_); + } + + public recoveryCode() { + return new RecoveryCodeModel(this.db_, this.dbSlave_, this.newModelFactory, this.config_); + } } export default function newModelFactory(db: DbConnection, dbSlave: DbConnection, config: Config): Models { diff --git a/packages/server/src/models/utils/user.ts b/packages/server/src/models/utils/user.ts index 3d31f74ad1..5354cbb730 100644 --- a/packages/server/src/models/utils/user.ts +++ b/packages/server/src/models/utils/user.ts @@ -37,3 +37,8 @@ export function totalSizeClass(user: User) { if (d >= .7) return 'is-warning'; return ''; } + +export function getIsMFAEnabled(user: User) { + if (!('totp_secret' in user)) throw new Error('Missing totp_secret property'); + return user.totp_secret.length > 0; +} diff --git a/packages/server/src/routes/admin/users.test.ts b/packages/server/src/routes/admin/users.test.ts index 2ff581e598..f17e7735f1 100644 --- a/packages/server/src/routes/admin/users.test.ts +++ b/packages/server/src/routes/admin/users.test.ts @@ -4,6 +4,7 @@ import { execRequest } from '../../utils/testing/apiUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, checkContextError, expectHttpError } from '../../utils/testing/testUtils'; import { uuidgen } from '@joplin/lib/uuid'; import { ErrorForbidden } from '../../utils/errors'; +import { AccountType } from '../../models/UserModel'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise { @@ -151,12 +152,56 @@ describe('admin/users', () => { expect(result).toContain(user2.email); }); + test('should search users by stripe_subscription_id', async () => { + const password = '!abc123456'; + const { user: admin } = await models().subscription().saveUserAndSubscription( + 'user@localhost', + 'user full name', + AccountType.Pro, + 'STRIPE_USER_ID', + 'STRIPE_SUB_ID', + ); + const { user } = await createUserAndSession(1, false); + await models().user().save({ id: admin.id, password, is_admin: 1 }); + const session = await models().session().authenticate(admin.email, password, ''); + const result = await execRequest(session.id, 'GET', 'admin/users', null, { + query: { + query: 'STRIPE_SUB_ID', + }, + }); + expect(result).toContain(admin.email); + expect(result).not.toContain(user.email); + }); + + test('should not return users when passing an invalid stripe_subscription_id', async () => { + const password = '!abc123456'; + const { user: admin } = await models().subscription().saveUserAndSubscription( + 'user@localhost', + 'user full name', + AccountType.Pro, + 'STRIPE_USER_ID', + 'STRIPE_SUB_ID', + ); + const { user } = await createUserAndSession(1, false); + await models().user().save({ id: admin.id, password, is_admin: 1 }); + const session = await models().session().authenticate(admin.email, password, null); + const result = await execRequest(session.id, 'GET', 'admin/users', null, { + query: { + query: 'INVALID_STRIPE_SUB_ID', + }, + }); + expect(result).not.toContain(admin.email); + expect(result).not.toContain(user.email); + }); + test('should delete sessions when changing password', async () => { const { user, session, password } = await createUserAndSession(1); - await models().session().authenticate(user.email, password); - await models().session().authenticate(user.email, password); - await models().session().authenticate(user.email, password); + const mfaCode = ''; + + await models().session().authenticate(user.email, password, mfaCode); + await models().session().authenticate(user.email, password, mfaCode); + await models().session().authenticate(user.email, password, mfaCode); expect(await models().session().count()).toBe(4); @@ -182,4 +227,25 @@ describe('admin/users', () => { await expectHttpError(async () => execRequest(adminSession.id, 'POST', `admin/users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode); }); + test('should delete all applications when changing password to enforce user login', async () => { + const { user, session } = await createUserAndSession(1); + + await models().application().createPreLoginRecord('random-string', ''); + await models().application().onAuthorizeUse('random-string', user.id); + + await models().application().createPreLoginRecord('random-string2', ''); + await models().application().onAuthorizeUse('random-string2', user.id); + + expect(await models().application().count()).toBe(2); + + await patchUser(session.id, { + id: user.id, + email: 'changed@example.com', + password: '111111', + password2: '111111', + }, '/admin/users/me'); + + expect(await models().application().count()).toBe(0); + }); + }); diff --git a/packages/server/src/routes/admin/users.ts b/packages/server/src/routes/admin/users.ts index ab845b867d..f387d96d22 100644 --- a/packages/server/src/routes/admin/users.ts +++ b/packages/server/src/routes/admin/users.ts @@ -16,10 +16,10 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize import { getCanShareFolder, totalSizeClass } from '../../models/utils/user'; import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select'; import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl, adminUsersUrl, setQueryParameters } from '../../utils/urlUtils'; -import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe'; +import { cancelSubscriptionByUserId, initStripe, recheckPaymentStatus, updateSubscriptionType } from '../../utils/stripe'; import { createCsrfTag } from '../../utils/csrf'; import { formatDateTime, Hour } from '../../utils/time'; -import { startImpersonating, stopImpersonating } from './utils/users/impersonate'; +import { startImpersonating } from './utils/users/impersonate'; import { userFlagToString } from '../../models/UserFlagModel'; import { _ } from '@joplin/lib/locale'; import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table'; @@ -108,17 +108,34 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => { const pagination = makeTablePagination(ctx.query, 'full_name', PaginationOrderDir.ASC); pagination.limit = 1000; const page = await ctx.joplin.models.user().allPaginated(pagination, { + fields: [ + 'users.id as id', + 'full_name', + 'email', + 'account_type', + 'max_item_size', + 'total_item_size', + 'max_total_item_size', + 'can_share_folder', + 'enabled', + ], queryCallback: (query: Knex.QueryBuilder) => { if (!showDisabled) { void query.where('enabled', '=', 1); } if (searchQuery) { - void query.where(qb => { - void qb - .whereRaw('lower(full_name) like ?', [`%${searchQuery}%`]) - .orWhereRaw('lower(email) like ?', [`%${searchQuery}%`]); - }); + void query + .select([ + 'subscriptions.stripe_subscription_id', + ]) + .leftJoin('subscriptions', 'users.id', 'subscriptions.user_id') + .where(qb => { + void qb + .whereRaw('lower(full_name) like ?', [`%${searchQuery}%`]) + .orWhereRaw('lower(email) like ?', [`%${searchQuery}%`]) + .orWhereRaw('lower(subscriptions.stripe_subscription_id) = ?', [searchQuery]); + }); } return query; @@ -271,7 +288,7 @@ router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time); } - view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id; + view.content.showImpersonateButton = !isNew && user.id !== owner.id; view.content.showRestoreButton = !isNew && !user.enabled; view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion; view.content.showResetPasswordButton = !isNew && user.enabled; @@ -309,9 +326,10 @@ interface FormFields { update_subscription_basic_button: string; update_subscription_pro_button: string; impersonate_button: string; - stop_impersonate_button: string; + // stop_impersonate_button: string; delete_user_flags: string; schedule_deletion_button: string; + recheck_invoice_button: string; } router.post('admin/users', async (path: SubPath, ctx: AppContext) => { @@ -319,6 +337,8 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => { const owner = ctx.joplin.owner; let userId = userIsMe(path) ? owner.id : path.id; + const stripe = initStripe(); + try { const body = await formParse(ctx.req); const fields = body.fields as FormFields; @@ -341,11 +361,14 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => { // When changing the password, we also clear all session IDs for // that user, except the current one (otherwise they would be // logged out). - if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx)); + if (userToSave.password) { + await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx)); + await models.application().deleteByUserId(userToSave.id); + } } - } else if (fields.stop_impersonate_button) { - await stopImpersonating(ctx); - return redirect(ctx, config().baseUrl); + // } else if (fields.stop_impersonate_button) { + // await stopImpersonating(ctx); + // return redirect(ctx, config().baseUrl); } else if (fields.disable_button || fields.restore_button) { const user = await models.user().load(path.id); await models.user().checkIfAllowed(owner, AclAction.Delete, user); @@ -355,14 +378,16 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => { await models.user().save({ id: user.id, must_set_password: 1 }); await models.user().sendAccountConfirmationEmail(user); } else if (fields.impersonate_button) { - await startImpersonating(ctx, userId); + await startImpersonating(ctx, userId, ctx.URL.href); return redirect(ctx, config().baseUrl); } else if (fields.cancel_subscription_button) { await cancelSubscriptionByUserId(models, userId); } else if (fields.update_subscription_basic_button) { - await updateSubscriptionType(models, userId, AccountType.Basic); + await updateSubscriptionType(stripe, models, userId, AccountType.Basic); } else if (fields.update_subscription_pro_button) { - await updateSubscriptionType(models, userId, AccountType.Pro); + await updateSubscriptionType(stripe, models, userId, AccountType.Pro); + } else if (fields.recheck_invoice_button) { + await recheckPaymentStatus(stripe, models, userId); } else if (fields.schedule_deletion_button) { const deletionDate = Date.now() + 24 * Hour; diff --git a/packages/server/src/routes/admin/utils/users/impersonate.test.ts b/packages/server/src/routes/admin/utils/users/impersonate.test.ts index 5c089d9195..c7a014bada 100644 --- a/packages/server/src/routes/admin/utils/users/impersonate.test.ts +++ b/packages/server/src/routes/admin/utils/users/impersonate.test.ts @@ -24,7 +24,7 @@ describe('users/impersonate', () => { cookieSet(ctx, 'sessionId', adminSession.id); - await startImpersonating(ctx, user.id); + await startImpersonating(ctx, user.id, 'http://localhost'); { expect(cookieGet(ctx, 'adminSessionId')).toBe(adminSession.id); @@ -32,12 +32,13 @@ describe('users/impersonate', () => { expect(sessionUser.id).toBe(user.id); } - await stopImpersonating(ctx); + const returnUrl = await stopImpersonating(ctx); { expect(cookieGet(ctx, 'adminSessionId')).toBeFalsy(); const sessionUser = await models().session().sessionUser(cookieGet(ctx, 'sessionId')); expect(sessionUser.id).toBe(adminUser.id); + expect(returnUrl).toBe('http://localhost'); } }); @@ -49,18 +50,7 @@ describe('users/impersonate', () => { cookieSet(ctx, 'sessionId', session.id); - await expectThrow(async () => startImpersonating(ctx, adminUser.id)); + await expectThrow(async () => startImpersonating(ctx, adminUser.id, 'http://localhost')); }); - // test('should not stop impersonating if not admin', async function() { - // const ctx = await koaAppContext(); - - // await createUserAndSession(1, true); - // const { session } = await createUserAndSession(2); - - // cookieSet(ctx, 'adminSessionId', session.id); - - // await expectThrow(async () => stopImpersonating(ctx)); - // }); - }); diff --git a/packages/server/src/routes/admin/utils/users/impersonate.ts b/packages/server/src/routes/admin/utils/users/impersonate.ts index 09c0c79e7e..f76d716024 100644 --- a/packages/server/src/routes/admin/utils/users/impersonate.ts +++ b/packages/server/src/routes/admin/utils/users/impersonate.ts @@ -8,7 +8,7 @@ export function getImpersonatorAdminSessionId(ctx: AppContext): string { return cookieGet(ctx, 'adminSessionId'); } -export async function startImpersonating(ctx: AppContext, userId: Uuid) { +export async function startImpersonating(ctx: AppContext, userId: Uuid, returnUrl: string) { const adminSessionId = contextSessionId(ctx); const user = await ctx.joplin.models.session().sessionUser(adminSessionId); if (!user) throw new Error(`No user for session: ${adminSessionId}`); @@ -16,18 +16,24 @@ export async function startImpersonating(ctx: AppContext, userId: Uuid) { const impersonatedSession = await ctx.joplin.models.session().createUserSession(userId); cookieSet(ctx, 'adminSessionId', adminSessionId); + cookieSet(ctx, 'impersonationReturnUrl', returnUrl); cookieSet(ctx, 'sessionId', impersonatedSession.id); } -export async function stopImpersonating(ctx: AppContext) { +export async function stopImpersonating(ctx: AppContext): Promise { const adminSessionId = cookieGet(ctx, 'adminSessionId'); if (!adminSessionId) throw new Error('Missing cookie adminSessionId'); + const returnUrl = cookieGet(ctx, 'impersonationReturnUrl'); + // This function simply moves the adminSessionId back to sessionId. There's // no need to check if anything is valid because that will be done by other // session checking routines. We also don't want this function to fail // because it would leave the cookies in an invalid state (for example if // the admin has lost their sessions, or the user no longer exists). cookieDelete(ctx, 'adminSessionId'); + cookieDelete(ctx, 'impersonationReturnUrl'); cookieSet(ctx, 'sessionId', adminSessionId); + + return returnUrl; } diff --git a/packages/server/src/routes/api/application_auth.test.ts b/packages/server/src/routes/api/application_auth.test.ts new file mode 100644 index 0000000000..0fd6000970 --- /dev/null +++ b/packages/server/src/routes/api/application_auth.test.ts @@ -0,0 +1,105 @@ +import { afterAllTests, beforeEachDb, models, createUserAndSession, beforeAllDb, createApplicationCredentials } from '../../utils/testing/testUtils'; +import { getApi } from '../../utils/testing/apiUtils'; +import { checkPassword } from '../../utils/auth'; + +describe('application_auth', () => { + + beforeAll(async () => { + await beforeAllDb('application_auth'); + }); + + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should return a password and a id if the user has authorized', async () => { + + const applicationAuthId = 'appAuthId'; + const { user } = await createUserAndSession(1, false); + + const response = await createApplicationCredentials(user.id, applicationAuthId); + + const applications = await models().application().all(); + expect(applications.length).toBe(1); + + const application = applications[0]; + expect(application.user_id).toBe(user.id); + expect(application.password).not.toBeFalsy(); + + expect(response.id).toBe(application.id); + expect(await checkPassword(response.password, application.password)).toBe(true); + }); + + test('should throw an error if a invalid application_auth_id is sent to the API', async () => { + + const applicationAuthId = 'appAuthId'; + const { user } = await createUserAndSession(1, false); + await models().application().createPreLoginRecord( + applicationAuthId, + '', + undefined, + undefined, + undefined, + ); + await models().application().onAuthorizeUse(applicationAuthId, user.id); + + await expect( + getApi('', 'application_auth/asdf'), + ).rejects.toThrow('Application not authorized yet.'); + + const applications = await models().application().all(); + expect(applications.length).toBe(1); + + const application = applications[0]; + expect(application.user_id).toBe(user.id); + expect(application.password).toBe(''); + }); + + test('should return an error message if application_auth_id is not a string', async () => { + + const applicationAuthId = 'appAuthId'; + const { user } = await createUserAndSession(1, false); + await models().application().createPreLoginRecord( + applicationAuthId, + '', + '', + '0', + '0', + ); + await models().application().onAuthorizeUse(applicationAuthId, user.id); + + await expect( + getApi('', 'application_auth/[asdf, asdf]'), + ).rejects.toThrow('Application not authorized yet.'); + + const applications = await models().application().all(); + expect(applications.length).toBe(1); + + const application = applications[0]; + expect(application.user_id).toBe(user.id); + expect(application.password).toBe(''); + }); + + test('should return an error message if user has not authorized application use', async () => { + + const applicationAuthId = 'appAuthId'; + await createUserAndSession(1, false); + await models().application().createPreLoginRecord( + applicationAuthId, + '', + undefined, + undefined, + undefined, + ); + + await expect( + getApi('', `application_auth/${applicationAuthId}`), + ).rejects.toThrow('Application not authorized yet.'); + }); + +}); diff --git a/packages/server/src/routes/api/application_auth.ts b/packages/server/src/routes/api/application_auth.ts new file mode 100644 index 0000000000..ae9be3b822 --- /dev/null +++ b/packages/server/src/routes/api/application_auth.ts @@ -0,0 +1,16 @@ + +import { SubPath } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; + +const router = new Router(RouteType.Api); + +router.public = true; + +// id here should be the same code used in the index/applications/:id/confirm +router.get('api/application_auth/:id', async (path: SubPath, ctx: AppContext) => { + return await ctx.joplin.models.application().createAppPassword(path.id); +}); + +export default router; diff --git a/packages/server/src/routes/api/items.test.ts b/packages/server/src/routes/api/items.test.ts index 3f2a7f92d1..b8d17e3075 100644 --- a/packages/server/src/routes/api/items.test.ts +++ b/packages/server/src/routes/api/items.test.ts @@ -442,4 +442,5 @@ describe('api/items', () => { // Should not have deleted the other item expect(await models().item().loadByJopId(user1.id, '00000000000000000000000000000003')).toBeTruthy(); }); + }); diff --git a/packages/server/src/routes/api/items.ts b/packages/server/src/routes/api/items.ts index eb2aa42d2f..8bfc04c41d 100644 --- a/packages/server/src/routes/api/items.ts +++ b/packages/server/src/routes/api/items.ts @@ -51,9 +51,10 @@ export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: b // query parameter. if (ctx.query['share_id']) { saveOptions.shareId = ctx.query['share_id'] as string; - await ctx.joplin.models.item().checkIfAllowed(ctx.joplin.owner, AclAction.Create, { jop_share_id: saveOptions.shareId }); } + // await ctx.joplin.models.item().checkIfAllowed(ctx.joplin.owner, AclAction.Create, { jop_share_id: saveOptions.shareId }); + items = [ { name: ctx.joplin.models.item().pathToName(path.id), @@ -67,7 +68,7 @@ export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: b const output = await ctx.joplin.models.item().saveFromRawContent(ctx.joplin.owner, items, saveOptions); for (const [name] of Object.entries(output)) { - if (output[name].item) output[name].item = ctx.joplin.models.item().toApiOutput(output[name].item) as Item; + if (output[name].item) output[name].item = (await ctx.joplin.models.item().toApiOutput(output[name].item)) as Item; if (output[name].error) output[name].error = errorToPlainObject(output[name].error); } diff --git a/packages/server/src/routes/api/sessions.test.ts b/packages/server/src/routes/api/sessions.test.ts index a6b4952485..1d7948700f 100644 --- a/packages/server/src/routes/api/sessions.test.ts +++ b/packages/server/src/routes/api/sessions.test.ts @@ -25,6 +25,28 @@ async function postSession(email: string, password: string): Promise return context; } +const createApplicationCredentials = async () => { + const applicationAuthId = 'applicationAuthId1'; + const { user } = await createUserAndSession(1, false); + await models().application().createPreLoginRecord( + applicationAuthId, + '', + '', + '', + '', + ); + await models().application().onAuthorizeUse(applicationAuthId, user.id); + const response = await models().application().createAppPassword(applicationAuthId); + if (response.status === 'finished') { + return { + user, + password: response.password, + id: response.id, + }; + } + return {}; +}; + describe('api/sessions', () => { beforeAll(async () => { @@ -220,4 +242,72 @@ describe('api/sessions', () => { }); + test('should login with application credentials', async () => { + + const { user, password, id } = await createApplicationCredentials(); + + const context = await koaAppContext({ + request: { + method: 'POST', + url: '/api/sessions', + body: { + email: id, + password: password, + }, + }, + }); + + await routeHandler(context); + + expect(context.response.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + expect(!!(context.response.body as any).id).toBe(true); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + const session: Session = await models().session().load((context.response.body as any).id); + expect(session.user_id).toBe(user.id); + }); + + test('should update application information on successful login', async () => { + jest + .useFakeTimers() + .setSystemTime(new Date('2023-11-27')); + + const { password, id } = await createApplicationCredentials(); + + const context = await koaAppContext({ + request: { + method: 'POST', + url: '/api/sessions', + body: { + email: id, + password: password, + platform: 1, + type: 2, + version: '2.13.1', + }, + }, + }); + + // before request + const applicationsBefore = await models().application().all(); + expect(applicationsBefore.length).toBe(1); + + expect(applicationsBefore[0].platform).toBe(0); + expect(applicationsBefore[0].type).toBe(0); + expect(applicationsBefore[0].version).toBe(''); + expect(applicationsBefore[0].last_access_time).toBe(0); + + await routeHandler(context); + expect(context.response.status).toBe(200); + + // after request + const applicationsAfter = await models().application().all(); + expect(applicationsAfter.length).toBe(1); + + expect(applicationsAfter[0].platform).toBe(1); + expect(applicationsAfter[0].type).toBe(2); + expect(applicationsAfter[0].version).toBe('2.13.1'); + expect(applicationsAfter[0].last_access_time).toBe(Date.now()); + }); }); diff --git a/packages/server/src/routes/api/sessions.ts b/packages/server/src/routes/api/sessions.ts index cce2e0a142..2f2ac67d39 100644 --- a/packages/server/src/routes/api/sessions.ts +++ b/packages/server/src/routes/api/sessions.ts @@ -1,24 +1,37 @@ import { SubPath } from '../../utils/routeUtils'; import Router from '../../utils/Router'; import { RouteType } from '../../utils/types'; -import { ErrorForbidden } from '../../utils/errors'; import { AppContext } from '../../utils/types'; import { bodyFields, userIp } from '../../utils/requestUtils'; -import { User } from '../../services/database/types'; import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce'; const router = new Router(RouteType.Api); router.public = true; +type SessionFields = { + email?: string; + password?: string; + platform?: number; + type?: number; + version?: string; +}; + router.post('api/sessions', async (_path: SubPath, ctx: AppContext) => { await limiterLoginBruteForce(userIp(ctx)); - const fields: User = await bodyFields(ctx.req); - const user = await ctx.joplin.models.user().login(fields.email, fields.password); - if (!user) throw new ErrorForbidden('Invalid username or password', { details: { email: fields.email } }); + const fields: SessionFields = await bodyFields(ctx.req); + + const clientInfo = { + ...fields, + ip: userIp(ctx), + }; + + // we pass null on mfaCode because the user shouldn't be able to make 2FA login over the API + const session = await ctx.joplin.models.session().authenticate(fields.email, fields.password, null); + + await ctx.joplin.models.application().updateOnNewLogin(fields.email, clientInfo); - const session = await ctx.joplin.models.session().createUserSession(user.id); return { id: session.id, user_id: session.user_id }; }); diff --git a/packages/server/src/routes/api/share_users.test.ts b/packages/server/src/routes/api/share_users.test.ts index 1246734308..2795b7a216 100644 --- a/packages/server/src/routes/api/share_users.test.ts +++ b/packages/server/src/routes/api/share_users.test.ts @@ -2,7 +2,7 @@ import { ShareType, ShareUserStatus } from '../../services/database/types'; import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createItemTree, expectHttpError } from '../../utils/testing/testUtils'; import { getApi, patchApi } from '../../utils/testing/apiUtils'; import { shareFolderWithUser, shareWithUserAndAccept } from '../../utils/testing/shareApiUtils'; -import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors'; +import { ErrorBadRequest } from '../../utils/errors'; import { PaginatedResults } from '../../models/utils/pagination'; describe('share_users', () => { @@ -39,21 +39,6 @@ describe('share_users', () => { expect(shareUsers.items.find(su => su.share.id === share2.id)).toBeTruthy(); }); - test('should not change someone else shareUser object', async () => { - const { user: user1, session: session1 } = await createUserAndSession(1); - const { user: user2, session: session2 } = await createUserAndSession(2); - - await createItemTree(user1.id, '', { '000000000000000000000000000000F1': {} }); - const folderItem = await models().item().loadByJopId(user1.id, '000000000000000000000000000000F1'); - const { shareUser } = await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.Folder, folderItem); - - // User can modify own UserShare object - await patchApi(session2.id, `share_users/${shareUser.id}`, { status: ShareUserStatus.Rejected }); - - // User cannot modify someone else UserShare object - await expectHttpError(async () => patchApi(session1.id, `share_users/${shareUser.id}`, { status: ShareUserStatus.Accepted }), ErrorForbidden.httpCode); - }); - test('should not allow accepting a share twice or more', async () => { const { session: session1 } = await createUserAndSession(1); const { session: session2 } = await createUserAndSession(2); diff --git a/packages/server/src/routes/api/shares.folder.test.ts b/packages/server/src/routes/api/shares.folder.test.ts index c47a6664f1..5b75dc78a3 100644 --- a/packages/server/src/routes/api/shares.folder.test.ts +++ b/packages/server/src/routes/api/shares.folder.test.ts @@ -1,5 +1,5 @@ import { ChangeType, Session, Share, ShareType, ShareUser, ShareUserStatus } from '../../services/database/types'; -import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createNote, createFolder, updateItem, createItemTree, updateNote, expectHttpError, createResource, expectNotThrow } from '../../utils/testing/testUtils'; +import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createNote, createFolder, updateItem, createItemTree, updateNote, expectHttpError, createResource, expectNotThrow, updateFolder, db } from '../../utils/testing/testUtils'; import { postApi, patchApi, getApi, deleteApi } from '../../utils/testing/apiUtils'; import { PaginatedDeltaChanges } from '../../models/ChangeModel'; import { inviteUserToShare, shareFolderWithUser } from '../../utils/testing/shareApiUtils'; @@ -7,7 +7,7 @@ import { msleep } from '../../utils/time'; import { ErrorForbidden } from '../../utils/errors'; import { resourceBlobPath, serializeJoplinItem, unserializeJoplinItem } from '../../utils/joplinUtils'; import { PaginatedItems } from '../../models/ItemModel'; -import { NoteEntity } from '@joplin/lib/services/database/types'; +import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; import { makeNoteSerializedBody } from '../../utils/testing/serializedItems'; const createSingleFolderShare = async (session1: Session, session2: Session) => { @@ -225,7 +225,7 @@ describe('shares.folder', () => { }); test('should share when a note is added to a shared folder', async () => { - const { session: session1 } = await createUserAndSession(1); + const { user: user1, session: session1 } = await createUserAndSession(1); const { user: user2, session: session2 } = await createUserAndSession(2); const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', [ @@ -247,9 +247,49 @@ describe('shares.folder', () => { await models().share().updateSharedItems3(); - const newChildren = await models().item().children(user2.id); - expect(newChildren.items.length).toBe(3); - expect(!!newChildren.items.find(i => i.name === '00000000000000000000000000000002.md')).toBe(true); + { + const newChildren = await models().item().children(user2.id); + expect(newChildren.items.length).toBe(3); + expect(!!newChildren.items.find(i => i.name === '00000000000000000000000000000002.md')).toBe(true); + } + + await createNote(session2.id, { + id: '00000000000000000000000000000003', + parent_id: '000000000000000000000000000000F2', + share_id: share.id, + }); + + await models().share().updateSharedItems3(); + + { + const newChildren = await models().item().children(user1.id); + expect(newChildren.items.length).toBe(4); + expect(!!newChildren.items.find(i => i.name === '00000000000000000000000000000003.md')).toBe(true); + } + }); + + test('should allow recipient to modify the root folder title', async () => { + const { session: session1 } = await createUserAndSession(1); + const { session: session2 } = await createUserAndSession(2); + + const { item: rootFolderItem } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', [ + { + id: '000000000000000000000000000000F2', + children: [ + { + id: '00000000000000000000000000000001', + }, + ], + }, + ]); + + await updateFolder(session2.id, { + id: '000000000000000000000000000000F2', + title: 'update from session 2', + }); + + const rootFolder = await models().item().loadAsJoplinItem(rootFolderItem.id); + expect(rootFolder.title).toBe('update from session 2'); }); test('should update share status when note parent changes', async () => { @@ -609,30 +649,6 @@ describe('shares.folder', () => { expect((await models().item().children(user2.id)).items.length).toBe(0); }); - // test('should associate a user with the item after sharing', async function() { - // const { session: session1 } = await createUserAndSession(1); - // const { user: user2, session: session2 } = await createUserAndSession(2); - - // const item = await createItem(session1.id, 'root:/test.txt:', 'testing'); - - // await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.App, item); - - // expect((await models().userItem().all()).length).toBe(2); - // expect(!!(await models().userItem().all()).find(ui => ui.user_id === user2.id)).toBe(true); - // }); - - // test('should not share an already shared item', async function() { - // const { session: session1 } = await createUserAndSession(1); - // const { user: user2, session: session2 } = await createUserAndSession(2); - // const { user: user3, session: session3 } = await createUserAndSession(3); - - // const item = await createItem(session1.id, 'root:/test.txt:', 'testing'); - - // await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.App, item); - // const error = await checkThrowAsync(async () => shareWithUserAndAccept(session2.id, session3.id, user3, ShareType.App, item)); - // expect(error.httpCode).toBe(ErrorBadRequest.httpCode); - // }); - test('should see delta changes for linked items', async () => { const { user: user1, session: session1 } = await createUserAndSession(1); const { session: session2 } = await createUserAndSession(2); @@ -873,6 +889,22 @@ describe('shares.folder', () => { expect((await models().userItem().byUserId(user2.id)).length).toBe(0); }); + test('should not throw an error when deleting a root folder associated with a non-existing share', async () => { + const { session: session1 } = await createUserAndSession(1); + const { session: session2 } = await createUserAndSession(2); + + const { share, item } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [ + { + id: '000000000000000000000000000000F1', + children: [], + }, + ]); + + await db().from('shares').where('id', '=', share.id).delete(); + + await expectNotThrow(async () => deleteApi(session2.id, `items/root:/${item.jop_id}.md:`)); + }); + test('should unshare a folder', async () => { // The process to unshare a folder is as follow: // @@ -913,28 +945,6 @@ describe('shares.folder', () => { expect((await models().userItem().byUserId(user2.id)).length).toBe(0); }); - // test('should handle incomplete sync - orphan note is moved out of shared folder', async function() { - // // - A note and its folder are moved to a shared folder. - // // - However when data is synchronised, only the note is synced (not the folder). - // // - Then later the note is synchronised. - // // In that case, we need to make sure that both folder and note are eventually shared. - - // const { session: session1 } = await createUserAndSession(1); - // const { user: user2, session: session2 } = await createUserAndSession(2); - - // const folderItem1 = await createFolder(session1.id, { id: '000000000000000000000000000000F1' }); - // const noteItem1 = await createNote(session1.id, { id: '00000000000000000000000000000001', parent_id: '000000000000000000000000000000F2' }); - // await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.JoplinRootFolder, folderItem1); - // // await models().share().updateSharedItems2(); - - // await createFolder(session1.id, { id: '000000000000000000000000000000F2', parent_id: folderItem1.jop_id }); - // await models().share().updateSharedItems2(user2.id); - - // const children = await models().item().children(user2.id); - // expect(children.items.length).toBe(3); - // expect(children.items.find(c => c.id === noteItem1.id)).toBeTruthy(); - // }); - test('should check permissions - cannot share a folder with yourself', async () => { const { user: user1, session: session1 } = await createUserAndSession(1); diff --git a/packages/server/src/routes/api/shares.ts b/packages/server/src/routes/api/shares.ts index d4032ea2bb..856f9129bf 100644 --- a/packages/server/src/routes/api/shares.ts +++ b/packages/server/src/routes/api/shares.ts @@ -119,8 +119,8 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => { router.get('api/shares', async (_path: SubPath, ctx: AppContext) => { ownerRequired(ctx); - const ownedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[]; - const participatedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().participatedSharesByUser(ctx.joplin.owner.id)); + const ownedShares = (await ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id))) as Share[]; + const participatedShares = (await ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().participatedSharesByUser(ctx.joplin.owner.id))); // Fake paginated results so that it can be added later on, if needed. return { diff --git a/packages/server/src/routes/api/users.test.ts b/packages/server/src/routes/api/users.test.ts index 50f5f81530..ccfacd8524 100644 --- a/packages/server/src/routes/api/users.test.ts +++ b/packages/server/src/routes/api/users.test.ts @@ -80,6 +80,15 @@ describe('api/users', () => { expect(results.items.length).toBe(3); }); + + test('should not return password hash', async () => { + const { session: adminSession } = await createUserAndSession(1, true); + const { user } = await createUserAndSession(2); + + const fetchedUser: User = await getApi(adminSession.id, `users/${user.id}`); + + expect(fetchedUser.password).toBeUndefined(); + }); test('should not allow changing non-whitelisted properties', async () => { const { session, user } = await createUserAndSession(1, false); expect(user.is_admin).toBe(0); diff --git a/packages/server/src/routes/api/users.ts b/packages/server/src/routes/api/users.ts index aefc03650a..7c3f5c1c86 100644 --- a/packages/server/src/routes/api/users.ts +++ b/packages/server/src/routes/api/users.ts @@ -24,7 +24,7 @@ async function postedUserFromContext(ctx: AppContext): Promise { router.get('api/users/:id', async (path: SubPath, ctx: AppContext) => { const user = await fetchUser(path, ctx); await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user); - return user; + return ctx.joplin.models.user().toApiOutput(user); }); router.publicSchemas.push('api/users/:id/public_key'); @@ -52,8 +52,8 @@ router.post('api/users', async (_path: SubPath, ctx: AppContext) => { user.password = uuidgen(); user.must_set_password = 1; user.email_confirmed = 0; - const output = await ctx.joplin.models.user().save(user); - return ctx.joplin.models.user().toApiOutput(output); + const createdUser = await ctx.joplin.models.user().save(user); + return ctx.joplin.models.user().toApiOutput(await ctx.joplin.models.user().load(createdUser.id)); }); router.get('api/users', async (_path: SubPath, ctx: AppContext) => { diff --git a/packages/server/src/routes/api/utils/types.ts b/packages/server/src/routes/api/utils/types.ts new file mode 100644 index 0000000000..44759156c8 --- /dev/null +++ b/packages/server/src/routes/api/utils/types.ts @@ -0,0 +1,11 @@ +export interface PostSharesUserInput { + email?: string; + master_key?: string; +} + +export interface PostSharesInput { + folder_id?: string; + note_id?: string; + master_key_id?: string; + recursive?: number; +} diff --git a/packages/server/src/routes/index/applications.test.ts b/packages/server/src/routes/index/applications.test.ts new file mode 100644 index 0000000000..f143c710f2 --- /dev/null +++ b/packages/server/src/routes/index/applications.test.ts @@ -0,0 +1,132 @@ +import { ApplicationPlatform, ApplicationType } from '@joplin/lib/types'; +import routeHandler from '../../middleware/routeHandler'; +import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, parseHtml, createUserAndSession, expectHttpError } from '../../utils/testing/testUtils'; +import * as crypto from '../../utils/crypto'; +import { AppContext } from '../../utils/types'; +import { execRequest } from '../../utils/testing/apiUtils'; +import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors'; + +async function getApplicationConfirm(applicationAuthId: string, sessionId?: string): Promise { + const context = await koaAppContext({ + request: { + method: 'GET', + url: `/applications/${applicationAuthId}/confirm`, + query: { + platform: ApplicationPlatform.Windows, + type: ApplicationType.Desktop, + version: 'v2.19.2', + }, + }, + sessionId: sessionId, + ip: '192.0.0.1', + }); + + await routeHandler(context); + return context; +} + +async function doApplicationConfirm(appAuthId: string, sessionId: string): Promise { + const context = await koaAppContext({ + sessionId, + request: { + method: 'POST', + url: `/applications/${appAuthId}/confirm`, + body: { + applicationAuthId: appAuthId, + }, + }, + }); + + await routeHandler(context); + return context; +} + +describe('index/applications', () => { + + beforeAll(async () => { + await beforeAllDb('index_applications'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should be able to confirm an application login', async () => { + const { user, session } = await createUserAndSession(1); + await models().user().save({ id: user.id }); + + await getApplicationConfirm('asdf', session.id); + await doApplicationConfirm('asdf', session.id); + + const applications = await models().application().all(); + + expect(applications.length).toBe(1); + expect(applications[0].user_id).toBe(user.id); + expect(applications[0].last_access_time).toBe(0); + expect(applications[0].password).toBe(''); + }); + + test('should not override values if confirmation already happened and show error message', async () => { + const { user, session } = await createUserAndSession(1); + await models().user().save({ id: user.id }); + + await getApplicationConfirm('asdf', session.id); + await doApplicationConfirm('asdf', session.id); + + // Confirm again + const [applicationBefore] = await models().application().all(); + const context2 = await doApplicationConfirm('asdf', session.id); + const [applicationAfter] = await models().application().all(); + + const doc = parseHtml(context2.response.body as string); + const alertNode = doc.querySelector('div.notification.is-danger'); + + expect(alertNode.textContent.trim()).toBe('Application Auth Id has already been used, go back to the Joplin application to finish the login process: asdf'); + expect(applicationBefore).toEqual(applicationAfter); + }); + + test('should create a pre login record in applications', async () => { + const { user, session } = await createUserAndSession(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const checkCode = jest.spyOn(crypto, 'isValidMFACode'); + checkCode.mockReturnValue(true); + + await getApplicationConfirm('asdf', session.id); + + const applicationAuthIdInformation = await models().keyValue().value('ApplicationAuthId::asdf'); + const ulcInfo = JSON.parse(applicationAuthIdInformation); + + expect(ulcInfo.ip).toBe('192.0.0.1'); + expect(ulcInfo.platform).toBe(ApplicationPlatform.Windows); + expect(ulcInfo.type).toBe(ApplicationType.Desktop); + expect(ulcInfo.version).toBe('v2.19.2'); + }); + + test('should throw Forbidden error if user is not logged in', async () => { + const { user, session } = await createUserAndSession(1); + await models().user().save({ id: user.id }); + + await getApplicationConfirm('asdf', session.id); + await models().session().delete(session.id); + + await expectHttpError(async () => execRequest(session.id, 'POST', 'applications/asdf/confirm', { applicationAuthId: 'asdf2' }, null), ErrorForbidden.httpCode); + }); + + test('should throw Bad Request if application auth id does not exist', async () => { + const { user, session } = await createUserAndSession(1); + await models().user().save({ id: user.id }); + + await getApplicationConfirm('asdf', session.id); + await expectHttpError(async () => execRequest(session.id, 'POST', 'applications/asdf2/confirm', { applicationAuthId: 'asdf2' }, null), ErrorBadRequest.httpCode); + const context = await doApplicationConfirm('asdf2', session.id); + const doc = parseHtml(context.response.body as string); + const alertNode = doc.querySelector('div.notification.is-danger'); + + expect(alertNode.textContent.trim()).toBe('Check if you are not already logged in on your Joplin application, client associated with this application auth id not found: asdf2'); + }); +}); diff --git a/packages/server/src/routes/index/applications.ts b/packages/server/src/routes/index/applications.ts new file mode 100644 index 0000000000..2b0ff1bbfa --- /dev/null +++ b/packages/server/src/routes/index/applications.ts @@ -0,0 +1,126 @@ + +import { SubPath, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; +import defaultView from '../../utils/defaultView'; +import { formParse, userIp } from '../../utils/requestUtils'; +import { applicationDeleteUrl, applicationsConfirmUrl, applicationsUrl, homeUrl, loginUrl } from '../../utils/urlUtils'; +import { createCsrfTag } from '../../utils/csrf'; +import { _ } from '@joplin/lib/locale'; +import config from '../../config'; +import { formatDate } from '../../utils/time'; +import ApplicationModel, { ActiveApplication } from '../../models/ApplicationModel'; +import { AclAction } from '../../models/BaseModel'; +import { ErrorForbidden } from '../../utils/errors'; + +const router: Router = new Router(RouteType.Web); + +router.publicSchemas.push('applications/:id/confirm'); + +function makeView(error: Error = null, applicationAuthId: string, csrfTag: string) { + const view = defaultView('applications/confirm', 'Confirm application authorisation'); + view.content = { + error, + applicationAuthId, + csrfTag, + postUrl: applicationsConfirmUrl(applicationAuthId), + cancelRedirect: homeUrl(), + title: _('Authorisation required'), + description: _('Joplin needs your permission to access your %s account and to synchronise data with it.', config().appName), + cancel: _('Cancel'), + authorise: _('Authorise'), + }; + return view; +} + +type TableColumn = { + key: 'platform' | 'version' | 'ip' | 'created_time' | 'last_access_time'; + label: string; +}; + +const buildApplicationTable = (activeApplications: ActiveApplication[], applicationModel: ApplicationModel) => { + const tableColumns: TableColumn[] = [ + { key: 'platform', label: 'Platform' }, + { key: 'version', label: 'Application' }, + { key: 'ip', label: 'IP' }, + { key: 'created_time', label: 'Connected' }, + { key: 'last_access_time', label: 'Last active' }, + ]; + + const formattedData = activeApplications.map(row => { + return { + id: row.id, + ip: row.ip, + platform: applicationModel.getPlatformName(row.platform), + version: row.version ? `v${row.version}` : 'Unknown', + last_access_time: formatDate(parseInt(row.last_access_time, 10)), + created_time: formatDate(parseInt(row.created_time, 10)), + postUrl: applicationDeleteUrl(row.id), + }; + }); + + const table = formattedData.map(formattedDataRow => { + return { + deleteUrl: applicationDeleteUrl(formattedDataRow.id), + columns: tableColumns.map(tableColumn => { + return { + ...tableColumn, + value: formattedDataRow[tableColumn.key], + }; + }), + + }; + }); + + return table; +}; + +router.get('applications', async (_path: SubPath, ctx: AppContext) => { + const activeApplications = await ctx.joplin.models.application().activeApplications(ctx.joplin.owner.id); + const view = defaultView('applications/applications', 'Applications'); + view.content = { + csrfTag: await createCsrfTag(ctx), + applications: buildApplicationTable(activeApplications, ctx.joplin.models.application()), + }; + + return view; +}); + +router.post('applications/:id/delete', async (path: SubPath, ctx: AppContext) => { + const application = await ctx.joplin.models.application().load(path.id, { fields: ['user_id'] }); + await ctx.joplin.models.application().checkIfAllowed(ctx.joplin.owner, AclAction.Delete, application); + await ctx.joplin.models.application().delete(path.id); + + return redirect(ctx, applicationsUrl()); +}); + +router.get('applications/:id/confirm', async (path: SubPath, ctx: AppContext) => { + if (!ctx.joplin.owner) { + return redirect(ctx, loginUrl(path.id)); + } + + await ctx.joplin.models.application().createPreLoginRecord( + path.id, + userIp(ctx), + ctx.query.version as string, + ctx.query.platform as string, + ctx.query.type as string, + ); + + return makeView(null, path.id, await createCsrfTag(ctx)); +}); + +router.post('applications/:id/confirm', async (_path: SubPath, ctx: AppContext) => { + if (!ctx.joplin.owner) { + throw new ErrorForbidden('Your sessions must have expired. Please login again.'); + } + + const body = await formParse(ctx.req); + + await ctx.joplin.models.application().onAuthorizeUse(body.fields.applicationAuthId, ctx.joplin.owner.id); + + return redirect(ctx, homeUrl()); +}); + +export default router; diff --git a/packages/server/src/routes/index/home.ts b/packages/server/src/routes/index/home.ts index 2a7e3df99d..db8f1ac3b0 100644 --- a/packages/server/src/routes/index/home.ts +++ b/packages/server/src/routes/index/home.ts @@ -108,7 +108,7 @@ router.get('home', async (_path: SubPath, ctx: AppContext) => { }, { label: 'Can receive notebooks', - value: yesOrNo(getCanReceiveFolder(user)), + value: !!yesOrNo(getCanReceiveFolder(user)), show: true, }, ], diff --git a/packages/server/src/routes/index/login.test.ts b/packages/server/src/routes/index/login.test.ts index c460895abe..8c6f763b53 100644 --- a/packages/server/src/routes/index/login.test.ts +++ b/packages/server/src/routes/index/login.test.ts @@ -1,8 +1,9 @@ import { Session } from '../../services/database/types'; import routeHandler from '../../middleware/routeHandler'; -import { cookieGet } from '../../utils/cookies'; +import { cookieDelete, cookieGet } from '../../utils/cookies'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, parseHtml, createUser } from '../../utils/testing/testUtils'; import { AppContext } from '../../utils/types'; +import * as crypto from '../../utils/crypto'; async function doLogin(email: string, password: string): Promise { const context = await koaAppContext({ @@ -20,10 +21,33 @@ async function doLogin(email: string, password: string): Promise { return context; } +async function doLoginWithMFA(email: string, password: string, mfaCode: string, applicationAuthId?: string, recoveryCode?: string): Promise { + const context = await koaAppContext({ + request: { + method: 'POST', + url: '/login', + body: { + email: email, + password: password, + mfaCode: mfaCode, + applicationAuthId, + recoveryCode: recoveryCode, + }, + }, + }); + + await routeHandler(context); + return context; +} + describe('index_login', () => { beforeAll(async () => { - await beforeAllDb('index_login'); + await beforeAllDb('index_login', { + envValues: { + MFA_ENCRYPTION_KEY: 'b73e50cd8970ed5eefb980d860c8406eb8f6519a90ce9c8f9f2b2b73661a5ab21', + }, + }); }); afterAll(async () => { @@ -106,4 +130,103 @@ describe('index_login', () => { expect(getContext.response.status).toBe(200); }); + test('should not be able to login with credentials if has MFA enabled', async () => { + const user = await createUser(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const context = await doLogin(user.email, '123456'); + const sessionId = cookieGet(context, 'sessionId'); + + expect(sessionId).toBe(undefined); + }); + + test('should show mfa input field if user tries to login but has MFA enabled', async () => { + const user = await createUser(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const context = await doLogin(user.email, '123456'); + + expect(context.response.body.toString().includes('')).toBe(true); + }); + + test('should populate the email and password input fields with sent values if user has MFA enabled', async () => { + const user = await createUser(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const context = await doLogin(user.email, '123456'); + + expect(context.response.body.toString().includes('')).toBe(true); + expect(context.response.body.toString().includes('')).toBe(true); + }); + + test('should be able to login with MFA enabled if MFA code is valid', async () => { + const user = await createUser(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const checkCode = jest.spyOn(crypto, 'isValidMFACode'); + checkCode.mockReturnValue(true); + + const context = await doLoginWithMFA(user.email, '123456', '654321'); + + const sessionId = cookieGet(context, 'sessionId'); + const session: Session = await models().session().load(sessionId); + expect(session.user_id).toBe(user.id); + }); + + test('should not be able to login with MFA enabled if MFA code is not valid', async () => { + const user = await createUser(1); + await models().user().save({ id: user.id, totp_secret: 'totp_secret' }); + + const checkCode = jest.spyOn(crypto, 'isValidMFACode'); + checkCode.mockReturnValue(false); + + const context = await doLoginWithMFA(user.email, '123456', '654321'); + + const sessionId = cookieGet(context, 'sessionId'); + expect(sessionId).toBe(undefined); + }); + + test('should be able to login using recovery code if MFA is enabled', async () => { + const user = await createUser(1); + await models().user().enableMFA(user.id, 'asdf', ''); + const recoveryCodes = await models().recoveryCode().loadByUserId(user.id); + + const context = await doLoginWithMFA(user.email, '123456', undefined, undefined, recoveryCodes[0].code); + + const sessionId = cookieGet(context, 'sessionId'); + const session: Session = await models().session().load(sessionId); + expect(session.user_id).toBe(user.id); + }); + + test('should not be able to login using recovery code if the code has already been used', async () => { + const user = await createUser(1); + await models().user().enableMFA(user.id, 'asdf', ''); + const recoveryCodes = await models().recoveryCode().loadByUserId(user.id); + + const context = await doLoginWithMFA(user.email, '123456', undefined, undefined, recoveryCodes[0].code); + cookieDelete(context, 'sessionId'); + const context2 = await doLoginWithMFA(user.email, '123456', undefined, undefined, recoveryCodes[0].code); + + const sessionId = cookieGet(context2, 'sessionId'); + expect(sessionId).toBe(undefined); + }); + + test('should show the login page with recovery code input', async () => { + const context = await koaAppContext({ + request: { + method: 'GET', + url: '/login', + query: { + showRecoveryCodeInput: '1', + }, + }, + }); + + await routeHandler(context); + + const doc = parseHtml(context.response.body as string); + expect(!!doc.querySelector('input[name=email]')).toBe(true); + expect(!!doc.querySelector('input[name=password]')).toBe(true); + expect(!!doc.querySelector('input[name=recoveryCode]')).toBe(true); + }); }); diff --git a/packages/server/src/routes/index/login.ts b/packages/server/src/routes/index/login.ts index daf4aa2baf..27f77a81ca 100644 --- a/packages/server/src/routes/index/login.ts +++ b/packages/server/src/routes/index/login.ts @@ -1,4 +1,4 @@ -import { SubPath, redirect, makeUrl, UrlType } from '../../utils/routeUtils'; +import { SubPath, redirect, makeUrl, UrlType, internalRedirect } from '../../utils/routeUtils'; import Router from '../../utils/Router'; import { RouteType } from '../../utils/types'; import { AppContext } from '../../utils/types'; @@ -8,16 +8,34 @@ import defaultView from '../../utils/defaultView'; import { View } from '../../services/MustacheService'; import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce'; import { cookieSet } from '../../utils/cookies'; -import { homeUrl } from '../../utils/urlUtils'; +import { adminDashboardUrl, applicationsConfirmUrl, homeUrl } from '../../utils/urlUtils'; import { generateRedirectHtml } from '../../utils/saml'; import { ErrorForbidden } from '../../utils/errors'; +type LoginViewContentOptions = { + showMfaCodeInput: boolean; + showRecoveryCodeInput?: boolean; +}; + +type LoginInputFields = { + email?: string; + password?: string; + mfaCode?: string; + applicationAuthId?: string; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -function makeView(error: any = null): View { +function makeView(error: any = null, fields?: LoginInputFields, viewContentOptions?: LoginViewContentOptions): View { const view = defaultView('login', 'Login'); view.content = { error, signupUrl: config().signupEnabled || config().isJoplinCloud ? makeUrl(UrlType.Signup) : '', + email: fields?.email, + password: fields?.password, + applicationAuthId: fields?.applicationAuthId, + // should only show mfa code input if recovery code is not active + showMfaCodeInput: viewContentOptions?.showRecoveryCodeInput ? false : viewContentOptions?.showMfaCodeInput, + showRecoveryCodeInput: viewContentOptions?.showRecoveryCodeInput, samlEnabled: config().saml.enabled, samlOrganizationName: config().saml.enabled && config().saml.organizationDisplayName ? config().saml.organizationDisplayName : undefined, }; @@ -28,11 +46,44 @@ const router: Router = new Router(RouteType.Web); router.public = true; -router.get('login', async (_path: SubPath, ctx: AppContext) => { +type Queries = { + [key: string]: QueryParameter; +}; + +type QueryParameter = string | string[]; + +const getApplicationAuthId = (query: Queries, fields: LoginInputFields): string | undefined => { + if (query?.application_auth_id && !Array.isArray(query?.application_auth_id)) { + return query.application_auth_id; + } + if (fields?.applicationAuthId) { + return fields.applicationAuthId; + } + return undefined; +}; + + +const getRedirectUrl = (isAdmin: number, applicationAuthId?: string) => { + if (applicationAuthId) return applicationsConfirmUrl(applicationAuthId); + if (isAdmin) return adminDashboardUrl(); + return homeUrl(); +}; + +router.get('login', async (_path: SubPath, ctx: AppContext, fields: LoginInputFields = {}, options: LoginViewContentOptions) => { + const viewContentOptions = { + showMfaCodeInput: !!options?.showMfaCodeInput, + showRecoveryCodeInput: !!ctx.query?.showRecoveryCodeInput, + }; + fields.applicationAuthId = getApplicationAuthId(ctx.query, fields); + if (ctx.joplin.owner) { - return redirect(ctx, homeUrl()); + return redirect(ctx, getRedirectUrl(ctx.joplin.owner.is_admin, fields.applicationAuthId)); } + return makeView(null, fields, viewContentOptions); +}); + +router.post('login', async (_path: SubPath, _ctx: AppContext) => { if (!config().LOCAL_AUTH_ENABLED) { return await generateRedirectHtml('web-login'); } @@ -55,17 +106,31 @@ router.get('login/:id', async (path: SubPath, ctx: AppContext) => { } }); -router.post('login', async (_path: SubPath, ctx: AppContext) => { +router.post('login', async (path: SubPath, ctx: AppContext) => { await limiterLoginBruteForce(userIp(ctx)); - try { - const body = await formParse(ctx.req); + const body = await formParse(ctx.req); - const session = await ctx.joplin.models.session().authenticate(body.fields.email, body.fields.password); + try { + const hasMFAEnabled = await ctx.joplin.models.user().hasMFAEnabled(body.fields.email); + + if (hasMFAEnabled && (!body.fields.mfaCode && !body.fields.recoveryCode)) { + return internalRedirect(path, ctx, router, 'login', body.fields, { showMfaCodeInput: true }); + } + + const session = await ctx.joplin.models.session().authenticate( + body.fields.email, body.fields.password, body.fields.mfaCode, body.fields.recoveryCode, + ); cookieSet(ctx, 'sessionId', session.id); - return redirect(ctx, `${config().baseUrl}/home`); + const owner = await ctx.joplin.models.user().load(session.user_id, { fields: ['id', 'is_admin'] }); + + return redirect(ctx, getRedirectUrl(owner.is_admin, body.fields.applicationAuthId)); } catch (error) { - return makeView(error); + return makeView( + error, + { email: body.fields.email, password: body.fields.password, applicationAuthId: body.fields.applicationAuthId }, + { showMfaCodeInput: Boolean(body.fields.mfaCode), showRecoveryCodeInput: Boolean(body.fields.recoveryCode) }, + ); } }); diff --git a/packages/server/src/routes/index/mfa.test.ts b/packages/server/src/routes/index/mfa.test.ts new file mode 100644 index 0000000000..35245a0bf2 --- /dev/null +++ b/packages/server/src/routes/index/mfa.test.ts @@ -0,0 +1,105 @@ +import { execRequest } from '../../utils/testing/apiUtils'; +import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, expectHttpError } from '../../utils/testing/testUtils'; +import { totp } from 'otplib'; +import * as crypto from '../../utils/crypto'; +import { ErrorBadRequest } from '../../utils/errors'; + +describe('index/mfa', () => { + + beforeAll(async () => { + await beforeAllDb('index_mfa', { + envValues: { + MFA_ENCRYPTION_KEY: 'b73e50cd8970ed5eefb980d860c8406eb8f6519a90ce9c8f9f2b2b73661a5ab21', + }, + }); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should load a different totp_secret on every new page load', async () => { + const { session } = await createUserAndSession(1, true); + + const regex = /(\w.*?)<\/code>/; + + const response1 = await execRequest(session.id, 'GET', 'mfa/me'); + const totpSecret1 = response1.toString().match(regex)[1]; + + const response2 = await execRequest(session.id, 'GET', 'mfa/me'); + const totpSecret2 = response2.toString().match(regex)[1]; + expect(totpSecret1).not.toBe(totpSecret2); + }); + + test('should be able to register MFA', async () => { + const { session, user } = await createUserAndSession(1, true); + + const totpCheck = jest.spyOn(totp, 'check'); + totpCheck.mockReturnValue(true); + + const cryptoEncrypt = jest.spyOn(crypto, 'encryptMFASecret'); + cryptoEncrypt.mockReturnValue('encrypt-totp-secret'); + + await execRequest(session.id, 'POST', 'mfa/me', { totpSecret: 'totp-secret-in-base-32', confirmCode: '0123456' }, null); + expect((await models().user().load(user.id)).totp_secret).toBe('encrypt-totp-secret'); + }); + + test('should redirect user to the same page with same totpSecret if confirmCode is not correct', async () => { + const { session } = await createUserAndSession(1, true); + + const totpCheck = jest.spyOn(totp, 'check'); + totpCheck.mockReturnValue(false); + + try { + await execRequest(session.id, 'POST', 'mfa/me', { totpSecret: 'totp-secret-in-base-32', confirmCode: '0123456' }, null); + } catch (error) { + expect(error.toString().includes('totp-secret-in-base-32')).toBe(true); + } + }); + + test('should not save totp_secret if totp check is invalid', async () => { + const { session, user } = await createUserAndSession(1, true); + + const totpCheck = jest.spyOn(totp, 'check'); + totpCheck.mockReturnValue(false); + + await expectHttpError(async () => await execRequest(session.id, 'POST', 'mfa/me', { totpSecret: 'totp-secret-in-base-32', confirmCode: '0123456' }, null), 403); + + expect((await models().user().load(user.id)).totp_secret).toBe(''); + }); + + test('should create recovery codes when MFA is enabled', async () => { + const { session, user } = await createUserAndSession(1, true); + + const totpCheck = jest.spyOn(totp, 'check'); + totpCheck.mockReturnValue(true); + + const cryptoEncrypt = jest.spyOn(crypto, 'encryptMFASecret'); + cryptoEncrypt.mockReturnValue('encrypt-totp-secret'); + + await execRequest(session.id, 'POST', 'mfa/me', { totpSecret: 'totp-secret-in-base-32', confirmCode: '0123456' }, null); + + const recoveryCodes = await models().recoveryCode().loadByUserId(user.id); + + expect(recoveryCodes.length).toBe(10); + }); + + test('should throw Bad Request error if password is empty when MFA is being disabled', async () => { + const { session, user } = await createUserAndSession(1, true); + + await models().user().enableMFA(user.id, 'totp-secret', session.id); + + await expectHttpError( + async () => execRequest(session.id, 'POST', 'mfa/me', { formType: 'disableMFA', password: undefined }, null), + ErrorBadRequest.httpCode, + ); + + const dbUser = await models().user().load(user.id, { fields: ['totp_secret'] }); + expect(dbUser.totp_secret).not.toBe(''); + }); + +}); diff --git a/packages/server/src/routes/index/mfa.ts b/packages/server/src/routes/index/mfa.ts new file mode 100644 index 0000000000..83cf8d7212 --- /dev/null +++ b/packages/server/src/routes/index/mfa.ts @@ -0,0 +1,118 @@ +import { SubPath, internalRedirect, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; +import { bodyFields, contextSessionId } from '../../utils/requestUtils'; +import { ErrorForbidden } from '../../utils/errors'; +import config from '../../config'; +import { View } from '../../services/MustacheService'; +import defaultView from '../../utils/defaultView'; +import { mfaUrl, recoveryCodesUrl } from '../../utils/urlUtils'; +import { createCsrfTag } from '../../utils/csrf'; +import { totp } from 'otplib'; +const thirtyTwo = require('thirty-two'); +import { randomBytes } from 'crypto'; +import { checkConsecutiveMFACodes } from '../../utils/crypto'; +import { profileUrl } from '../../utils/urlUtils'; +import { getIsMFAEnabled } from '../../models/utils/user'; +import * as QRCode from 'qrcode'; +import { cookieSet } from '../../utils/cookies'; + +const router = new Router(RouteType.Web); + +type MFAWebpageContent = { + isMFAEnabled: boolean; + csrfTag: string; + buttonTitle: string; + title: string; + postUrl?: string; + totpSecret?: string; + qrcodeImage?: string; + error?: Error; +}; + +router.get('mfa/:id', async (path: SubPath, ctx: AppContext, fields: Enable2FaFormData = null, error: Error = null) => { + const owner = ctx.joplin.owner; + + if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden(); + + const user = await ctx.joplin.models.user().load(owner.id, { fields: ['totp_secret'] }); + const isMFAEnabled = getIsMFAEnabled(user); + + const content: MFAWebpageContent = { + isMFAEnabled, + title: '', + buttonTitle: '', + csrfTag: await createCsrfTag(ctx), + }; + + if (isMFAEnabled) { + content.title = 'Disable multi-factor authentication'; + content.buttonTitle = 'Disable MFA'; + + } else { + let secretEncoded = fields?.totpSecret; + if (!secretEncoded) { + const secret = randomBytes(20); + secretEncoded = thirtyTwo.encode(secret); + } + + content.title = 'Enable multi-factor authentication'; + content.buttonTitle = 'Enable MFA'; + content.postUrl = mfaUrl(owner.id); + content.totpSecret = secretEncoded; + content.qrcodeImage = await QRCode.toDataURL(totp.keyuri(owner.email, config().appName, secretEncoded)); + content.error = error; + } + + content.isMFAEnabled = isMFAEnabled; + + const view: View = { + ...defaultView('mfa', content.title), + content, + }; + + return view; +}); + +interface Enable2FaFormData { + postButton: string; + formType: 'disableMFA' | 'enableMFA'; + password?: string; + totpSecret?: string; + confirmCode?: string; + confirmCode2?: string; +} + +router.post('mfa/:id', async (path: SubPath, ctx: AppContext) => { + const owner = ctx.joplin.owner; + + if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden(); + + const fields = await bodyFields(ctx.req); + + if (fields.formType === 'disableMFA') { + const passwordIsValid = await ctx.joplin.models.user().isPasswordValid(owner.id, fields.password); + if (!passwordIsValid) { + return redirect(ctx, profileUrl()); + } + await ctx.joplin.models.user().disableMFA(owner.id); + + return redirect(ctx, profileUrl()); + } else { + + const isVerified = checkConsecutiveMFACodes(fields.totpSecret, fields.confirmCode, fields.confirmCode2); + + if (!isVerified) { + return internalRedirect(path, ctx, router, 'mfa/:id', fields, new ErrorForbidden('The code wasn\'t valid, try again.')); + } + + await ctx.joplin.models.user().enableMFA(owner.id, fields.totpSecret, contextSessionId(ctx)); + } + + const recoveryCodeAccessKey = await ctx.joplin.models.recoveryCode().saveRecoveryCodeAccessKey(owner.id); + cookieSet(ctx, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + return redirect(ctx, recoveryCodesUrl()); +}); + +export default router; diff --git a/packages/server/src/routes/index/password.test.ts b/packages/server/src/routes/index/password.test.ts index b465f64827..a364e68249 100644 --- a/packages/server/src/routes/index/password.test.ts +++ b/packages/server/src/routes/index/password.test.ts @@ -19,12 +19,13 @@ describe('index/password', () => { test('should queue an email to reset password', async () => { const { user, password } = await createUserAndSession(1); + const mfaCode = ''; // Create a few sessions, to verify that they are all deleted when the // password is changed. - await models().session().authenticate(user.email, password); - await models().session().authenticate(user.email, password); - await models().session().authenticate(user.email, password); + await models().session().authenticate(user.email, password, mfaCode); + await models().session().authenticate(user.email, password, mfaCode); + await models().session().authenticate(user.email, password, mfaCode); expect(await models().session().count()).toBe(4); await models().email().deleteAll(); diff --git a/packages/server/src/routes/index/privacy.ts b/packages/server/src/routes/index/privacy.ts index db98334352..820b407937 100644 --- a/packages/server/src/routes/index/privacy.ts +++ b/packages/server/src/routes/index/privacy.ts @@ -43,7 +43,7 @@ We treat personal data confidentially and will not share it with any third party ## Where do we store and process personal data? -Personal data is stored securely in a Postgres database. Access to it is strictly controlled. +Personal data is stored securely in a database. Access to it is strictly controlled. We may process the data for reporting purposes - for example, how many users use the service. How many requests per day, etc. @@ -53,7 +53,11 @@ A backup is made at regular intervals and stored on a secure server. ## How long do we keep your personal data for? -We keep your data for as long as you use the service. If you would like to stop using it and have your data deleted, please contact us. We will also consider automatic data deletion provided it can be done in a safe way. +Disabled accounts are automatically deleted after 100 days. A disabled account is one where the Stripe subscription has been cancelled either by yourself or automatically (eg for unpaid invoices). + +When an account is deleted, all notes, notebooks, tags, etc are permanently deleted. User information, in particular email and full name will be removed from the system within 92 days, but archived for an additional 90 days for legal reasons, after which they will be deleted too. + +If you would like to have your data deleted immediately after your subscription is cancelled, please contact us. ## How to contact us? diff --git a/packages/server/src/routes/index/recovery_codes.test.ts b/packages/server/src/routes/index/recovery_codes.test.ts new file mode 100644 index 0000000000..9daee06392 --- /dev/null +++ b/packages/server/src/routes/index/recovery_codes.test.ts @@ -0,0 +1,173 @@ +import { totp } from 'otplib'; +import routeHandler from '../../middleware/routeHandler'; +import { cookieGet, cookieSet } from '../../utils/cookies'; +import { execRequest } from '../../utils/testing/apiUtils'; +import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, koaAppContext } from '../../utils/testing/testUtils'; + +describe('index/recovery_codes', () => { + + beforeAll(async () => { + await beforeAllDb('index_recovery_codes', { + envValues: { + MFA_ENCRYPTION_KEY: 'b73e50cd8970ed5eefb980d860c8406eb8f6519a90ce9c8f9f2b2b73661a5ab21', + }, + }); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should not be able to see recovery codes without access key', async () => { + const { session, user } = await createUserAndSession(1); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const response1 = await execRequest(session.id, 'GET', 'recovery_codes'); + + expect(response1).toBe(null); + }); + + test('should be able to see recovery codes if it has access key', async () => { + const { session, user } = await createUserAndSession(1); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/recovery_codes', + }, + }); + const recoveryCodeAccessKey = await models().recoveryCode().saveRecoveryCodeAccessKey(user.id); + cookieSet(context, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + await routeHandler(context); + + const recoveryCodes = await models().recoveryCode().loadByUserId(user.id); + + expect((context.response.body as string).includes('

Recovery codes

')).toBe(true); + expect((context.response.body as string).includes(recoveryCodes[0].code)).toBe(true); + expect((context.response.body as string).includes(recoveryCodes[1].code)).toBe(true); + expect((context.response.body as string).includes(recoveryCodes[2].code)).toBe(true); + }); + + test('should allow access to recovery code if mfa code credential gets confirmed', async () => { + const { session, user } = await createUserAndSession(1); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const totpCheck = jest.spyOn(totp, 'check'); + totpCheck.mockReturnValue(true); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'POST', + url: '/recovery_codes/auth', + body: { + mfaCode: '123456', + }, + }, + }); + await routeHandler(context); + + const accessKeyCookie = cookieGet(context, 'recoveryCodeAccessKey'); + expect(accessKeyCookie).not.toBe(null); + + const context2 = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/recovery_codes', + }, + }); + cookieSet(context2, 'recoveryCodeAccessKey', accessKeyCookie); + await routeHandler(context2); + + expect((context2.response.body as string).includes('

Recovery codes

')).toBe(true); + }); + + test('should allow access to recovery code if password credential gets confirmed', async () => { + const { session, user } = await createUserAndSession(1, undefined, { password: '123456' }); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'POST', + url: '/recovery_codes/auth', + body: { + password: '123456', + }, + }, + }); + await routeHandler(context); + + const accessKeyCookie = cookieGet(context, 'recoveryCodeAccessKey'); + expect(accessKeyCookie).not.toBe(null); + + const context2 = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/recovery_codes', + }, + }); + cookieSet(context2, 'recoveryCodeAccessKey', accessKeyCookie); + await routeHandler(context2); + + expect((context2.response.body as string).includes('

Recovery codes

')).toBe(true); + }); + + test('should not send a email to user the first time recovery codes are accessed', async () => { + const { session, user } = await createUserAndSession(1, undefined, { email: 'user@localhost' }); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/recovery_codes', + }, + }); + const recoveryCodeAccessKey = await models().recoveryCode().saveRecoveryCodeAccessKey(user.id); + cookieSet(context, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + await routeHandler(context); + + const emails = await models().email().all(); + + expect(!!emails.find(e => e.subject === '[Joplin Server] Your multi-factor authentication recovery codes were viewed')).toBe(false); + }); + + test('should send a email to user when the recovery codes are accessed, but the first time', async () => { + const { session, user } = await createUserAndSession(1, undefined, { email: 'user@localhost' }); + await models().user().enableMFA(user.id, 'asdf', session.id); + + const accessRecoveryCodes = async () => { + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/recovery_codes', + }, + }); + const recoveryCodeAccessKey = await models().recoveryCode().saveRecoveryCodeAccessKey(user.id); + cookieSet(context, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + await routeHandler(context); + }; + + // first time doesn't send an email + await accessRecoveryCodes(); + + // second time upwards should receive and email alert + await accessRecoveryCodes(); + await accessRecoveryCodes(); + await accessRecoveryCodes(); + + const emails = await models().email().all(); + + expect(emails.filter(e => e.subject === '[Joplin Server] Your multi-factor authentication recovery codes were viewed').length).toBe(3); + }); +}); diff --git a/packages/server/src/routes/index/recovery_codes.ts b/packages/server/src/routes/index/recovery_codes.ts new file mode 100644 index 0000000000..e851ade4db --- /dev/null +++ b/packages/server/src/routes/index/recovery_codes.ts @@ -0,0 +1,106 @@ +import { SubPath, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; +import defaultView from '../../utils/defaultView'; +import { profileUrl, recoveryCodesAuthUrl, recoveryCodesUrl } from '../../utils/urlUtils'; +import { _ } from '@joplin/lib/locale'; +import { createCsrfTag } from '../../utils/csrf'; +import { bodyFields, userIp } from '../../utils/requestUtils'; +import { cookieSet } from '../../utils/cookies'; +import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce'; + +type RecoveryCodeAuthInputs = { + password?: string; + mfaCode?: string; +}; + +const router = new Router(RouteType.Web); + +const recoveryCodeAuthView = async (ctx: AppContext, error?: Error) => { + const showMfaCode = !!ctx.query.show_mfa_code; + const showPassword = !!ctx.query.show_password && !showMfaCode; + return { + ...defaultView('recovery_codes/auth', 'Recovery codes '), + content: { + error: error, + csrfTag: await createCsrfTag(ctx), + title: _('Confirm credentials'), + description: _('To access your recovery codes please enter an authentication code or your password.'), + buttonTitle: _('Confirm'), + postUrl: recoveryCodesAuthUrl(), + authWithMfaCodeUrl: recoveryCodesAuthUrl(false, true), + authWithPasswordUrl: recoveryCodesAuthUrl(true, false), + showPassword: showPassword, + }, + }; +}; + +router.post('recovery_codes', async (_path: SubPath, ctx: AppContext) => { + const owner = ctx.joplin.owner; + + await ctx.joplin.models.recoveryCode().regenerate(owner.id); + + const recoveryCodeAccessKey = await ctx.joplin.models.recoveryCode().saveRecoveryCodeAccessKey(owner.id); + cookieSet(ctx, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + + return redirect(ctx, recoveryCodesUrl()); +}); + +router.get('recovery_codes', async (_path: SubPath, ctx: AppContext) => { + const owner = ctx.joplin.owner; + + const { isValid, isNewlyCreated } = await ctx.joplin.models.recoveryCode().isRecoveryCodeAccessKeyValid(owner.id, ctx.cookies.get('recoveryCodeAccessKey')); + + if (!isValid) { + return redirect(ctx, recoveryCodesAuthUrl()); + } + + const codes = await ctx.joplin.models.recoveryCode().loadByUserId(owner.id); + + const codesToRender = codes + .map(code => { + return { + ...code, + isUsedText: code.is_used ? _('Used') : _('Not Used'), + }; + }); + + const view = { + ...defaultView('recovery_codes', 'Recovery codes'), + content: { + csrfTag: await createCsrfTag(ctx), + codes: codesToRender, + buttonTitle: 'Generate new codes', + postUrl: recoveryCodesUrl(), + profileUrl: profileUrl(), + isNewlyCreated, + }, + }; + + return view; +}); + +router.get('recovery_codes/auth', async (_path: SubPath, ctx: AppContext) => { + return recoveryCodeAuthView(ctx); +}); + +router.post('recovery_codes/auth', async (_path: SubPath, ctx: AppContext) => { + await limiterLoginBruteForce(userIp(ctx)); + + const owner = ctx.joplin.owner; + + const fields = await bodyFields(ctx.req); + + try { + await ctx.joplin.models.recoveryCode().checkCredentials(owner.id, fields.password, fields.mfaCode); + const recoveryCodeAccessKey = await ctx.joplin.models.recoveryCode().saveRecoveryCodeAccessKey(owner.id); + cookieSet(ctx, 'recoveryCodeAccessKey', recoveryCodeAccessKey); + } catch (error) { + return recoveryCodeAuthView(ctx, error); + } + + return redirect(ctx, recoveryCodesUrl()); +}); + +export default router; diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index 2918542a08..893c8fd86d 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -27,7 +27,22 @@ function mockStripe(options: StripeOptions = null) { }, }, subscriptions: { - del: jest.fn(), + cancel: jest.fn(), + retrieve: async () => { + return { + stripe_subscription_id: 'sub_new', + items: { + data: [ + { + id: 'item_123456', + }, + ], + }, + }; + }, + }, + subscriptionItems: { + update: jest.fn(), }, }; } @@ -40,6 +55,8 @@ interface WebhookOptions { customerId?: string; sessionId?: string; userEmail?: string; + accountType?: AccountType; + quantity?: number; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -63,11 +80,13 @@ async function createUserViaSubscription(ctx: AppContext, options: WebhookOption options = { subscriptionId: `sub_${uuidgen()}`, customerId: `cus_${uuidgen()}`, + accountType: AccountType.Pro, + quantity: 1, ...options, }; const stripeSessionId = 'sess_123'; - const stripePrice = findPrice(stripeConfig(), { accountType: 2, period: PricePeriod.Monthly }); + const stripePrice = findPrice(stripeConfig(), { accountType: options.accountType, period: PricePeriod.Monthly }); await models().keyValue().setValue(`stripeSessionToPriceId::${stripeSessionId}`, stripePrice.id); await simulateWebhook(ctx, 'customer.subscription.created', { @@ -77,6 +96,7 @@ async function createUserViaSubscription(ctx: AppContext, options: WebhookOption data: [ { price: stripePrice, + quantity: options.quantity, }, ], }, @@ -170,12 +190,12 @@ describe('index/stripe', () => { expect((await models().user().all()).length).toBe(1); const user = (await models().user().all())[0]; const subBefore = await models().subscription().byUserId(user.id); - expect(stripe.subscriptions.del).toHaveBeenCalledTimes(0); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(0); await createUserViaSubscription(ctx, { userEmail: 'toto@example.com', stripe }); expect((await models().user().all()).length).toBe(1); const subAfter = await models().subscription().byUserId(user.id); - expect(stripe.subscriptions.del).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(1); expect(subBefore.stripe_subscription_id).toBe(subAfter.stripe_subscription_id); }); @@ -306,7 +326,7 @@ describe('index/stripe', () => { }, { stripe }); // Verify that we didn't try to delete that new subscription - expect(stripe.subscriptions.del).toHaveBeenCalledTimes(0); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index f596a18a00..6c483df59c 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -9,14 +9,14 @@ import { Stripe } from 'stripe'; import Logger from '@joplin/utils/Logger'; import getRawBody = require('raw-body'); import { AccountType } from '../../models/UserModel'; -import { betaUserTrialPeriodDays, cancelSubscription, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe'; +import { autoAssignCustomerPreferredLocales, betaUserTrialPeriodDays, cancelSubscription, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe'; import { Subscription, User, UserFlagType } from '../../services/database/types'; import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud'; import { Models } from '../../models/factory'; import { confirmUrl } from '../../utils/urlUtils'; import { msleep } from '../../utils/time'; -const logger = Logger.create('/stripe'); +const logger = Logger.create('index/stripe'); const router: Router = new Router(RouteType.Web); @@ -40,6 +40,7 @@ interface CreateCheckoutSessionFields { coupon: string; promotionCode: string; email: string; + source: string; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -110,7 +111,7 @@ export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, } } } else { - logger.info(`Creating subscription for new user: ${customerName} (${userEmail})`); + logger.info(`Creating subscription for new user: ${customerName} (${userEmail}), Account type: ${accountType}`); await models.subscription().saveUserAndSubscription( userEmail, @@ -150,7 +151,13 @@ export const postHandlers: PostHandlers = { mode: 'subscription', // Stripe supports many payment method types but it seems only // "card" is supported for recurring subscriptions. - payment_method_types: ['card'], + payment_method_types: [ + 'card', + 'sepa_debit', + 'ideal', + // 'sofort', + 'alipay', + ], line_items: [ { price: priceId, @@ -161,6 +168,12 @@ export const postHandlers: PostHandlers = { subscription_data: { trial_period_days: 14, }, + automatic_tax: { + enabled: true, + }, + tax_id_collection: { + enabled: true, + }, allow_promotion_codes: true, // {CHECKOUT_SESSION_ID} is a string literal; do not change it! // the actual Session ID is returned in the query parameter when your customer @@ -208,6 +221,10 @@ export const postHandlers: PostHandlers = { } } + if (fields.source) { + checkoutSession.metadata = { 'source': fields.source }; + } + // See https://stripe.com/docs/api/checkout/sessions/create // for additional parameters to pass. const session = await stripe.checkout.sessions.create(checkoutSession); @@ -237,6 +254,8 @@ export const postHandlers: PostHandlers = { } await models.keyValue().setValue(eventDoneKey, 1); + // console.info('EVENT', JSON.stringify(event, null, 4)); + type HookFunction = ()=> Promise; const hooks: Record = { @@ -254,62 +273,12 @@ export const postHandlers: PostHandlers = { 'checkout.session.completed': async () => { const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session; const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; + const customer = await stripe.customers.retrieve(checkoutSession.customer as string) as Stripe.Customer; + await stripe.customers.update(customer.id, { metadata: { source: checkoutSession.metadata.source } }); logger.info('Checkout session completed:', checkoutSession.id); logger.info('User email:', userEmail); }, - // 'checkout.session.completed': async () => { - // // Payment is successful and the subscription is created. - // // - // // For testing: `stripe trigger checkout.session.completed` - // // Or use /checkoutTest URL. - - // const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session; - // const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; - - // let customerName = ''; - // try { - // const customer = await stripe.customers.retrieve(checkoutSession.customer as string) as Stripe.Customer; - // customerName = customer.name; - // } catch (error) { - // logger.error('Could not fetch customer information:', error); - // } - - // logger.info('Checkout session completed:', checkoutSession.id); - // logger.info('User email:', userEmail); - // logger.info('User name:', customerName); - - // let accountType = AccountType.Basic; - // try { - // const priceId: string = await models.keyValue().value(`stripeSessionToPriceId::${checkoutSession.id}`); - // accountType = priceIdToAccountType(priceId); - // logger.info('Price ID:', priceId); - // } catch (error) { - // // We don't want this part to fail since the user has - // // already paid at that point, so we just default to Basic - // // in that case. Normally it should not happen anyway. - // logger.error('Could not determine account type from price ID - defaulting to "Basic"', error); - // } - - // logger.info('Account type:', accountType); - - // // The Stripe TypeScript object defines "customer" and - // // "subscription" as various types but they are actually - // // string according to the documentation. - // const stripeUserId = checkoutSession.customer as string; - // const stripeSubscriptionId = checkoutSession.subscription as string; - - // await handleSubscriptionCreated( - // stripe, - // models, - // customerName, - // userEmail, - // accountType, - // stripeUserId, - // stripeSubscriptionId - // ); - // }, - 'customer.subscription.created': async () => { const stripeSub: Stripe.Subscription = event.data.object as Stripe.Subscription; const stripeUserId = stripeSub.customer as string; @@ -325,6 +294,8 @@ export const postHandlers: PostHandlers = { logger.error('Could not determine account type from price ID - defaulting to "Basic"', error); } + await autoAssignCustomerPreferredLocales(stripe, customer.id); + await handleSubscriptionCreated( stripe, models, @@ -469,6 +440,7 @@ const getHandlers: Record = { \ No newline at end of file diff --git a/packages/server/src/views/index/mfa.mustache b/packages/server/src/views/index/mfa.mustache new file mode 100644 index 0000000000..5b9ef81652 --- /dev/null +++ b/packages/server/src/views/index/mfa.mustache @@ -0,0 +1,60 @@ +

{{title}}

+ +
+ +
+ {{> errorBanner}} + {{{csrfTag}}} + + + + {{#isMFAEnabled}} + +
+

Please enter your password to disable multi-factor authentication.

+
+
+ +
+ +
+
+ {{/isMFAEnabled}} + + {{^isMFAEnabled}} + +
+

Multi-factor authentication (MFA), also known as Two Factor Authentication (2FA), enhances the security of your account by adding an additional layer of protection. When you log in to your Joplin Cloud account, you'll be required to enter both your password and an authentication code sent to your mobile phone.

+
+
+

1. Install an authenticator app on your phone, such as Aegis for Android or 2FAS for iOS.

+
+
+

2. Open your authenticator app and choose the option to add a new entry.

+
+
+

3. Pair your authenticator app to your Joplin Cloud account by scanning the QR code below.

+
+
+ qrcode-2fa +

Or copy the secret code: {{totpSecret}}

+
+
+

4. Verify the pairing was successful by entering two consecutive codes generated by your authenticator app. +

+ +
+
+ +
+
+
+

After enabling multi-factor authentication you will need an authentication code or a recovery code to log in.

+
+ {{/isMFAEnabled}} + +
+ +
+
+
\ No newline at end of file diff --git a/packages/server/src/views/index/recovery_codes.mustache b/packages/server/src/views/index/recovery_codes.mustache new file mode 100644 index 0000000000..8d6d57d6a3 --- /dev/null +++ b/packages/server/src/views/index/recovery_codes.mustache @@ -0,0 +1,46 @@ +

Recovery codes

+ +
+

Recovery codes can be used to access your account in the event you lose access to your device and cannot receive multi-factor authentication codes.

+

Keep your recovery codes as safe as your password.

+ +
    + {{#codes}} + {{^is_used}} +
  • + {{code}} +
  • + {{/is_used}} + + {{#is_used}} +
  • + {{code}} {{ isUsedText }} +
  • + {{/is_used}} + {{/codes}} +
+ + {{^isNewlyCreated}} +
+ +
+ {{{csrfTag}}} + + +
+ +
+
+
+ {{/isNewlyCreated}} + + {{#isNewlyCreated}} + + {{/isNewlyCreated}} +
diff --git a/packages/server/src/views/index/recovery_codes/auth.mustache b/packages/server/src/views/index/recovery_codes/auth.mustache new file mode 100644 index 0000000000..e52edfc7d7 --- /dev/null +++ b/packages/server/src/views/index/recovery_codes/auth.mustache @@ -0,0 +1,39 @@ + +

{{title}}

+ +
+ {{{csrfTag}}} + +
+

{{description}}

+
+ + {{> errorBanner}} + + {{^showPassword}} +
+ +
+
+ +
+
+

Use your password

+
+ {{/showPassword}} + {{#showPassword}} +
+ +
+
+ +
+
+

Use your authenticator app

+
+ {{/showPassword}} + +
+ +
+
\ No newline at end of file diff --git a/packages/server/src/views/index/upgrade.mustache b/packages/server/src/views/index/upgrade.mustache index c98a187ae4..43fedc8ff6 100644 --- a/packages/server/src/views/index/upgrade.mustache +++ b/packages/server/src/views/index/upgrade.mustache @@ -27,6 +27,10 @@ {{/planRows}} + + For the complete feature list, please see the Plan page + + diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index f0cdcc5e62..bae08b823d 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -1,6 +1,6 @@

Your profile

-
+
{{> errorBanner}} @@ -35,12 +35,29 @@
+ {{#isMFAFeatureEnabled}} +
+

Multi-factor authentication

+

Multi-factor authentication (MFA), also known as two factor authentication (2FA), enhances the security of your account by adding an additional layer of protection. When you log in to your Joplin Cloud account, you'll be required to enter both your password and an authentication code sent to your mobile phone.

+ {{#hasMFAEnabled}} +

Multi-factor authentication is currently enabled.

+ + {{/hasMFAEnabled}} + {{^hasMFAEnabled}} + Enable multi-factor authentication + {{/hasMFAEnabled}} +
+ {{/isMFAFeatureEnabled}}
- {{#showSendAccountConfirmationEmailButton}} - - {{/showSendAccountConfirmationEmailButton}} + {{#showSendAccountConfirmationEmailButton}} + + {{/showSendAccountConfirmationEmailButton}}
+ {{#subscription}} diff --git a/packages/server/src/views/layouts/default.mustache b/packages/server/src/views/layouts/default.mustache index ecd5a4a9a3..b7471085eb 100644 --- a/packages/server/src/views/layouts/default.mustache +++ b/packages/server/src/views/layouts/default.mustache @@ -46,4 +46,4 @@ {{> footer}} - \ No newline at end of file + diff --git a/packages/tools/cspell/dictionary1.txt b/packages/tools/cspell/dictionary1.txt index 5412dbeeb6..07bc87edc5 100644 --- a/packages/tools/cspell/dictionary1.txt +++ b/packages/tools/cspell/dictionary1.txt @@ -39,6 +39,7 @@ apidoc apidox apos appiconset +appimage appimages applewebkit approot diff --git a/packages/tools/cspell/dictionary2.txt b/packages/tools/cspell/dictionary2.txt index e60c8fcc78..d5bdf69699 100644 --- a/packages/tools/cspell/dictionary2.txt +++ b/packages/tools/cspell/dictionary2.txt @@ -104,6 +104,7 @@ keycodes keymapping keymaps keytar +KHTML Kibris kickass Kinyarwanda @@ -171,6 +172,7 @@ Madagasikara Magdy Magyarország Maininterface +mailparser majax Mals managebutton diff --git a/packages/tools/cspell/dictionary3.txt b/packages/tools/cspell/dictionary3.txt index 5179ab9bf3..6ca67c2a70 100644 --- a/packages/tools/cspell/dictionary3.txt +++ b/packages/tools/cspell/dictionary3.txt @@ -153,6 +153,7 @@ SJCL Slovenčina Slovenija Slovensko +smartmail softbreaks Solarised SOLARIZED diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 27c4f8f883..9f524ed7d9 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -93,19 +93,33 @@ titlewrapper unresponded activeline Prec +totp ellipsized Trashable +SEPA +Sofort +Alipay hideable unignore unignored unsyncable infini +teamuser libgbm libgtk libasound libatk ENOTFOUND Scaleway +knexfile +Stringifier +inet +otplib +qrcode +keyuri +Invit +tfvars +rata Inkscape Ionicon onready @@ -202,6 +216,7 @@ vikasmanimc traspire Pokies Pokiesman +presign Gamstop ESIGNER TOTP diff --git a/packages/tools/git-changelog.ts b/packages/tools/git-changelog.ts index a8c182e50d..094d8353c3 100644 --- a/packages/tools/git-changelog.ts +++ b/packages/tools/git-changelog.ts @@ -478,8 +478,15 @@ async function findFirstRelevantTag(baseTag: string, platform: Platform, allTags for (let i = baseTagIndex - 1; i >= 0; i--) { const tag = allTags[i]; if (platformFromTag(tag) !== platform) continue; + const currentVersion = versionFromTag(tag); - if (compareVersions(baseVersion, currentVersion) <= 0) continue; + + try { + if (compareVersions(baseVersion, currentVersion) <= 0) continue; + } catch (error) { + console.warn(`Skipping invalid tag: ${tag}`); + continue; + } try { const logs = await gitLog(tag); diff --git a/packages/utils/url.test.ts b/packages/utils/url.test.ts index 6538e6f97f..85ed1fcf3f 100644 --- a/packages/utils/url.test.ts +++ b/packages/utils/url.test.ts @@ -1,4 +1,4 @@ -import { fileUriToPath, hasProtocol } from './url'; +import { fileUriToPath, isHttpOrHttpsUrl, hasProtocol } from './url'; describe('utils/url', () => { @@ -28,6 +28,17 @@ describe('utils/url', () => { expect(fileUriToPath('file:///c:/AUTOEXEC.BAT', 'win32')).toBe('c:\\AUTOEXEC.BAT'); })); + it('should correctly identify https and http URLs', () => { + expect(isHttpOrHttpsUrl('https://example.com')).toBe(true); + expect(isHttpOrHttpsUrl('http://example.com')).toBe(true); + // cSpell:disable + expect(isHttpOrHttpsUrl('htttp://example.com')).toBe(false); + // cSpell:enable + expect(isHttpOrHttpsUrl('ftp://')).toBe(false); + expect(isHttpOrHttpsUrl('javascript:alert()')).toBe(false); + expect(isHttpOrHttpsUrl('void:0')).toBe(false); + }); + test.each([ [ 'https://joplinapp.org', diff --git a/packages/utils/url.ts b/packages/utils/url.ts index 52987b4aaf..7afc696d2e 100644 --- a/packages/utils/url.ts +++ b/packages/utils/url.ts @@ -1,5 +1,3 @@ -/* eslint-disable import/prefer-default-export */ - // This is a modified version of the file-uri-to-path package: // // - It removes the dependency to the "path" package, which wouldn't work with @@ -101,6 +99,11 @@ export const isDataUrl = (path: string) => { return path.startsWith('data:'); }; +export const isHttpOrHttpsUrl = (url: string) => { + return url.startsWith('http://') || url.startsWith('https://'); +}; + + export const hasProtocol = (url: string, protocol: string | string[]) => { if (!url) return false; diff --git a/yarn.lock b/yarn.lock index ae6515d56d..8640d1d875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11033,6 +11033,7 @@ __metadata: "@types/node": "npm:18.19.130" "@types/node-os-utils": "npm:1.3.4" "@types/nodemailer": "npm:6.4.20" + "@types/qrcode": "npm:1.5.6" "@types/uuid": "npm:10.0.0" "@types/yargs": "npm:17.0.33" "@types/zxcvbn": "npm:4.4.5" @@ -11058,16 +11059,19 @@ __metadata: node-os-utils: "npm:1.3.7" nodemailer: "npm:6.10.1" nodemon: "npm:3.1.10" + otplib: "npm:12.0.1" pg: "npm:8.16.3" pretty-bytes: "npm:5.6.0" prettycron: "npm:0.10.0" + qrcode: "npm:1.5.3" query-string: "npm:7.1.3" rate-limiter-flexible: "npm:7.2.0" raw-body: "npm:3.0.1" samlify: "npm:2.10.1" + short-uuid: "npm:4.2.0" source-map-support: "npm:0.5.21" sqlite3: "npm:5.1.6" - stripe: "npm:8.222.0" + stripe: "npm:13.9.0" typescript: "npm:5.8.3" uuid: "npm:11.1.0" yargs: "npm:17.7.2" @@ -12916,6 +12920,54 @@ __metadata: languageName: node linkType: hard +"@otplib/core@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/core@npm:12.0.1" + checksum: 10/d6edc1ed5fd744cd7be5fd0886e627f42f5e25f9593c7c6f4cac6a64a078eeaf1f70a190f1bb7f366f4eafdabe445c272e699292daa84da266e43b89bb40a6c0 + languageName: node + linkType: hard + +"@otplib/plugin-crypto@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-crypto@npm:12.0.1" + dependencies: + "@otplib/core": "npm:^12.0.1" + checksum: 10/6867c74ee8aca6c2db9670362cf51e44f3648602c39318bf537421242e33f0012a172acd43bbed9a21d706e535dc4c66aff965380673391e9fd74cf685b5b13a + languageName: node + linkType: hard + +"@otplib/plugin-thirty-two@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-thirty-two@npm:12.0.1" + dependencies: + "@otplib/core": "npm:^12.0.1" + thirty-two: "npm:^1.0.2" + checksum: 10/920099e40d3e8c2941291c84c70064c2d86d0d1ed17230d650445d5463340e406bc413ddf2e40c374ddc4ee988ef1e3facacab9b5248b1ff361fd13df52bf88f + languageName: node + linkType: hard + +"@otplib/preset-default@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-default@npm:12.0.1" + dependencies: + "@otplib/core": "npm:^12.0.1" + "@otplib/plugin-crypto": "npm:^12.0.1" + "@otplib/plugin-thirty-two": "npm:^12.0.1" + checksum: 10/8133231384f6277f77eb8e42ef83bc32a8b01059bef147d1c358d9e9bfd292e1c239f581fe008367a48489dd68952b7ac0948e6c41412fc06079da2c91b71d16 + languageName: node + linkType: hard + +"@otplib/preset-v11@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-v11@npm:12.0.1" + dependencies: + "@otplib/core": "npm:^12.0.1" + "@otplib/plugin-crypto": "npm:^12.0.1" + "@otplib/plugin-thirty-two": "npm:^12.0.1" + checksum: 10/367cb09397e617c21ec748d54e920ab43f1c5dfba70cbfd88edf73aecca399cf0c09fefe32518f79c7ee8a06e7058d14b200da378cc7d46af3cac4e22a153e2f + languageName: node + linkType: hard + "@paralleldrive/cuid2@npm:^2.2.2": version: 2.2.2 resolution: "@paralleldrive/cuid2@npm:2.2.2" @@ -16849,6 +16901,15 @@ __metadata: languageName: node linkType: hard +"@types/qrcode@npm:1.5.6": + version: 1.5.6 + resolution: "@types/qrcode@npm:1.5.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/61aa00ba56a874a71a82006a55715d2ff2dc65689b582314c6f955c72c830938c93d0ee1e3376d370a1dcd6bac61b05afe75ad8d1f4a47ebcdd6344d39f70847 + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.9.7 resolution: "@types/qs@npm:6.9.7" @@ -18960,6 +19021,13 @@ __metadata: languageName: node linkType: hard +"any-base@npm:^1.1.0": + version: 1.1.0 + resolution: "any-base@npm:1.1.0" + checksum: 10/c1fd040de52e710e2de7d9ae4df52bac589f35622adb24686c98ce21c7b824859a8db9614bc119ed8614f42fd08918b2612e6a6c385480462b3100a1af59289d + languageName: node + linkType: hard + "any-promise@npm:^1.0.0, any-promise@npm:^1.1.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -25775,6 +25843,13 @@ __metadata: languageName: node linkType: hard +"dijkstrajs@npm:^1.0.1": + version: 1.0.3 + resolution: "dijkstrajs@npm:1.0.3" + checksum: 10/0d8429699a6d5897ed371de494ef3c7072e8052b42abbd978e686a9b8689e70af005fa3e93e93263ee3653673ff5f89c36db830a57ae7c2e088cb9c496307507 + languageName: node + linkType: hard + "dir-compare@npm:^3.0.0": version: 3.3.0 resolution: "dir-compare@npm:3.3.0" @@ -26472,6 +26547,13 @@ __metadata: languageName: node linkType: hard +"encode-utf8@npm:^1.0.3": + version: 1.0.3 + resolution: "encode-utf8@npm:1.0.3" + checksum: 10/0204c37cda21bf19bb8f87f7ec6c89a23d43488c2ef1e5cfa40b64ee9568e63e15dc323fa7f50a491e2c6d33843a6b409f6de09afbf6cf371cb8da596cc64b44 + languageName: node + linkType: hard + "encodeurl@npm:^1.0.2, encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -40671,6 +40753,17 @@ __metadata: languageName: node linkType: hard +"otplib@npm:12.0.1": + version: 12.0.1 + resolution: "otplib@npm:12.0.1" + dependencies: + "@otplib/core": "npm:^12.0.1" + "@otplib/preset-default": "npm:^12.0.1" + "@otplib/preset-v11": "npm:^12.0.1" + checksum: 10/37415ce3706b9e186c1bdcef3e975d96f24bdd97d66dff6148d31a523d4e84009154f7ad1d491afc326d85f7adf0afcd2daf3750b6e6cfc4151d3deec8c0dcc3 + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1" @@ -43309,7 +43402,21 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.11.0, qs@npm:^6.10.3": +"qrcode@npm:1.5.3": + version: 1.5.3 + resolution: "qrcode@npm:1.5.3" + dependencies: + dijkstrajs: "npm:^1.0.1" + encode-utf8: "npm:^1.0.3" + pngjs: "npm:^5.0.0" + yargs: "npm:^15.3.1" + bin: + qrcode: bin/qrcode + checksum: 10/823642d59a81ba5f406a1e78415fee37fd53856038f49a85c4ca7aa32ba6b8505ab059a832718ac16612bed75aa2a18584faae38cf3c25e2c90fb19b8c55fe46 + languageName: node + linkType: hard + +"qs@npm:6.11.0": version: 6.11.0 resolution: "qs@npm:6.11.0" dependencies: @@ -47311,6 +47418,16 @@ __metadata: languageName: node linkType: hard +"short-uuid@npm:4.2.0": + version: 4.2.0 + resolution: "short-uuid@npm:4.2.0" + dependencies: + any-base: "npm:^1.1.0" + uuid: "npm:^8.3.2" + checksum: 10/c9765dd0f21c5a6e0b2245926c73a13079b021dab34f5d152579cf76c11895c20ece6fab06c0dff786041a1c878f12032daec89adc03863fe6d68849e0a475c5 + languageName: node + linkType: hard + "side-channel-list@npm:^1.0.0": version: 1.0.0 resolution: "side-channel-list@npm:1.0.0" @@ -48950,13 +49067,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:8.222.0": - version: 8.222.0 - resolution: "stripe@npm:8.222.0" +"stripe@npm:13.9.0": + version: 13.9.0 + resolution: "stripe@npm:13.9.0" dependencies: "@types/node": "npm:>=8.1.0" - qs: "npm:^6.10.3" - checksum: 10/5d88142ec118c2799c9d96d96703e3136bb41b3c74609c1b532a4b2892d4611c73ef0ebad5f2fd1c6585591c1aca0ddcd1ab484bd1634e0632a00fd357d2bac2 + qs: "npm:^6.11.0" + checksum: 10/7d8f1f9f52b0dff5e9d87addf79370cf225e21ca22feeaab2441806b23f11ef92d821e6965f5c288a98e3bf94c1540590899985f2893e58b2d1393c1fd668632 languageName: node linkType: hard @@ -49966,6 +50083,13 @@ __metadata: languageName: node linkType: hard +"thirty-two@npm:^1.0.2": + version: 1.0.2 + resolution: "thirty-two@npm:1.0.2" + checksum: 10/f6700b31d16ef942fdc0d14daed8a2f69ea8b60b0e85db8b83adf58d84bbeafe95a17d343ab55efaae571bb5148b62fc0ee12b04781323bf7af7d7e9693eec76 + languageName: node + linkType: hard + "throat@npm:^5.0.0": version: 5.0.0 resolution: "throat@npm:5.0.0"