Server: Add support for MFA (#14081)

This commit is contained in:
Laurent Cozic
2026-01-13 14:14:46 +00:00
committed by GitHub
parent 606b397326
commit a3bf0cfdeb
113 changed files with 3589 additions and 583 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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;
};

View File

@@ -7,4 +7,5 @@ db-*.sqlite
logs/
tests/temp/
temp/
resource/
.env

View File

@@ -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';

View File

@@ -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"
}

View File

@@ -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;
}

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import { ChangeType, Event } from './services/database/types';
import { DatabaseConfig, DatabaseConfigClient } from './utils/types';
import { createDb } from './tools/dbTools';
import { msleep } from './utils/time';
import { FolderEntity } from '@joplin/lib/services/database/types';
const eventId1 = '4f405391-bd72-4a4f-809f-344fc6cd4b31';
const eventId2 = '4f405391-bd72-4a4f-809f-344fc6cd4b32';
@@ -115,7 +116,7 @@ describe('db.replication', () => {
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<FolderEntity>(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<FolderEntity>(folderItem.id)).title).toBe('title 2');
// After sync, we should get the change
await sqliteSyncSlave(db(), dbSlave());

View File

@@ -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<void> => {
if (!isPostgres(db)) return;
await db.raw(`ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET DATA TYPE character varying(32) COLLATE "C"`);

View File

@@ -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;

View File

@@ -2,8 +2,9 @@ import { AppContext, KoaNext } from '../utils/types';
import { contextSessionId } from '../utils/requestUtils';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
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();
}

View File

@@ -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');
});
};

View File

@@ -0,0 +1,38 @@
import { Knex } from 'knex';
import { DbConnection, isPostgres } from '../db';
export async function up(db: DbConnection): Promise<void> {
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<void> {
await db.schema.dropTable('applications');
await db.schema.alterTable('sessions', (table: Knex.CreateTableBuilder) => {
table.dropColumn('application_id');
});
}

View File

@@ -0,0 +1,18 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<void> {
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<void> {
await db.schema.dropTable('recovery_codes');
}

View File

@@ -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);
});
});

View File

@@ -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<Application, 'id' | 'version' | 'platform' | 'ip' | 'created_time' | 'last_access_time'>;
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<Application> {
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<string>(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<Application> {
const applicationAuthIdInformation = await this.models().keyValue().value<string>(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<CreateAppPasswordResponse> {
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<ActiveApplication[]> {
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<void> {
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();
}
}

View File

@@ -126,6 +126,10 @@ export default abstract class BaseModel<T> {
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<void> {
throw new Error('Must be overriden');
}
@@ -302,13 +306,17 @@ export default abstract class BaseModel<T> {
return output;
}
protected objectToApiOutput(object: T): T {
protected async objectToApiOutput(object: T): Promise<T> {
return { ...object };
}
public toApiOutput(object: T | T[]): T | T[] {
public async toApiOutput(object: T | T[]): Promise<T | T[]> {
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);
}

View File

@@ -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<Email> {
}
public async push(email: EmailToSend): Promise<Email | null> {
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

View File

@@ -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);

View File

@@ -119,16 +119,18 @@ export default class ItemModel extends BaseModel<Item> {
}
public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise<void> {
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<Item> {
return output;
}
protected objectToApiOutput(object: Item): Item {
protected async objectToApiOutput(object: Item): Promise<Item> {
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<Item> {
return item;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async loadAsJoplinItem(id: Uuid): Promise<any> {
public async loadAsJoplinItem<T>(id: Uuid): Promise<T> {
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<SaveFromRawContentResult> {
@@ -590,14 +591,35 @@ export default class ItemModel extends BaseModel<Item> {
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<string, ItemToProcess> = {};
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> {
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<Item> {
}
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<Item> {
public async deleteForUser(userId: Uuid, item: Item, options: DeleteOptions = {}): Promise<void> {
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) {

View File

@@ -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<ItemResource> {
return rows.map(r => r.item_id);
}
public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[]): Promise<Item[]> {
public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
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<TreeItem> {

View File

@@ -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<RecoveryCode> {
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<RecoveryCode>[]) {
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<RecoveryCode>[] = 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<RecoveryCodeAccess>(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);
}
}

View File

@@ -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);

View File

@@ -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<Session> {
}, { isNew: true });
}
public async authenticate(email: string, password: string): Promise<Session> {
public async createApplicationSession(userId: string, applicationId: Uuid): Promise<Session> {
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<Session> {
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();
}
}

View File

@@ -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<Share> {
}
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<Share> {
}
}
protected objectToApiOutput(object: Share): Share {
protected async objectToApiOutput(object: Share): Promise<Share> {
const output: Share = {};
if (object.id) output.id = object.id;
@@ -88,10 +89,10 @@ export default class ShareModel extends BaseModel<Share> {
return this.save(toSave);
}
public async itemShare(shareType: ShareType, itemId: string): Promise<Share> {
public async itemShare(shareType: ShareType, itemId: string, options: LoadOptions = null): Promise<Share> {
return this
.db(this.tableName)
.select(this.defaultFields)
.select(this.selectFields(options))
.where('item_id', '=', itemId)
.where('type', '=', shareType)
.first();

View File

@@ -24,7 +24,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
}
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) {

View File

@@ -24,6 +24,15 @@ export default class TokenModel extends BaseModel<Token> {
return token.value;
}
public async generateAnonymous(): Promise<string> {
const token = await this.save({
value: uuidgen(32),
user_id: '',
});
return token.value;
}
public async checkToken(userId: string, tokenValue: string): Promise<void> {
if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token');
}

View File

@@ -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;

View File

@@ -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<AccountType, Account> = {
// 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<User> {
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<User> {
return 'users';
}
public async loadByEmail(email: string): Promise<User> {
const user: User = this.formatValues({ email: email });
return this.db<User>(this.tableName).where(user).first();
public async loadByEmail(email: string, options: LoadOptions = {}): Promise<User> {
return this.db(this.tableName)
.select(this.selectFields(options))
.where(this.formatValues({ email: email }))
.first();
}
public async loadBySsoAuthCode(code: string): Promise<User> {
@@ -257,9 +271,16 @@ export default class UserModel extends BaseModel<User> {
return user;
}
protected objectToApiOutput(object: User): User {
const output: User = { ...object };
delete output.password;
protected async objectToApiOutput(object: User): Promise<GetUsersApiResponse> {
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<User> {
// 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<User> {
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<User> {
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<void> {
// const shares = await this.models().share().sharesByUser(id);
@@ -502,12 +519,16 @@ export default class UserModel extends BaseModel<User> {
});
}
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<User> {
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<User> {
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<void> {
await this.withTransaction(async () => {
for (const user of users) {
@@ -806,4 +827,38 @@ export default class UserModel extends BaseModel<User> {
}
}, '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');
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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<User> {
@@ -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);
});
});

View File

@@ -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;

View File

@@ -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));
// });
});

View File

@@ -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<string> {
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;
}

View File

@@ -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.');
});
});

View File

@@ -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;

View File

@@ -442,4 +442,5 @@ describe('api/items', () => {
// Should not have deleted the other item
expect(await models().item().loadByJopId(user1.id, '00000000000000000000000000000003')).toBeTruthy();
});
});

View File

@@ -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);
}

View File

@@ -25,6 +25,28 @@ async function postSession(email: string, password: string): Promise<AppContext>
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());
});
});

View File

@@ -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 };
});

View File

@@ -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);

View File

@@ -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<FolderEntity>(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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -24,7 +24,7 @@ async function postedUserFromContext(ctx: AppContext): Promise<User> {
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) => {

View File

@@ -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;
}

View File

@@ -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<AppContext> {
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<AppContext> {
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<string>('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');
});
});

View File

@@ -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;

View File

@@ -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,
},
],

View File

@@ -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<AppContext> {
const context = await koaAppContext({
@@ -20,10 +21,33 @@ async function doLogin(email: string, password: string): Promise<AppContext> {
return context;
}
async function doLoginWithMFA(email: string, password: string, mfaCode: string, applicationAuthId?: string, recoveryCode?: string): Promise<AppContext> {
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('<label class="label">Authentication code</label>')).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('<input class="input" type="password" name="password" value="123456"/>')).toBe(true);
expect(context.response.body.toString().includes('<input class="input" type="email" name="email" value="user1@localhost"/>')).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);
});
});

View File

@@ -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) },
);
}
});

View File

@@ -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 = /<code>(\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('');
});
});

View File

@@ -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<Enable2FaFormData>(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;

View File

@@ -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();

View File

@@ -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?

View File

@@ -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('<h1 class="title">Recovery codes</h1>')).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('<h1 class="title">Recovery codes</h1>')).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('<h1 class="title">Recovery codes</h1>')).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);
});
});

View File

@@ -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<RecoveryCodeAuthInputs>(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;

View File

@@ -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);
});
});

