1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-30 10:36:35 +02:00

Server: Added support for resetting user password

This commit is contained in:
Laurent Cozic 2021-07-11 16:28:07 +01:00
parent 240cb35756
commit 62b619865a
14 changed files with 300 additions and 23 deletions

View File

@ -6,6 +6,7 @@ import Logger from '@joplin/lib/Logger';
import * as MarkdownIt from 'markdown-it';
import config from '../config';
import { NotificationKey } from '../models/NotificationModel';
import { profileUrl } from '../utils/urlUtils';
const logger = Logger.create('notificationHandler');
@ -20,7 +21,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
ctx.joplin.owner.id,
NotificationKey.ChangeAdminPassword,
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', ctx.joplin.models.user().profileUrl())
_('The default admin password is insecure and has not been changed! [Change it now](%s)', profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);

View File

@ -30,4 +30,8 @@ export default class EmailModel extends BaseModel<Email> {
return this.db(this.tableName).where('sent_time', '=', 0);
}
public async deleteAll() {
await this.db(this.tableName).delete();
}
}

View File

@ -1,5 +1,5 @@
import { Token, Uuid } from '../db';
import { ErrorForbidden } from '../utils/errors';
import { Token, User, Uuid } from '../db';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import BaseModel from './BaseModel';
@ -37,6 +37,22 @@ export default class TokenModel extends BaseModel<Token> {
.first();
}
private async byToken(tokenValue: string): Promise<Token> {
return this
.db(this.tableName)
.select(['user_id', 'value'])
.where('value', '=', tokenValue)
.first();
}
public async userFromToken(tokenValue: string): Promise<User> {
const token = await this.byToken(tokenValue);
if (!token) throw new ErrorNotFound(`No such token: ${tokenValue}`);
const user = this.models().user().load(token.user_id);
if (!user) throw new ErrorNotFound('No user associated with this token');
return user;
}
public async isValid(userId: string, tokenValue: string): Promise<boolean> {
const token = await this.byUser(userId, tokenValue);
return !!token;

View File

@ -8,6 +8,8 @@ import { formatBytes, GB, MB } from '../utils/bytes';
import { itemIsEncrypted } from '../utils/joplinUtils';
import { getMaxItemSize, getMaxTotalItemSize } from './utils/user';
import * as zxcvbn from 'zxcvbn';
import { confirmUrl, resetPasswordUrl } from '../utils/urlUtils';
import { checkRepeatPassword, CheckRepeatPasswordInput } from '../routes/index/users';
export enum AccountType {
Default = 0,
@ -226,14 +228,6 @@ export default class UserModel extends BaseModel<User> {
return !!s[0].length && !!s[1].length;
}
public profileUrl(): string {
return `${this.baseUrl}/users/me`;
}
public confirmUrl(userId: Uuid, validationToken: string): string {
return `${this.baseUrl}/users/${userId}/confirm?token=${validationToken}`;
}
public async delete(id: string): Promise<void> {
const shares = await this.models().share().sharesByUser(id);
@ -256,7 +250,7 @@ export default class UserModel extends BaseModel<User> {
public async sendAccountConfirmationEmail(user: User) {
const validationToken = await this.models().token().generate(user.id);
const confirmUrl = encodeURI(this.confirmUrl(user.id, validationToken));
const url = encodeURI(confirmUrl(user.id, validationToken));
await this.models().email().push({
sender_id: EmailSender.NoReply,
@ -264,10 +258,34 @@ export default class UserModel extends BaseModel<User> {
recipient_email: user.email,
recipient_name: user.full_name || '',
subject: `Please setup your ${this.appName} account`,
body: `Your new ${this.appName} account is almost ready to use!\n\nPlease click on the following link to finish setting up your account:\n\n[Complete your account](${confirmUrl})`,
body: `Your new ${this.appName} account is almost ready to use!\n\nPlease click on the following link to finish setting up your account:\n\n[Complete your account](${url})`,
});
}
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);
await this.models().email().push({
sender_id: EmailSender.NoReply,
recipient_id: user.id,
recipient_email: user.email,
recipient_name: user.full_name || '',
subject: `Reset your ${this.appName} password`,
body: `Somebody asked to reset your password on ${this.appName}\n\nIf it was not you, you can safely ignore this email.\n\nClick the following link to choose a new password:\n\n${url}`,
});
}
public async resetPassword(token: string, fields: CheckRepeatPasswordInput) {
checkRepeatPassword(fields, true);
const user = await this.models().token().userFromToken(token);
await this.models().user().save({ id: user.id, password: fields.password });
await this.models().token().deleteByValue(user.id, token);
}
private formatValues(user: User): User {
const output: User = { ...user };
if ('email' in output) output.email = user.email.trim().toLowerCase();

View File

@ -0,0 +1,63 @@
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, expectHttpError } from '../../utils/testing/testUtils';
import { execRequest } from '../../utils/testing/apiUtils';
import uuidgen from '../../utils/uuidgen';
import { ErrorNotFound } from '../../utils/errors';
describe('index/password', function() {
beforeAll(async () => {
await beforeAllDb('index/password');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should queue an email to reset password', async function() {
const { user } = await createUserAndSession(1);
await models().email().deleteAll();
await execRequest('', 'POST', 'password/forgot', { email: user.email });
const emails = await models().email().all();
expect(emails.length).toBe(1);
const match = emails[0].body.match(/(password\/reset)\?token=(.{32})/);
expect(match).toBeTruthy();
const newPassword = uuidgen();
await execRequest('', 'POST', match[1], {
password: newPassword,
password2: newPassword,
}, { query: { token: match[2] } });
const loggedInUser = await models().user().login(user.email, newPassword);
expect(loggedInUser.id).toBe(user.id);
});
test('should not queue an email for non-existing emails', async function() {
await createUserAndSession(1);
await models().email().deleteAll();
await execRequest('', 'POST', 'password/forgot', { email: 'justtryingtohackdontmindme@example.com' });
expect((await models().email().all()).length).toBe(0);
});
test('should not reset the password if the token is invalid', async function() {
const { user } = await createUserAndSession(1);
await models().email().deleteAll();
const newPassword = uuidgen();
await expectHttpError(async () => {
await execRequest('', 'POST', 'password/reset', {
password: newPassword,
password2: newPassword,
}, { query: { token: 'stilltryingtohack' } });
}, ErrorNotFound.httpCode);
const loggedInUser = await models().user().login(user.email, newPassword);
expect(loggedInUser).toBeFalsy();
});
});

View File

@ -0,0 +1,85 @@
import { RouteHandler, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { ErrorNotFound } from '../../utils/errors';
import defaultView from '../../utils/defaultView';
import { forgotPasswordUrl, resetPasswordUrl } from '../../utils/urlUtils';
import { bodyFields } from '../../utils/requestUtils';
import Logger from '@joplin/lib/Logger';
const logger = Logger.create('index/password');
const router: Router = new Router(RouteType.Web);
router.public = true;
interface ForgotPasswordFields {
email: string;
}
interface ResetPasswordFields {
password: string;
password2: string;
}
const subRoutes: Record<string, RouteHandler> = {
forgot: async (_path: SubPath, ctx: AppContext) => {
let confirmationMessage: string = '';
if (ctx.method === 'POST') {
const fields = await bodyFields<ForgotPasswordFields>(ctx.req);
try {
await ctx.joplin.models.user().sendResetPasswordEmail(fields.email || '');
} catch (error) {
logger.warn(`Could not send reset email for ${fields.email}`, error);
}
confirmationMessage = 'If we have an account that matches your email, you should receive an email with instructions on how to reset your password shortly.';
}
const view = defaultView('password/forgot', 'Reset password');
view.content = {
postUrl: forgotPasswordUrl(),
confirmationMessage,
};
return view;
},
reset: async (_path: SubPath, ctx: AppContext) => {
let successMessage: string = '';
let error: Error = null;
const token = ctx.query.token;
if (ctx.method === 'POST') {
const fields = await bodyFields<ResetPasswordFields>(ctx.req);
try {
await ctx.joplin.models.user().resetPassword(token, fields);
successMessage = 'Your password was successfully reset.';
} catch (e) {
error = e;
}
}
const view = defaultView('password/reset', 'Reset password');
view.content = {
postUrl: resetPasswordUrl(token),
error,
successMessage,
};
view.jsFiles.push('zxcvbn');
return view;
},
};
router.get('password/:id', async (path: SubPath, ctx: AppContext) => {
if (!subRoutes[path.id]) throw new ErrorNotFound(`Not found: password/${path.id}`);
return subRoutes[path.id](path, ctx);
});
router.post('password/:id', async (path: SubPath, ctx: AppContext) => {
if (!subRoutes[path.id]) throw new ErrorNotFound(`Not found: password/${path.id}`);
return subRoutes[path.id](path, ctx);
});
export default router;

View File

@ -6,7 +6,7 @@ import { bodyFields } from '../../utils/requestUtils';
import config from '../../config';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { checkPassword } from './users';
import { checkRepeatPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { ErrorForbidden } from '../../utils/errors';
@ -41,7 +41,7 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
try {
const formUser = await bodyFields<FormUser>(ctx.req);
const password = checkPassword(formUser, true);
const password = checkRepeatPassword(formUser, true);
const user = await ctx.joplin.models.user().save({
account_type: AccountType.Basic,

View File

@ -15,13 +15,14 @@ import uuidgen from '../../utils/uuidgen';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions } from '../../utils/views/select';
import { confirmUrl } from '../../utils/urlUtils';
interface CheckPasswordInput {
export interface CheckRepeatPasswordInput {
password: string;
password2: string;
}
export function checkPassword(fields: CheckPasswordInput, required: boolean): string {
export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
@ -57,7 +58,7 @@ function makeUser(isNew: boolean, fields: any): User {
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
if ('account_type' in fields) user.account_type = Number(fields.account_type);
const password = checkPassword(fields, false);
const password = checkRepeatPassword(fields, false);
if (password) user.password = password;
if (!isNew) user.id = fields.id;
@ -174,7 +175,7 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
user,
error,
token,
postUrl: ctx.joplin.models.user().confirmUrl(userId, token),
postUrl: confirmUrl(userId, token),
},
navbar: false,
};
@ -207,7 +208,7 @@ router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
const fields = await bodyFields<SetPasswordFormData>(ctx.req);
await ctx.joplin.models.token().checkToken(userId, fields.token);
const password = checkPassword(fields, true);
const password = checkRepeatPassword(fields, true);
await ctx.joplin.models.user().save({ id: userId, password, must_set_password: 0 });
await ctx.joplin.models.token().deleteByValue(userId, fields.token);

View File

@ -17,6 +17,7 @@ import indexItems from './index/items';
import indexLogin from './index/login';
import indexLogout from './index/logout';
import indexNotifications from './index/notifications';
import indexPassword from './index/password';
import indexSignup from './index/signup';
import indexShares from './index/shares';
import indexUsers from './index/users';
@ -41,6 +42,7 @@ const routes: Routers = {
'changes': indexChanges,
'home': indexHome,
'items': indexItems,
'password': indexPassword,
'login': indexLogin,
'logout': indexLogout,
'notifications': indexNotifications,

View File

@ -1,6 +1,6 @@
/* eslint-disable import/prefer-default-export */
import { URL } from 'url';
import config from '../config';
import { Uuid } from '../db';
export function setQueryParameters(url: string, query: any): string {
if (!query) return url;
@ -13,3 +13,20 @@ export function setQueryParameters(url: string, query: any): string {
return u.toString();
}
export function resetPasswordUrl(token: string): string {
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
}
export function forgotPasswordUrl(): string {
return `${config().baseUrl}/password/forgot`;
}
export function profileUrl(): string {
return `${config().baseUrl}/users/me`;
}
export function confirmUrl(userId: Uuid, validationToken: string): string {
return `${config().baseUrl}/users/${userId}/confirm?token=${validationToken}`;
}

View File

@ -16,6 +16,7 @@
<div class="control">
<input class="input" type="password" name="password"/>
</div>
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
</div>
<div class="control">
<button class="button is-primary">Login</button>

View File

@ -0,0 +1,24 @@
<section class="section">
<div class="container">
{{#confirmationMessage}}
<div class="notification is-info">
{{confirmationMessage}}
</div>
{{/confirmationMessage}}
<h1 class="title">Reset your password</h1>
<p class="block">Enter your email address, and we'll send you a password reset email.</p>
<form action="{{postUrl}}" method="POST">
<div class="field">
<label class="label">Email</label>
<div class="control">
<input name="email" class="input" type="email"/>
</div>
</div>
<div class="control">
<button class="button is-primary">Reset password</button>
</div>
</form>
</div>
</section>

View File

@ -0,0 +1,45 @@
<section class="section">
<div class="container">
{{#successMessage}}
<div class="notification is-info">
{{successMessage}}
</div>
{{/successMessage}}
{{#error}}
<div class="notification is-danger">
{{error.message}}
</div>
{{/error}}
<h1 class="title">Reset your password</h1>
<p class="block">Enter your new password below:</p>
<form action="{{postUrl}}" method="POST">
<div class="field">
<label class="label">Password</label>
<div class="control">
<input id="password" name="password" class="input" type="password"/>
</div>
<p id="password_strength" class="help"></p>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input name="password2" class="input" type="password"/>
</div>
</div>
<div class="control">
<button class="button is-primary">Reset password</button>
</div>
</form>
</div>
</section>
<script>
$(() => {
setupPasswordStrengthHandler();
});
</script>

View File

@ -2,7 +2,7 @@
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">
<a class="navbar-item" href="{{{global.baseUrl}}}">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a>
</div>