View File

@@ -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<void>;
const hooks: Record<string, HookFunction> = {
@@ -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<string, StripeRouteHandler> = {
<script>
var stripe = Stripe(${JSON.stringify(stripeConfig().publishableKey)});
var source = localStorage.getItem('source');
var createCheckoutSession = function(priceId, promotionCode) {
return fetch("/stripe/createCheckoutSession", {
@@ -479,6 +451,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
body: JSON.stringify({
priceId,
promotionCode,
source,
})
}).then(function(result) {
return result.json();

View File

@@ -3,8 +3,6 @@ import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import MarkdownIt = require('markdown-it');
import markdownUtils from '@joplin/lib/markdownUtils';
import config from '../../config';
const router: Router = new Router(RouteType.Web);
router.public = true;
@@ -36,9 +34,7 @@ The use of this website is subject to the following terms of use:
- From time to time, this website may also include links to other websites. These links are provided for your convenience to provide further information. They do not signify that we endorse the website(s). We have no responsibility for the content of the linked website(s).
- Your use of this website and any dispute arising out of such use of the website is subject to the laws of France.
- Please contact us at [${markdownUtils.escapeTitleText(config().supportEmail)}](mailto:${markdownUtils.escapeLinkUrl(config().supportEmail)}) for any question.`);
- Your use of this website and any dispute arising out of such use of the website is subject to the laws of France.`);
});
export default router;

View File

@@ -5,7 +5,7 @@ import { AppContext } from '../../utils/types';
import { findPrice, PricePeriod, PlanName, getFeatureLabel, getFeatureEnabled, getAllFeatureIds } from '@joplin/lib/utils/joplinCloud';
import config from '../../config';
import defaultView from '../../utils/defaultView';
import { stripeConfig, stripePriceIdByUserId, updateSubscriptionType } from '../../utils/stripe';
import { initStripe, stripeConfig, stripePriceIdByUserId, updateSubscriptionType } from '../../utils/stripe';
import { bodyFields } from '../../utils/requestUtils';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
@@ -48,7 +48,9 @@ router.get('upgrade', async (_path: SubPath, ctx: AppContext) => {
});
}
const priceId = await stripePriceIdByUserId(ctx.joplin.models, ctx.joplin.owner.id);
const stripe = initStripe();
const priceId = await stripePriceIdByUserId(stripe, ctx.joplin.models, ctx.joplin.owner.id);
const currentPrice = findPrice(stripeConfig(), { priceId });
const upgradePrice = findPrice(stripeConfig(), {
accountType: AccountType.Pro,
@@ -75,7 +77,8 @@ router.post('upgrade', async (_path: SubPath, ctx: AppContext) => {
const models = joplin.models;
if (fields.upgrade_button) {
await updateSubscriptionType(models, joplin.owner.id, AccountType.Pro);
const stripe = initStripe();
await updateSubscriptionType(stripe, models, joplin.owner.id, AccountType.Pro);
await models.user().save({ id: joplin.owner.id, account_type: AccountType.Pro });
await models.notification().add(joplin.owner.id, NotificationKey.UpgradedToPro);
return redirect(ctx, `${config().baseUrl}/home`);

View File

@@ -93,7 +93,7 @@ describe('index/users', () => {
const userModel = models().user();
await patchUser(session.id, { id: user.id, full_name: 'new name' });
await patchUser(session.id, { id: user.id, full_name: 'new name' }, '/users/me');
const modUser: User = await userModel.load(user.id);
expect(modUser.full_name).toBe('new name');
});
@@ -290,7 +290,7 @@ describe('index/users', () => {
max_total_item_size: 5555,
can_share_folder: 1,
can_upload: 0,
});
}, '/users/me');
const reloadedUser1 = await models().user().load(user1.id);
expect(reloadedUser1.is_admin).toBe(0);
expect(reloadedUser1.max_item_size).toBe(null);
@@ -316,5 +316,37 @@ describe('index/users', () => {
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
});
test('should delete all sessions when changing the password but the current one', async () => {
const { user, session, password } = await createUserAndSession(1, true);
await models().session().authenticate(user.email, password, '');
await models().session().authenticate(user.email, password, '');
expect(await models().session().count()).toBe(3);
const newPassword = uuidgen();
await patchUser(session.id, { id: user.id, password: newPassword, password2: newPassword });
const sessions = await models().session().all();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(session.id);
});
test('should delete all applications when changing the password', async () => {
const { user, session } = await createUserAndSession(1, true);
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);
const newPassword = uuidgen();
await patchUser(session.id, { id: user.id, password: newPassword, password2: newPassword });
expect(await models().application().count()).toBe(0);
});
});

View File

@@ -12,13 +12,14 @@ import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType, accountTypeOptions } from '../../models/UserModel';
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
import { updateCustomerEmail } from '../../utils/stripe';
import { initStripe, updateCustomerEmail } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime } from '../../utils/time';
import { cookieSet } from '../../utils/cookies';
import { userFlagToString } from '../../models/UserFlagModel';
import { stopImpersonating } from '../admin/utils/users/impersonate';
import { _ } from '@joplin/lib/locale';
import { getIsMFAEnabled } from '../../models/utils/user';
export interface CheckRepeatPasswordInput {
password: string;
@@ -51,6 +52,15 @@ function makeUser(userId: Uuid, fields: any): User {
return user;
}
interface FormFields {
id: Uuid;
post_button: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
stop_impersonate_button: string;
send_account_confirmation_email: string;
}
const router = new Router(RouteType.Web);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -93,6 +103,8 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User =
view.content.postUrl = postUrl;
view.content.csrfTag = await createCsrfTag(ctx);
view.content.showSendAccountConfirmationEmailButton = !user.email_confirmed;
view.content.isMFAFeatureEnabled = config().MFA_ENABLED;
view.content.hasMFAEnabled = getIsMFAEnabled(user);
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
@@ -136,7 +148,8 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
const beforeChangingEmailHandler = async (newEmail: string) => {
if (config().stripe.enabled) {
try {
await updateCustomerEmail(models, userId, newEmail);
const stripe = initStripe();
await updateCustomerEmail(stripe, models, userId, newEmail);
} catch (error) {
if (['no_sub', 'no_stripe_sub'].includes(error.code)) {
// ok - the user just doesn't have a subscription
@@ -212,15 +225,6 @@ router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
router.alias(HttpMethod.POST, 'users/:id', 'users');
interface FormFields {
id: Uuid;
post_button: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
stop_impersonate_button: string;
send_account_confirmation_email: string;
}
router.post('users', async (path: SubPath, ctx: AppContext) => {
const owner = ctx.joplin.owner;
@@ -251,12 +255,15 @@ router.post('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.send_account_confirmation_email) {
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);
const returnUrl = await stopImpersonating(ctx);
return redirect(ctx, returnUrl ? returnUrl : config().baseUrl);
} else {
throw new Error('Invalid form button');
}

View File

@@ -11,6 +11,7 @@ import apiSessions from './api/sessions';
import apiShares from './api/shares';
import apiShareUsers from './api/share_users';
import apiUsers from './api/users';
import apiApplicationAuth from './api/application_auth';
import apiLogin from './api/login';
import apiTranscribe from './api/transcribe';
@@ -36,6 +37,9 @@ import indexStripe from './index/stripe';
import indexTerms from './index/terms';
import indexUpgrade from './index/upgrade';
import indexUsers from './index/users';
import indexMFA from './index/mfa';
import indexApplications from './index/applications';
import indexRecoveryCodes from './index/recovery_codes';
import defaultRoute from './default';
@@ -53,6 +57,7 @@ const routes: Routers = {
'api/share_users': apiShareUsers,
'api/shares': apiShares,
'api/users': apiUsers,
'api/application_auth': apiApplicationAuth,
'api/transcribe': apiTranscribe,
'admin/dashboard': adminDashboard,
@@ -77,6 +82,10 @@ const routes: Routers = {
'terms': indexTerms,
'upgrade': indexUpgrade,
'users': indexUsers,
'mfa': indexMFA,
'applications': indexApplications,
'recovery_codes/auth': indexRecoveryCodes,
'recovery_codes': indexRecoveryCodes,
'': defaultRoute,
};

View File

@@ -3,13 +3,13 @@ import * as fs from 'fs-extra';
import { extname } from 'path';
import config, { fullVersionString } from '../config';
import { filename } from '@joplin/lib/path-utils';
import { NotificationView } from '../utils/types';
import { Config, NotificationView } from '../utils/types';
import { User } from '../services/database/types';
import { makeUrl, SubPath, UrlType } from '../utils/routeUtils';
import MarkdownIt = require('markdown-it');
import { headerAnchor } from '@joplin/renderer';
import { _ } from '@joplin/lib/locale';
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, homeUrl, itemsUrl, adminReportUrl } from '../utils/urlUtils';
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, homeUrl, itemsUrl, adminReportUrl, applicationsUrl } from '../utils/urlUtils';
import { MenuItem, setSelectedMenu } from '../utils/views/menu';
import { ReportType } from './reports/types';
@@ -58,6 +58,7 @@ interface GlobalParams {
isAdminPage?: boolean;
adminMenu?: MenuItem[];
navbarMenu?: MenuItem[];
config?: Config;
currentPath?: SubPath;
appShortName?: string;
}
@@ -150,6 +151,10 @@ export default class MustacheService {
title: _('Home'),
url: homeUrl(),
},
{
title: _('Applications'),
url: applicationsUrl(),
},
];
if (isAdmin) {

View File

@@ -131,6 +131,8 @@ export default class TaskService extends BaseService {
public async runTask(id: TaskId, runType: RunType) {
const displayString = this.taskDisplayString(id);
const taskState = await this.models.taskState().loadByTaskId(id);
if (!taskState) throw new Error(`Invalid task: ${id}: ${runType}`);
if (!taskState.enabled) {
logger.info(`Not running ${displayString} because the tasks is disabled`);
return;

View File

@@ -1,6 +1,6 @@
import config from '../config';
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
import { afterAllTests, beforeAllDb, beforeEachDb, createNote, createUserAndSession, models } from '../utils/testing/testUtils';
import { afterAllTests, beforeAllDb, beforeEachDb, createApplicationCredentials, createNote, createUserAndSession, models } from '../utils/testing/testUtils';
import { Env } from '../utils/types';
import { BackupItemType, UserFlagType } from './database/types';
import UserDeletionService from './UserDeletionService';
@@ -180,4 +180,31 @@ describe('UserDeletionService', () => {
expect(await models().userDeletion().count()).toBe(0);
});
test('should delete applications when deleting user account', async () => {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
await createApplicationCredentials(user1.id, 'mockAppAuth');
await createApplicationCredentials(user2.id, 'mockAppAuth2');
await models().userFlag().toggle(user1.id, UserFlagType.ManuallyDisabled, true);
const job = await models().userDeletion().add(user1.id, new Date().getTime(), {
processData: false,
processAccount: true,
});
expect(await models().application().count()).toBe(2);
const service = newService();
await service.processDeletionJob(job, { sleepBetweenOperations: 0 });
const application = (await models().application().all())[0];
expect(application.user_id).toBe(user2.id);
expect(await models().user().count()).toBe(1);
expect(await models().session().count()).toBe(1);
expect(await models().application().count()).toBe(1);
});
});

View File

@@ -77,6 +77,7 @@ export default class UserDeletionService extends BaseService {
await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress);
await this.models.session().deleteByUserId(userId);
await this.models.application().deleteByUserId(userId);
await this.models.notification().deleteByUserId(userId);
await this.models.user().delete(userId);
await this.models.userFlag().deleteByUserId(userId);

View File

@@ -49,12 +49,12 @@ export enum UserFlagType {
export function userFlagTypeToLabel(t: UserFlagType): string {
const s: Record<UserFlagType, string> = {
[UserFlagType.FailedPaymentWarning]: 'Failed Payment (Warning)',
[UserFlagType.FailedPaymentFinal]: 'Failed Payment (Final)',
[UserFlagType.AccountOverLimit]: 'Account Over Limit',
[UserFlagType.AccountWithoutSubscription]: 'Account Without Subscription',
[UserFlagType.SubscriptionCancelled]: 'Subscription Cancelled',
[UserFlagType.ManuallyDisabled]: 'Manually Disabled',
[UserFlagType.FailedPaymentWarning]: 'Failed payment (Warning)',
[UserFlagType.FailedPaymentFinal]: 'Failed payment (Final)',
[UserFlagType.AccountOverLimit]: 'Account over limit',
[UserFlagType.AccountWithoutSubscription]: 'Account without subscription',
[UserFlagType.SubscriptionCancelled]: 'Subscription cancelled',
[UserFlagType.ManuallyDisabled]: 'Manually disabled',
[UserFlagType.UserDeletionInProgress]: 'User deletion in progress',
};
@@ -146,6 +146,7 @@ export enum TaskId {
export interface Session extends WithDates, WithUuid {
user_id?: Uuid;
auth_code?: string;
application_id?: Uuid;
}
export interface File {
@@ -244,28 +245,6 @@ export interface Subscription {
is_deleted?: number;
}
export interface User extends WithDates, WithUuid {
email?: string;
password?: string;
full_name?: string;
is_admin?: number;
email_confirmed?: number;
must_set_password?: number;
account_type?: number;
can_upload?: number;
max_item_size?: number | null;
can_share_folder?: number | null;
can_share_note?: number | null;
max_total_item_size?: number | null;
total_item_size?: number;
enabled?: number;
disabled_time?: number;
can_receive_folder?: number;
is_external?: number;
sso_auth_code?: string;
sso_auth_code_expire_at?: number;
}
export interface UserFlag extends WithDates {
id?: number;
user_id?: Uuid;
@@ -342,6 +321,51 @@ export interface TaskState extends WithDates {
enabled?: number;
}
export interface Application {
id?: Uuid;
user_id?: Uuid;
password?: string;
version?: string;
platform?: number;
ip?: string;
type?: number;
updated_time?: string;
created_time?: string;
last_access_time?: string;
}
export interface RecoveryCode {
id?: Uuid;
user_id?: Uuid;
code?: string;
is_used?: number;
updated_time?: string;
created_time?: string;
}
export interface User extends WithDates, WithUuid {
email?: string;
password?: string;
full_name?: string;
is_admin?: number;
email_confirmed?: number;
must_set_password?: number;
account_type?: number;
can_upload?: number;
max_item_size?: number | null;
can_share_folder?: number | null;
can_share_note?: number | null;
max_total_item_size?: number | null;
total_item_size?: number;
enabled?: number;
disabled_time?: number;
can_receive_folder?: number;
totp_secret?: string;
is_external?: number;
sso_auth_code?: string;
sso_auth_code_expire_at?: number;
}
export const databaseSchema: DatabaseTables = {
sessions: {
id: { type: 'string', defaultValue: null },
@@ -349,6 +373,7 @@ export const databaseSchema: DatabaseTables = {
auth_code: { type: 'string', defaultValue: '' },
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
application_id: { type: 'string', defaultValue: 'null' },
},
files: {
id: { type: 'string', defaultValue: null },
@@ -453,30 +478,6 @@ export const databaseSchema: DatabaseTables = {
created_time: { type: 'string', defaultValue: null },
is_deleted: { type: 'number', defaultValue: 0 },
},
users: {
id: { type: 'string', defaultValue: null },
email: { type: 'string', defaultValue: null },
password: { type: 'string', defaultValue: null },
full_name: { type: 'string', defaultValue: '' },
is_admin: { type: 'number', defaultValue: 0 },
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
email_confirmed: { type: 'number', defaultValue: 0 },
must_set_password: { type: 'number', defaultValue: 0 },
account_type: { type: 'number', defaultValue: 0 },
can_upload: { type: 'number', defaultValue: 1 },
max_item_size: { type: 'number', defaultValue: null },
can_share_folder: { type: 'number', defaultValue: null },
can_share_note: { type: 'number', defaultValue: null },
max_total_item_size: { type: 'string', defaultValue: null },
total_item_size: { type: 'string', defaultValue: 0 },
enabled: { type: 'number', defaultValue: 1 },
disabled_time: { type: 'string', defaultValue: 0 },
can_receive_folder: { type: 'number', defaultValue: null },
is_external: { type: 'number', defaultValue: 0 },
sso_auth_code: { type: 'string', defaultValue: '' },
sso_auth_code_expire_at: { type: 'number', defaultValue: 0 },
},
user_flags: {
id: { type: 'number', defaultValue: null },
user_id: { type: 'string', defaultValue: null },
@@ -558,5 +559,50 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
},
applications: {
id: { type: 'string', defaultValue: null },
user_id: { type: 'string', defaultValue: '' },
password: { type: 'string', defaultValue: '' },
version: { type: 'string', defaultValue: '' },
platform: { type: 'number', defaultValue: null },
ip: { type: 'string', defaultValue: null },
type: { type: 'number', defaultValue: null },
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
last_access_time: { type: 'string', defaultValue: 0 },
},
recovery_codes: {
id: { type: 'string', defaultValue: null },
user_id: { type: 'string', defaultValue: '' },
code: { type: 'string', defaultValue: '' },
is_used: { type: 'number', defaultValue: 1 },
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
},
users: {
id: { type: 'string', defaultValue: null },
email: { type: 'string', defaultValue: null },
password: { type: 'string', defaultValue: null },
full_name: { type: 'string', defaultValue: '' },
is_admin: { type: 'number', defaultValue: 0 },
updated_time: { type: 'string', defaultValue: null },
created_time: { type: 'string', defaultValue: null },
email_confirmed: { type: 'number', defaultValue: 0 },
must_set_password: { type: 'number', defaultValue: 0 },
account_type: { type: 'number', defaultValue: 0 },
can_upload: { type: 'number', defaultValue: 1 },
max_item_size: { type: 'number', defaultValue: null },
can_share_folder: { type: 'number', defaultValue: null },
can_share_note: { type: 'number', defaultValue: null },
max_total_item_size: { type: 'string', defaultValue: null },
total_item_size: { type: 'string', defaultValue: 0 },
enabled: { type: 'number', defaultValue: 1 },
disabled_time: { type: 'string', defaultValue: 0 },
can_receive_folder: { type: 'number', defaultValue: null },
totp_secret: { type: 'string', defaultValue: '' },
is_external: { type: 'number', defaultValue: 0 },
sso_auth_code: { type: 'string', defaultValue: '' },
sso_auth_code_expire_at: { type: 'string', defaultValue: 0 },
},
};
// AUTO-GENERATED-TYPES

View File

@@ -1,34 +1,16 @@
import time from '@joplin/lib/time';
import { DbConnection, truncateTables } from '../../db';
import { DbConnection } from '../../db';
import newModelFactory from '../../models/factory';
import { AccountType } from '../../models/UserModel';
import { User, UserFlagType } from '../../services/database/types';
import { Config } from '../../utils/types';
import truncateUserDataTables from './truncateUserDataTables';
export interface CreateTestUsersOptions {
count?: number;
fromNum?: number;
}
const includedTables = [
'changes',
'emails',
'events',
'item_resources',
'items',
'notifications',
'sessions',
'share_users',
'shares',
'subscriptions',
'teams',
'team_users',
'user_deletions',
'user_flags',
'user_items',
'users',
];
export default async function createTestUsers(db: DbConnection, config: Config, options: CreateTestUsersOptions = null) {
options = {
count: 0,
@@ -39,7 +21,7 @@ export default async function createTestUsers(db: DbConnection, config: Config,
const password = '111111';
const models = newModelFactory(db, db, config);
await truncateTables(db, includedTables);
await truncateUserDataTables(db);
await models.user().save({
email: 'admin@localhost',

View File

@@ -187,7 +187,7 @@ const reactions: Record<Action, Reaction> = {
// Tag the folder with the share ID so that items created within
// the folder can be part of the share:
const folder = await context.models.item().loadAsJoplinItem(item.id);
const folder = await context.models.item().loadAsJoplinItem<FolderEntity>(item.id);
const serialized = makeFolderSerializedBody({
...folder,
share_id: share.id,
@@ -252,7 +252,7 @@ const reactions: Record<Action, Reaction> = {
try {
const noteItem = await context.models.item().loadByJopId(user.id, noteId);
if (!noteItem) return false;
const note = await context.models.item().loadAsJoplinItem(noteItem.id);
const note = await context.models.item().loadAsJoplinItem<NoteEntity>(noteItem.id);
const serialized = makeNoteSerializedBody({
title: randomWords(10),
...note,
@@ -276,7 +276,7 @@ const reactions: Record<Action, Reaction> = {
try {
const folderItem = await context.models.item().loadByJopId(user.id, folderId);
const folder = await context.models.item().loadAsJoplinItem(folderItem.id);
const folder = await context.models.item().loadAsJoplinItem<FolderEntity>(folderItem.id);
const serialized = makeFolderSerializedBody({
title: randomWords(5),
...folder,

View File

@@ -0,0 +1,24 @@
import { DbConnection, truncateTables } from '../../db';
const includedTables = [
'changes',
'emails',
'events',
'item_resources',
'items',
'notifications',
'sessions',
'share_users',
'shares',
'subscriptions',
'user_deletions',
'user_flags',
'user_items',
'users',
];
const truncateUserDataTables = (db: DbConnection) => {
return truncateTables(db, includedTables);
};
export default truncateUserDataTables;

View File

@@ -71,6 +71,7 @@ const propertyTypes: Record<string, string> = {
'users.max_item_size': 'number | null',
'users.max_total_item_size': 'number | null',
'users.total_item_size': 'number',
'users.sso_auth_code_expire_at': 'number',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {

View File

@@ -0,0 +1,19 @@
import { randomBytes } from 'crypto';
import { decryptMFASecret, encryptMFASecret } from './crypto';
describe('crypto', () => {
it('should be able to encrypt and decrypt and return to same value', () => {
const secret = randomBytes(32).toString('hex');
const message = 'hello world';
const resultEncryption = encryptMFASecret(Buffer.from(message), secret);
const resultDecrypt = decryptMFASecret(resultEncryption, secret);
expect(resultDecrypt.toString()).toEqual(message);
});
});

View File

@@ -1,7 +1,50 @@
/* eslint-disable import/prefer-default-export */
import { createHash } from 'crypto';
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'crypto';
const thirtyTwo = require('thirty-two');
import { totp } from 'otplib';
import config from '../config';
export function md5(string: string): string {
return createHash('md5').update(string).digest('hex');
}
const outputEncoding = 'hex';
const algorithm = 'aes-256-gcm';
export const encryptMFASecret = (toBeEncrypted: Buffer, encryptionKey: string) => {
const iv = randomBytes(12);
const cipher = createCipheriv(algorithm, Buffer.from(encryptionKey, outputEncoding), iv);
let encrypted = cipher.update(toBeEncrypted, undefined, outputEncoding);
encrypted += cipher.final(outputEncoding);
const authTag = cipher.getAuthTag();
return `${iv.toString(outputEncoding)}${encrypted}${authTag.toString(outputEncoding)}`;
};
export const decryptMFASecret = (encryptedSecret: string, encryptionKey: string) => {
const encryptedData = Buffer.from(encryptedSecret, outputEncoding);
const iv = encryptedData.slice(0, 12);
const authTag = encryptedData.slice(-16);
const decipher = createDecipheriv(algorithm, Buffer.from(encryptionKey, outputEncoding), iv);
decipher.setAuthTag(authTag);
const decrypted = decipher.update(encryptedData.slice(12, -16));
return Buffer.concat([decrypted, decipher.final()]);
};
export const checkConsecutiveMFACodes = (totpSecret: string, confirmCode1: string, confirmCode2: string) => {
const decodedTotpSecret = thirtyTwo.decode(totpSecret);
totp.options = {
// Set epoch 30s in the past to verify if the first code is valid
epoch: Date.now() - 30 * 1000,
};
const isVerified = totp.check(confirmCode1, decodedTotpSecret);
totp.resetOptions();
const isVerified2 = totp.check(confirmCode2, decodedTotpSecret);
return isVerified && isVerified2;
};
export const isValidMFACode = (totpSecret: string, confirmCode: string) => {
const decryptedSecret = decryptMFASecret(totpSecret, config().MFA_ENCRYPTION_KEY);
return totp.verify({ token: confirmCode, secret: decryptedSecret.toString('binary') });
};

View File

@@ -6,6 +6,7 @@ export enum ErrorCode {
NoSub = 'no_sub',
NoStripeSub = 'no_stripe_sub',
InvalidOrigin = 'invalidOrigin',
IsReadOnly = 'isReadOnly',
TaskAlreadyRunning = 'taskAlreadyRunning',
}

View File

@@ -27,7 +27,11 @@ import Logger from '@joplin/utils/Logger';
import config from '../config';
import { TreeItem } from '../models/ItemResourceModel';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtils';
import KeychainServiceDriverDummy from '@joplin/lib/services/keychain/KeychainServiceDriver.dummy';
import BaseService from '@joplin/lib/services/BaseService';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
import FsDriverNode from '@joplin/lib/fs-driver-node';
const logger = Logger.create('JoplinUtils');
@@ -88,6 +92,18 @@ export async function initializeJoplinUtils(config: Config, models: Models, must
BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
const fsDriver = new FsDriverNode();
Resource.fsDriver_ = fsDriver;
BaseService.logger_ = new Logger();
Setting.allowFileStorage = false;
Setting.setConstant('appId', 'net.cozic.joplin-desktop');
Setting.setConstant('tempDir', config.tempDir);
Setting.setConstant('resourceDir', config.resourceDir);
await loadKeychainServiceAndSettings([KeychainServiceDriverDummy]);
}
export function linkedResourceIds(body: string): string[] {
@@ -160,7 +176,7 @@ async function getResourceInfos(linkedItemInfos: LinkedItemInfos): Promise<Resou
return output;
}
async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, noteBody: string): Promise<LinkedItemInfos> {
export async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, noteBody: string): Promise<LinkedItemInfos> {
const jopIds = await Note.linkedItemIds(noteBody);
const output: LinkedItemInfos = {};

View File

@@ -6,6 +6,7 @@ import { AppContext, HttpMethod, RouteType } from './types';
import { URL } from 'url';
import { csrfCheck } from './csrf';
import { contextSessionId } from './requestUtils';
import { shortToLong } from './uuid';
import { stripOffQueryParameters } from './urlUtils';
import { hasOwnProperty } from '@joplin/utils/object';
@@ -84,6 +85,12 @@ export function redirect(ctx: AppContext, url: string): Response {
return new Response(ResponseType.KoaResponse, ctx.response);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function internalRedirect(path: SubPath, ctx: AppContext, router: Router, urlSchema: string, ...args: any[]) {
const endPoint = router.findEndPoint(HttpMethod.GET, urlSchema);
return endPoint.handler(path, ctx, ...args);
}
export function filePathInfo(path: string): PathInfo {
return {
basename: removeTrailingColon(basename(path)),
@@ -200,6 +207,22 @@ function disabledAccountCheck(route: MatchedRoute, user: User) {
if (route.subPath.schema.startsWith('api/')) throw new ErrorForbidden(`This account is disabled. Please login to ${config().baseUrl} for more information.`);
}
const needsConvertedId = (_path: SubPath): boolean => {
// Return true if the particular schema should use a converted ID
return false;
};
const convertPathId = (path: SubPath): SubPath => {
if (needsConvertedId(path)) {
return {
...path,
id: shortToLong(path.id),
};
}
return path;
};
interface ExecRequestResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
response: any;
@@ -234,7 +257,7 @@ export async function execRequest(routes: Routers, ctx: AppContext): Promise<Exe
return {
response: await endPoint.handler(match.subPath, ctx),
path: match.subPath,
path: convertPathId(match.subPath),
};
}

View File

@@ -31,19 +31,18 @@ export function accountTypeToPriceId(accountType: AccountType): string {
return price.id;
}
export async function subscriptionInfoByUserId(models: Models, userId: Uuid): Promise<SubscriptionInfo> {
export async function subscriptionInfoByUserId(stripe: Stripe, models: Models, userId: Uuid): Promise<SubscriptionInfo> {
const sub = await models.subscription().byUserId(userId);
if (!sub) throw new ErrorWithCode('Could not retrieve subscription info', ErrorCode.NoSub);
const stripe = initStripe();
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
if (!stripeSub) throw new ErrorWithCode('Could not retrieve Stripe subscription', ErrorCode.NoStripeSub);
return { sub, stripeSub };
}
export async function stripePriceIdByUserId(models: Models, userId: Uuid): Promise<string> {
const { stripeSub } = await subscriptionInfoByUserId(models, userId);
export async function stripePriceIdByUserId(stripe: Stripe, models: Models, userId: Uuid): Promise<string> {
const { stripeSub } = await subscriptionInfoByUserId(stripe, models, userId);
return stripePriceIdByStripeSub(stripeSub);
}
@@ -55,18 +54,18 @@ export async function cancelSubscriptionByUserId(models: Models, userId: Uuid) {
const sub = await models.subscription().byUserId(userId);
if (!sub) throw new Error(`No subscription for user: ${userId}`);
const stripe = initStripe();
await stripe.subscriptions.del(sub.stripe_subscription_id);
await stripe.subscriptions.cancel(sub.stripe_subscription_id);
}
export async function cancelSubscription(stripe: Stripe, stripeSubId: string) {
await stripe.subscriptions.del(stripeSubId);
await stripe.subscriptions.cancel(stripeSubId);
}
export async function updateSubscriptionType(models: Models, userId: Uuid, newAccountType: AccountType) {
export async function updateSubscriptionType(stripe: Stripe, models: Models, userId: Uuid, newAccountType: AccountType) {
const user = await models.user().load(userId);
if (user.account_type === newAccountType) throw new Error(`Account type is already: ${newAccountType}`);
const { sub, stripeSub } = await subscriptionInfoByUserId(models, userId);
const { sub, stripeSub } = await subscriptionInfoByUserId(stripe, models, userId);
const currentPrice = findPrice(stripeConfig(), { priceId: stripePriceIdByStripeSub(stripeSub) });
const upgradePrice = findPrice(stripeConfig(), { accountType: newAccountType, period: currentPrice.period });
@@ -103,7 +102,6 @@ export async function updateSubscriptionType(models: Models, userId: Uuid, newAc
// example, the user could try to upgrade the account a second time.
// Although that attempt would most likely fail due the checks above and
// the checks in subscriptions.update().
const stripe = initStripe();
await stripe.subscriptions.update(sub.stripe_subscription_id, { items });
}
@@ -142,10 +140,54 @@ export function betaStartSubUrl(email: string, accountType: AccountType): string
return `${globalConfig().joplinAppBaseUrl}/plans/?email=${encodeURIComponent(email)}&account_type=${encodeURIComponent(accountType)}`;
}
export async function updateCustomerEmail(models: Models, userId: Uuid, newEmail: string) {
const subInfo = await subscriptionInfoByUserId(models, userId);
const stripe = initStripe();
export async function updateCustomerEmail(stripe: Stripe, models: Models, userId: Uuid, newEmail: string) {
const subInfo = await subscriptionInfoByUserId(stripe, models, userId);
await stripe.customers.update(subInfo.sub.stripe_user_id, {
email: newEmail,
});
}
// Currently we only handle FR, because it's required by French administration
const countryToLocales = (countryCode: string): string[] => {
countryCode = countryCode.toUpperCase();
if (countryCode === 'FR') return ['fr-FR', 'en-GB'];
return ['en-GB'];
};
export const autoAssignCustomerPreferredLocales = async (stripe: Stripe, customerId: string) => {
const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer;
if (!customer.address) return;
const locales = countryToLocales(customer.address.country);
await stripe.customers.update(customerId, {
preferred_locales: locales,
});
};
enum InvoiceStatus {
None = 'none',
Paid = 'paid',
Unpaid = 'unpaid',
}
export const getLastInvoiceStatus = async (stripe: Stripe, subscriptionId: string) => {
const sub = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['latest_invoice'],
});
const invoice = sub.latest_invoice as Stripe.Invoice;
if (!invoice) return InvoiceStatus.None;
return invoice.paid ? InvoiceStatus.Paid : InvoiceStatus.Unpaid;
};
// Rechecking an invoice normally should not be necessary since we receive the events from Stripe.
// However if for some reason the server was down for a long period of time, it may not have picked
// up that an invoice had been paid during that time. In that case, it's possible call this to
// manually check the invoice status and update the related flags.
export const recheckPaymentStatus = async (stripe: Stripe, models: Models, userId: Uuid) => {
const subInfo = await subscriptionInfoByUserId(stripe, models, userId);
const status = await getLastInvoiceStatus(stripe, subInfo.sub.stripe_subscription_id);
if (status === InvoiceStatus.None) return;
await models.subscription().handlePayment(subInfo.sub.stripe_subscription_id, status === InvoiceStatus.Paid);
};

View File

@@ -2,7 +2,7 @@ import { AppContext } from '../types';
import routeHandler from '../../middleware/routeHandler';
import { AppContextTestOptions, checkContextError, koaAppContext } from './testUtils';
interface ExecRequestOptions {
export interface ExecRequestOptions {
filePath?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
query?: Record<string, any>;

View File

@@ -66,6 +66,14 @@ async function createItemTree3(sessionId: Uuid, userId: Uuid, parentFolderId: st
}
const result = await models().item().saveFromRawContent(user, [{ name: `${jopItem.id}.md`, body: Buffer.from(serializedBody) }]);
for (const [, resultItem] of Object.entries(result)) {
if (resultItem.error) {
resultItem.error.message = `Cannot create item tree: ${resultItem.error.message}`;
throw resultItem.error;
}
}
const newItem = result[`${jopItem.id}.md`].item;
if (isFolder && jopItem.children.length) await createItemTree3(sessionId, userId, newItem.jop_id, shareId, jopItem.children);
}

View File

@@ -15,7 +15,7 @@ import * as fs from 'fs-extra';
import * as jsdom from 'jsdom';
import setupAppContext from '../setupAppContext';
import { ApiError } from '../errors';
import { deleteApi, getApi, putApi } from './apiUtils';
import { getApi, putApi, deleteApi, ExecRequestOptions } from './apiUtils';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel';
import { initializeJoplinUtils } from '../joplinUtils';
@@ -25,8 +25,10 @@ import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
import { parseEnv } from '../../env';
import { URL } from 'url';
import { AccountType } from '../../models/UserModel';
import initLib from '@joplin/lib/initLib';
import { makeFolderSerializedBody, makeNoteSerializedBody, makeResourceSerializedBody } from './serializedItems';
import { AppAuthResponse } from '../../models/ApplicationModel';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@@ -91,11 +93,13 @@ export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOpt
const tempDir = `${packageRootDir}/temp/test-${unitName}`;
await fs.mkdirp(tempDir);
// To run the test units with Postgres. Run this:
// To run the tests with Postgres, first run this:
//
// docker compose -f docker-compose.db-dev.yml up
// docker compose -f docker-compose.db-dev.yml up
//
// JOPLIN_TESTS_SERVER_DB=pg yarn test
// Then this:
//
// JOPLIN_TESTS_SERVER_DB=pg yarn test
if (getDatabaseClientType() === DatabaseConfigClient.PostgreSQL) {
await initConfig(Env.Dev, parseEnv({
@@ -178,7 +182,7 @@ export interface AppContextTestOptions {
sessionId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
request?: any;
ip?: string;
baseAppContext?: AppContext;
}
@@ -272,6 +276,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
method: req.method,
redirect: () => {},
URL: new URL(config().baseUrl), // origin
ip: options.ip,
};
if (options.sessionId) {
@@ -317,6 +322,7 @@ export function parseHtml(html: string): Document {
interface CreateUserAndSessionOptions {
email?: string;
password?: string;
account_type?: AccountType;
}
export const createUserAndSession = async function(index = 1, isAdmin = false, options: CreateUserAndSessionOptions = null): Promise<UserAndSession> {
@@ -328,8 +334,16 @@ export const createUserAndSession = async function(index = 1, isAdmin = false, o
...options,
};
const user = await models().user().save({ email: options.email, password: options.password, is_admin: isAdmin ? 1 : 0 }, { skipValidation: true });
const session = await models().session().authenticate(options.email, options.password);
let user: User = {
email: options.email,
password: options.password,
is_admin: isAdmin ? 1 : 0,
};
if (options.account_type) user.account_type = options.account_type;
user = await models().user().save(user, { skipValidation: true });
const session = await models().session().authenticate(options.email, options.password, '');
return {
user: await models().user().load(user.id),
@@ -399,9 +413,9 @@ export async function getItem(sessionId: string, path: string): Promise<string>
return item.toString();
}
export async function createItem(sessionId: string, path: string, content: string | Buffer): Promise<Item> {
export async function createItem(sessionId: string, path: string, content: string | Buffer, options: ExecRequestOptions = null): Promise<Item> {
const tempFilePath = await makeTempFileWithContent(content);
const item: Item = await putApi(sessionId, `items/${path}/content`, null, { filePath: tempFilePath });
const item: Item = await putApi(sessionId, `items/${path}/content`, null, { filePath: tempFilePath, ...options });
await fs.remove(tempFilePath);
return models().item().load(item.id);
}
@@ -428,6 +442,10 @@ export async function createNote(sessionId: string, note: NoteEntity): Promise<I
return createItem(sessionId, `root:/${note.id}.md:`, makeNoteSerializedBody(note));
}
export async function deleteNoteBySession(sessionId: string, noteJopId: string) {
await deleteApi(sessionId, `items/root:/${noteJopId}.md:`);
}
export async function updateNote(sessionId: string, note: NoteEntity): Promise<Item> {
return updateItem(sessionId, `root:/${note.id}.md:`, makeNoteSerializedBody(note));
}
@@ -456,6 +474,10 @@ export async function createFolder(sessionId: string, folder: FolderEntity): Pro
return createItem(sessionId, `root:/${folder.id}.md:`, makeFolderSerializedBody(folder));
}
export const createResourceContent = async (sessionId: string, resourceId: string, content: string, options: ExecRequestOptions = null) => {
return await createItem(sessionId, `root:/.resource/${resourceId}:`, content, options);
};
export async function createResource(sessionId: string, resource: ResourceEntity, content: string): Promise<Item> {
resource = {
id: '000000000000000000000000000000E1',
@@ -468,13 +490,15 @@ export async function createResource(sessionId: string, resource: ResourceEntity
const serializedBody = makeResourceSerializedBody(resource);
const resourceItem = await createItem(sessionId, `root:/${resource.id}.md:`, serializedBody);
await createItem(sessionId, `root:/.resource/${resource.id}:`, content);
await createResourceContent(sessionId, resource.id, content);
return resourceItem;
}
export function checkContextError(context: AppContext) {
if (context.response.status >= 400) {
throw new ApiError(`${context.method} ${context.path} ${JSON.stringify(context.response)}`, context.response.status);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const body: any = context.response?.body || {};
throw new ApiError(`${context.method} ${context.path} ${JSON.stringify(context.response)}`, context.response.status, body.code);
}
}
@@ -599,3 +623,19 @@ export async function expectNotThrow(asyncFn: Function) {
expect(true).toBe(true);
}
}
export async function createApplicationCredentials(userId: string, applicationAuthId: string) {
await models().application().createPreLoginRecord(
applicationAuthId,
'',
undefined,
undefined,
undefined,
);
await models().application().onAuthorizeUse(applicationAuthId, userId);
const appAuthResponse: AppAuthResponse = await getApi('', `application_auth/${applicationAuthId}`);
return appAuthResponse;
}

View File

@@ -32,12 +32,24 @@ export function msleep(ms: number) {
});
}
export function formatDateOnServer(ms: number | Date): string {
if (!ms) return '-';
ms = ms instanceof Date ? ms.getTime() : ms;
return `${dayjs(ms).utc().format('D MMM YY HH:mm:ss')} (UTC)`;
}
export function formatDateTime(ms: number | Date): string {
if (!ms) return '-';
ms = ms instanceof Date ? ms.getTime() : ms;
return `${dayjs(ms).format('D MMM YY HH:mm:ss')} (${defaultTimezone()})`;
}
export function formatDate(ms: number | Date): string {
if (!ms) return '-';
ms = ms instanceof Date ? ms.getTime() : ms;
return `${dayjs(ms).format('D MMM YYYY')}`;
}
export const durationToMilliseconds = (durationIso8601: string) => {
const d = dayjs.duration(durationIso8601).asMilliseconds();
if (isNaN(d)) throw new Error(`Invalid ISO 8601 duration: ${durationIso8601}`);

View File

@@ -166,6 +166,7 @@ export interface Config extends EnvVariables {
// to stdout, which is then handled by Docker own log mechanism
logDir: string;
tempDir: string;
resourceDir: string;
baseUrl: string;
apiBaseUrl: string;
adminBaseUrl: string;

View File

@@ -0,0 +1,10 @@
import { sanitizeUserUrl } from './urlUtils';
describe('urlUtils', () => {
test('sanitizeUserUrl should return # for dangerous URLs', () => {
expect(sanitizeUserUrl('randomProtocol://foo')).toBe('#');
expect(sanitizeUserUrl('javascript:foo')).toBe('#');
expect(sanitizeUserUrl('example.com')).toBe('#');
expect(sanitizeUserUrl('https://example.com/')).toBe('https://example.com/');
});
});

View File

@@ -1,6 +1,7 @@
import { URL } from 'url';
import config from '../config';
import { Uuid } from '../services/database/types';
import { isHttpOrHttpsUrl } from '@joplin/utils/url';
import { ReportType } from '../services/reports/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -24,6 +25,14 @@ export function stripOffQueryParameters(url: string): string {
return s.join('?');
}
// Does not escape HTML characters.
export const sanitizeUserUrl = (url: string) => {
if (!isHttpOrHttpsUrl(url)) {
return '#';
}
return url;
};
export function resetPasswordUrl(token: string): string {
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
}
@@ -60,8 +69,10 @@ export function changesUrl(): string {
return `${config().baseUrl}/changes`;
}
export function loginUrl(): string {
return `${config().baseUrl}/login`;
export function loginUrl(applicationAuthId?: string): string {
const url = `${config().baseUrl}/login`;
if (!applicationAuthId) return url;
return `${url}?application_auth_id=${applicationAuthId}`;
}
export function adminUserDeletionsUrl(): string {
@@ -96,6 +107,33 @@ export function adminEmailUrl(id: number) {
return `${config().adminBaseUrl}/emails/${id}`;
}
export function mfaUrl(userId: Uuid) {
return `${config().baseUrl}/mfa/${userId}`;
}
export function applicationsConfirmUrl(applicationAuthId: string) {
return `${config().baseUrl}/applications/${applicationAuthId}/confirm`;
}
export function recoveryCodesUrl() {
return `${config().baseUrl}/recovery_codes`;
}
export function recoveryCodesAuthUrl(isPassword?: boolean, isMfaCode?: boolean) {
const url = `${config().baseUrl}/recovery_codes/auth`;
if (isMfaCode) return `${url}?show_mfa_code=1`;
if (isPassword) return `${url}?show_password=1`;
return url;
}
export function applicationsUrl() {
return `${config().baseUrl}/applications`;
}
export function applicationDeleteUrl(id: string) {
return `${config().baseUrl}/applications/${id}/delete`;
}
export function adminReportUrl(type: ReportType) {
return `${config().adminBaseUrl}/reports/${type}`;
}

View File

@@ -0,0 +1,38 @@
import shortUuid = require('short-uuid');
import { customAlphabetSecure } from '@joplin/lib/uuid';
const charSet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let shortUuidTranslator_: shortUuid.Translator = null;
const customAlphabetFromUUIDGen = customAlphabetSecure(charSet);
export const shortUuidTranslator = () => {
if (!shortUuidTranslator_) shortUuidTranslator_ = shortUuid(charSet);
return shortUuidTranslator_;
};
// https://zelark.github.io/nano-id-cc/
// https://security.stackexchange.com/a/41749/1873
// > On the other hand, 128 bits (between 21 and 22 characters
// > alphanumeric) is beyond the reach of brute-force attacks pretty much
// > indefinitely
export const uuidgen = (length = 22): string => {
return customAlphabetFromUUIDGen(length);
};
export const isReservedId = (s: string): boolean => {
return ['me', 'new'].includes(s);
};
export const shortToLong = (shortId: string): string => {
return shortId;
// if (isReservedId(shortId)) return shortId;
// return shortUuidTranslator().toUUID(shortId);
};
export const longToShort = (longId: string): string => {
return longId;
// if (isReservedId(longId)) return longId;
// return shortUuidTranslator().fromUUID(longId);
};

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import { ErrorUnprocessableEntity } from './errors';
export const validateEmail = (email: string) => {
const s = email.split('@');
if (s.length !== 2) throw new ErrorUnprocessableEntity(`Invalid email: ${email}`);
if (!s[0].length || !s[1].length) throw new ErrorUnprocessableEntity(`Invalid email: ${email}`);
};

View File

@@ -119,6 +119,7 @@
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
<input type="submit" name="recheck_invoice_button" class="button is-link" value="Recheck invoice" />
</div>
</div>
{{/subscription}}

View File

@@ -3,7 +3,7 @@
<div class="field">
<label class="label">Search user</label>
<div class="control">
<input name="query" id="query" class="input" type="text" placeholder="User name or email" value="{{query}}">
<input name="query" id="query" class="input" type="text" placeholder="User name, email or subscription ID" value="{{query}}">
{{#queryArray}}
<input type="hidden" name="{{name}}" value="{{value}}" />
{{/queryArray}}

View File

@@ -0,0 +1,24 @@
import markdownUtils from '@joplin/lib/markdownUtils';
import config from '../../config';
import { EmailSubjectBody } from '../../models/EmailModel';
interface TemplateView {
changePasswordUrl: string;
accessTime: string;
}
export default (view: TemplateView): EmailSubjectBody => {
return {
subject: `[${config().appName}] Your multi-factor authentication recovery codes were viewed`,
body: `
Your multi-factor authentication recovery codes were viewed on ${view.accessTime}.
If this was you, no further action is required.
If this was **not** you, your account may be compromised. [Click on this link to change your password](${markdownUtils.escapeLinkUrl(view.changePasswordUrl)})
Joplin Cloud Team
`.trim(),
};
};

View File

@@ -0,0 +1,33 @@
<h2 class="title">Manage application credentials</h2>
<div class="block readable-block content">
<p>Your active application connections are listed below.</p>
<p>Your application credential is how you connect from your desktop, mobile or CLI application to Joplin Cloud.</p>
</div>
<h3 class="title is-4">Last connected applications</h3>
<div class="m-2" id="applications-table">
{{#applications}}
<div class="columns is-mobile is-vcentered mb-3 application-item">
<div class="column">
<div class="columns -is-multiline ">
{{#columns}}
<div class="column">
<div class=""><strong>{{label}}</strong></div>
<div class="">{{value}}</div>
</div>
{{/columns}}
</div>
</div>
<div class="column is-narrow">
<form action="{{deleteUrl}}" method="POST">
{{{csrfTag}}}
<button class="button is-primary">Delete</button>
</form>
</div>
</div>
{{/applications}}
</div>

View File

@@ -0,0 +1,22 @@
<section class="section">
<div class="container">
<h1 class="title">{{title}}</h1>
<form action="{{postUrl}}" method="POST">
{{{csrfTag}}}
<div class="block readable-block content">
<p>{{description}}</p>
</div>
<input class="input" type="hidden" name="applicationAuthId" value="{{applicationAuthId}}"/>
{{> errorBanner}}
<div class="control">
<a href="{{cancelRedirect}}" class="button">{{cancel}}</a>
<button class="button is-primary">{{authorise}}</button>
</div>
</form>
</div>
</section>

View File

@@ -1,36 +1,85 @@
# Joplin Cloud Help
## How can I change my details?
## Account management
Most of your details can be found in your Profile page. To open it, click on the Profile button - this is the button in the top right corner, with your name or email on it.
### How can I change my details?
## How can I get more space?
Most of your details can be found in your [Profile](/users/me) page. To open it, click on the Profile button - this is the button in the top right corner, with your name or email on it.
If you are on a Basic account, you may upgrade to a Pro account to get more space. Click on the [Profile button](#how-can-i-change-my-details), then scroll down and select "Upgrade to Pro".
### How can I get more space?
If you are on a Basic account, you may upgrade to a Pro account to get more space. Open your [profile](/users/me), then scroll down and select "Upgrade to Pro".
If you are already on a Pro account, and you need more space for specific reasons, please contact us as we may increase the cap in some cases.
## How can I manage my payment details?
### How can I switch to a different billing cycle or plan?
To update your card or other payment details, click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription".
## How can I switch to a different billing cycle or plan?
To switch between yearly and monthly payments, or to change from a Basic to Pro plan or vice versa, please click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". From the Subscription page, click on Update Plan to choose your new billing cycle or to change your plan.
To switch between yearly and monthly payments, or to change from a Basic to Pro plan or vice versa, please open your [profile](/users/me), then scroll down and click on "[Manage subscription](/stripe/portal)". From the Subscription page, click on Update Plan to choose your new billing cycle or to change your plan.
Note that if you downgrade from Pro to Basic, new limitations will apply so for example you may have to delete some notes so that your account is below the required limit.
## Can my subscription be refunded?
### What if I exceed the storage space?
We offer a 14 days trial when the subscription starts so that you can evaluate the service and potentially change your mind - if you cancel during that period you will not be charged. After 14 days your card will be charged and it will not be possible to issue a refund.
If you exceed the storage space, you will not be able to upload new notes. You may however delete notes and attachments so as to free up space. If you are on a Basic plan, you may also upgrade to Pro. If you are on a Pro or Business plan please contact us and let us know that you need more space and we will increase your storage space.
## How can I cancel my account?
## Billing
Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". Your subscription will be cancelled and you will not be charged on what would have been the next billing period. Please note that we do not cancel accounts over email as we cannot verify your identity, however we can provide assistance if there is an issue.
### How can I manage my payment details?
To update your card or other payment details, open your [profile](/users/me), then scroll down and click on "[Manage subscription](/stripe/portal)".
### If a payment has failed
This can happen for various reasons, for example if your card is expired, if your bank has blocked the payment, or simply if the card details are not correct. So you may want to check all this, and possibly contact your bank to tell them to authorise the payment.
When a payment has failed, Stripe will retry again a few times, a few days later, so you don't need to do anything. However please note that after 14 days the Joplin Cloud account will be restricted until the payment is made - it will still be possible to download your data, but it will not be possible to upload more.
### How to manually pay an invoice when a payment has failed?
In case of a failed payment, Stripe will retry automatically. However you may also manually pay the invoice by following these steps:
Open your [profile](/users/me), then click on "[Manage subscription](/stripe/portal)". This will open your Joplin Cloud subscription page. Scroll down and, under "Invoice history", click on the invoice that has a failed payment. This will open a new page where you can pay the invoice.
## Team billing
Increasing or decreasing the number of members in a Joplin Cloud Teams account will result in prorated charges. The pro rata amount ensures that you don't get charged for a service you haven't used. For example, if you have 10 team members, for a total of 80 EUR, and add 5 more members (a total of 120 EUR per month) in the middle of the billing period, you will be charged like so:
- 80 - 40 = 40 EUR for the first part of the month
- 120 - 60 = 60 EUR for the second part of the month
So a total of 100 EUR for the first month. The second month will be the regular charge of 120 EUR.
## Cancellation policy
### Can my subscription be refunded?
We offer a 14 days trial when the subscription starts so that you can evaluate the service and potentially change your mind - if you cancel during that period you will not be charged. After that period of time, billing will start and it will not be possible to issue a refund. There will be no exception so please make sure you evaluate the service during the trial period. Consider setting a reminder at 14 days so that you remember to cancel on time if you are not satisfied. We will also send you an email as a reminder.
We offer the yearly subscription at a significant discount but with the understanding that you are able to commit for a year. If you are not sure, we recommend starting with a monthly subscription and switching to a yearly subscription later on.
Please note that it is however possible to cancel the subscription. Cancellation will be effective from the next billing cycle.
### How can I cancel my account?
Open your [profile](/users/me), then scroll down and click on "[Manage subscription](/stripe/portal)". Your subscription will be cancelled and you will not be charged on what would have been the next billing period. Please note that we do not cancel accounts over email as we cannot verify your identity, however we can provide assistance if there is an issue.
## Data retention
Disabled accounts on Joplin Cloud are automatically deleted 99 days after they have been disabled (*). A disabled account is one where the Stripe subscription has been cancelled either by the user or automatically (eg for unpaid invoices).
When an account is deleted, all notes, notebooks, tags, attachments, etc. are permanently deleted. User information, in particular emails and full names 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 wish to delete your data before this delay, simply delete all your notes and notebooks from the app, then synchronise with Joplin Cloud. You can use the [Victor plugin](https://joplinapp.org/plugins/plugin/org.joplinapp.plugins.Victor/) to make this easier. After synchronisation, the data will be removed from the server too. For safety reasons, we do not delete data on request.
(*) After 90 days, they are queued for deletion; 2 days later, they are removed from the system (no longer accessible); and 7 days later they are permanently deleted.
## Why was I charged?
If you have been charged and you didn't expect it the most likely explanation is that the trial ended, after 14 days, and an invoice was emitted. You may have cancelled the subscription after that date, but the invoice is still due. As with everything, cancelling a contract doesn't cancel existing invoices.
If you have been charged and you didn't expect it the most likely explanation is that the trial ended, after 14 days, and an invoice was emitted. You may have cancelled the subscription after that date, but the invoice is still due.
Likewise, if your subscription was renewed, an invoice was emitted. And if the subscription is cancelled afterwards, that invoice is still due and thus you will receive payment reminders.
In general, cancelling a subscription does not cancel existing invoices.
## Further information

View File

@@ -1,17 +1,17 @@
{{#showBetaMessage}}
<div class="notification is-warning">
<p class="block">This is a free beta account that will expire in <strong>{{betaExpiredDays}} day(s).</strong></p>
<p class="block">To continue using it after this date, please start the subscription by clicking on the button below. From the next screen, select either monthly or yearly payments and click "Buy now".</p>
<p class="block">Note that remaining days on the beta trial period will be transferred to the new subscription, so you will not lose any trial day. It means you do not need to wait till the last day to start the subscription.</p>
<div class="notification is-warning content">
<p>This is a free beta account that will expire in <strong>{{betaExpiredDays}} day(s).</strong></p>
<p>To continue using it after this date, please start the subscription by clicking on the button below. From the next screen, select either monthly or yearly payments and click "Buy now".</p>
<p>Note that remaining days on the beta trial period will be transferred to the new subscription, so you will not lose any trial day. It means you do not need to wait till the last day to start the subscription.</p>
<a href="{{{betaStartSubUrl}}}" class="button is-link">Start Subscription</a>
</div>
{{/showBetaMessage}}
<h2 class="title">Welcome to {{global.appName}}</h2>
<div class="block readable-block">
<p class="block">To start using {{global.appName}}, make sure to <a href="{{{global.joplinAppBaseUrl}}}/download">download one of the Joplin applications</a>, either for desktop or for your mobile phone.</p>
<p class="block">Once the app is installed, open the <strong>Configuration</strong> screen, then the <strong>Synchronisation</strong> section. {{{setupMessageHtml}}}<p>
<p class="block">Once it is setup {{global.appName}} allows you to synchronise your devices, to publish notes, or to collaborate on notebooks with other {{global.appName}} users.</p>
<div class="block readable-block content">
<p>To start using {{global.appName}}, make sure to <a href="{{{global.joplinAppBaseUrl}}}/download">download one of the Joplin applications</a>, either for desktop or for your mobile phone.</p>
<p>Once the app is installed, open the <strong>Configuration</strong> screen, then the <strong>Synchronisation</strong> section. {{{setupMessageHtml}}}<p>
<p>Once it is setup {{global.appName}} allows you to synchronise your devices, to publish notes, or to collaborate on notebooks with other {{global.appName}} users.</p>
</div>
<h2 class="title">Your account</h2>

View File

@@ -6,8 +6,8 @@
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.joplinAppBaseUrl}}}">
<img class="logo" src="{{{global.baseUrl}}}/images/JoplinLogo.png"/><span class="logo-text">Joplin</span>
<a class="navbar-item" href="{{logoUrl}}">
<img class="logo" src="{{logoSrc}}"/><span class="logo-text">{{logoTitle}}</span>
</a>
</div>
</nav>

View File

@@ -1,30 +1,60 @@
<section class="section login-box">
<h1 class="title">Login to {{global.appName}}</h1>
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
<div class="container block">
{{> errorBanner}}
<div class="columns">
<form action="{{{global.baseUrl}}}/login" method="POST" class="column">
<h2 class="title">Login to {{global.appName}}</h1>
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email"/>
<form action="{{{global.baseUrl}}}/login" method="POST" id="login-form">
<input type="hidden" name="applicationAuthId" value="{{applicationAuthId}}"/>
<div class="field">
<label class="label">Email</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="email" name="email" value="{{email}}"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password"/>
</div>
<div class="field">
<label class="label">Password</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="password" name="password" value="{{password}}"/>
</div>
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
<p class="control">
<button class="button" onclick="togglePasswordVisibility()" type="button">
<span class="icon is-small">
<i class="fas fa-eye"></i>
</span>
</button>
</p>
</div>
<div class="control">
<button class="button is-primary">Login</button>
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
</div>
{{#showMfaCodeInput}}
<div class="field">
<label class="label">Authentication code</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" inputmode="number" name="mfaCode" maxlength="6" placeholder="XXXXXX" autocomplete="off" required/>
</div>
</div>
<p class="help"><a href="{{{global.baseUrl}}}/login?showRecoveryCodeInput=1">Use a recovery code</a></p>
</div>
</form>
</div>
{{/showMfaCodeInput}}
{{#showRecoveryCodeInput}}
<div class="field">
<label class="label">Recovery Code</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="recoveryCode" maxlength="11" required/>
</div>
</div>
</div>
{{/showRecoveryCodeInput}}
<div class="control pt-3">
<button class="button is-primary">Login</button>
</div>
</form>
</div>
{{#samlEnabled}}
@@ -47,4 +77,16 @@
Or <a href="{{signupUrl}}">sign up</a> to create a new account.
</div>
{{/signupUrl}}
</section>
</section>
<script type="text/javascript">
function togglePasswordVisibility() {
let input = document.querySelector('input[name=password]');
if (input.type === 'password') {
input.type = 'text';
} else {
input.type = 'password';
}
}
</script>

View File

@@ -0,0 +1,60 @@
<h1 class="title">{{title}}</h1>
<form id="user_form" action="{{{postUrl}}}" method="POST" class="block">
<div class="block">
{{> errorBanner}}
{{{csrfTag}}}
<input type="hidden" name="id" value="{{user.id}}"/>
<input type="hidden" name="totpSecret" value="{{totpSecret}}"/>
{{#isMFAEnabled}}
<input type="hidden" name="formType" value="disableMFA"/>
<div class="block readable-block content">
<p>Please enter your password to disable multi-factor authentication.</p>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control mb-2">
<input class="input" type="password" name="password"/>
</div>
</div>
{{/isMFAEnabled}}
{{^isMFAEnabled}}
<input type="hidden" name="formType" value="enableMFA"/>
<div class="block readable-block content">
<p>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.</p>
</div>
<div class="block readable-block content">
<p>1. Install an authenticator app on your phone, such as <a href="https://getaegis.app/" target="_blank">Aegis for Android</a> or <a href="https://apps.apple.com/us/app/2fa-authenticator-2fas/id1217793794" target="_blank">2FAS for iOS</a>.</p>
</div>
<div class="block readable-block content">
<p>2. Open your authenticator app and choose the option to add a new entry.</p>
</div>
<div class="block readable-block content">
<p>3. Pair your authenticator app to your Joplin Cloud account by scanning the QR code below.</p>
</div>
<div class="block readable-block content ml-4">
<img class="ml-6" src="{{qrcodeImage}}" alt="qrcode-2fa" />
<p class="has-text-grey">Or copy the secret code: <code>{{totpSecret}}</code></p>
</div>
<div class="field block readable-block content">
<p>4. Verify the pairing was successful by entering two consecutive codes generated by your authenticator app.</label>
<div class="control mb-2">
<input type="text" name="confirmCode" class="input"/>
</div>
<div class="control">
<input type="text" name="confirmCode2" class="input"/>
</div>
</div>
<div class="block readable-block content notification is-warning">
<p>After enabling multi-factor authentication you will need an authentication code or a recovery code to log in.</p>
</div>
{{/isMFAEnabled}}
<div class="control block">
<input type="submit" name="postButton" class="button is-primary" value="{{buttonTitle}}" />
</div>
</div>
</form>

Some files were not shown because too many files have changed in this diff Show More