diff --git a/client/html/login.hbs b/client/html/login.hbs
index 651ac92e..d6dd2629 100644
--- a/client/html/login.hbs
+++ b/client/html/login.hbs
@@ -20,7 +20,7 @@
diff --git a/client/js/controllers/auth_controller.js b/client/js/controllers/auth_controller.js
index 607a21ca..1a4565d4 100644
--- a/client/js/controllers/auth_controller.js
+++ b/client/js/controllers/auth_controller.js
@@ -5,10 +5,12 @@ const page = require('page');
const api = require('../api.js');
const topNavController = require('../controllers/top_nav_controller.js');
const LoginView = require('../views/login_view.js');
+const PasswordResetView = require('../views/password_reset_view.js');
class AuthController {
constructor() {
this.loginView = new LoginView();
+ this.passwordResetView = new PasswordResetView();
const auth = cookies.getJSON('auth');
if (auth && auth.user && auth.password) {
@@ -51,6 +53,43 @@ class AuthController {
page('/');
this.loginView.notifySuccess('Logged out');
}
+
+ passwordResetRoute() {
+ topNavController.activate('login');
+ this.passwordResetView.render({
+ proceed: nameOrEmail => {
+ api.logout();
+ cookies.remove('auth');
+ return new Promise((resolve, reject) => {
+ api.get('/password-reset/' + nameOrEmail)
+ .then(() => { resolve(); })
+ .catch(errorMessage => { reject(errorMessage); });
+ });
+ }});
+ }
+
+ passwordResetFinishRoute(name, token) {
+ api.logout();
+ cookies.remove('auth');
+ api.post('/password-reset/' + name, {token: token})
+ .then(response => {
+ const password = response.password;
+ api.login(name, password)
+ .then(() => {
+ cookies.set(
+ 'auth', {'user': name, 'password': password}, {});
+ page('/');
+ this.passwordResetView.notifySuccess(
+ 'New password: ' + password);
+ }).catch(errorMessage => {
+ page('/');
+ this.passwordResetView.notifyError(errorMessage);
+ });
+ }).catch(response => {
+ page('/');
+ this.passwordResetView.notifyError(response.description);
+ });
+ }
}
module.exports = new AuthController();
diff --git a/client/js/main.js b/client/js/main.js
index 6f386ffd..c221d3d9 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -44,6 +44,11 @@ page(
page('/history', () => { historyController.showHistoryRoute(); });
page('/tags', () => { tagsController.listTagsRoute(); });
page('/comments', () => { commentsController.listCommentsRoute(); });
+page(/\/password-reset\/([^:]+):([^:]+)$/,
+ (ctx, next) => {
+ authController.passwordResetFinishRoute(ctx.params[0], ctx.params[1]);
+ });
+page('/password-reset', () => { authController.passwordResetRoute(); });
page('/login', () => { authController.loginRoute(); });
page('/logout', () => { authController.logoutRoute(); });
diff --git a/client/js/views/password_reset_view.js b/client/js/views/password_reset_view.js
new file mode 100644
index 00000000..23b17ee0
--- /dev/null
+++ b/client/js/views/password_reset_view.js
@@ -0,0 +1,37 @@
+'use strict';
+
+const BaseView = require('./base_view.js');
+
+class PasswordResetView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('password-reset-template');
+ }
+
+ render(options) {
+ this.showView(this.template());
+ const form = this.contentHolder.querySelector('form');
+ this.decorateValidator(form);
+
+ const userNameOrEmailField = document.getElementById('user-name');
+
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ this.clearMessages();
+ this.disableForm(form);
+ options
+ .proceed(userNameOrEmailField.value)
+ .then(() => {
+ this.notifySuccess(
+ 'E-mail has been sent. To finish the procedure, ' +
+ 'please click the link it contains.');
+ })
+ .catch(errorMessage => {
+ this.enableForm(form);
+ this.notifyError(errorMessage);
+ });
+ });
+ }
+}
+
+module.exports = PasswordResetView;
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
index 291b2ace..4f79c4b4 100644
--- a/server/szurubooru/api/__init__.py
+++ b/server/szurubooru/api/__init__.py
@@ -1,4 +1,4 @@
''' Falcon-compatible API facades. '''
-from szurubooru.api.password_reminder_api import PasswordReminderApi
+from szurubooru.api.password_reset_api import PasswordResetApi
from szurubooru.api.user_api import UserListApi, UserDetailApi
diff --git a/server/szurubooru/api/password_reminder_api.py b/server/szurubooru/api/password_reset_api.py
similarity index 85%
rename from server/szurubooru/api/password_reminder_api.py
rename to server/szurubooru/api/password_reset_api.py
index da2870ef..45ef042d 100644
--- a/server/szurubooru/api/password_reminder_api.py
+++ b/server/szurubooru/api/password_reset_api.py
@@ -8,18 +8,18 @@ MAIL_BODY = \
'If you wish to proceed, click this link: {url}\n' \
'Otherwise, please ignore this email.'
-class PasswordReminderApi(BaseApi):
+class PasswordResetApi(BaseApi):
def get(self, context, user_name):
''' Send a mail with secure token to the correlated user. '''
- user = users.get_by_name(context.session, user_name)
+ user = users.get_by_name_or_email(context.session, user_name)
if not user:
raise errors.NotFoundError('User %r not found.' % user_name)
if not user.email:
raise errors.ValidationError(
'User %r hasn\'t supplied email. Cannot reset password.' % user_name)
token = auth.generate_authentication_token(user)
- url = '%s/password-reset/%s' % (
- config.config['basic']['base_url'].rstrip('/'), token)
+ url = '%s/password-reset/%s:%s' % (
+ config.config['basic']['base_url'].rstrip('/'), user.name, token)
mailer.send_mail(
'noreply@%s' % config.config['basic']['name'],
user.email,
@@ -29,7 +29,7 @@ class PasswordReminderApi(BaseApi):
def post(self, context, user_name):
''' Verify token from mail, generate a new password and return it. '''
- user = users.get_by_name(context.session, user_name)
+ user = users.get_by_name_or_email(context.session, user_name)
if not user:
raise errors.NotFoundError('User %r not found.' % user_name)
good_token = auth.generate_authentication_token(user)
diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py
index b0971e33..a36110b1 100644
--- a/server/szurubooru/api/user_api.py
+++ b/server/szurubooru/api/user_api.py
@@ -1,4 +1,3 @@
-import sqlalchemy
from szurubooru import errors, search
from szurubooru.util import auth, users
from szurubooru.api.base_api import BaseApi
diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py
index ede59c57..1d33e08b 100644
--- a/server/szurubooru/app.py
+++ b/server/szurubooru/app.py
@@ -64,7 +64,7 @@ def create_app():
user_list_api = api.UserListApi()
user_detail_api = api.UserDetailApi()
- password_reminder_api = api.PasswordReminderApi()
+ password_reset_api = api.PasswordResetApi()
app.add_error_handler(errors.AuthError, _on_auth_error)
app.add_error_handler(errors.IntegrityError, _on_integrity_error)
@@ -74,6 +74,6 @@ def create_app():
app.add_route('/users/', user_list_api)
app.add_route('/user/{user_name}', user_detail_api)
- app.add_route('/password_reminder/{user_name}', password_reminder_api)
+ app.add_route('/password-reset/{user_name}', password_reset_api)
return app
diff --git a/server/szurubooru/tests/api/test_password_reset_api.py b/server/szurubooru/tests/api/test_password_reset_api.py
new file mode 100644
index 00000000..1e7a43d0
--- /dev/null
+++ b/server/szurubooru/tests/api/test_password_reset_api.py
@@ -0,0 +1,77 @@
+from datetime import datetime
+from unittest import mock
+from szurubooru import api, db, errors, config
+from szurubooru.util import auth, misc, mailer
+from szurubooru.tests.database_test_case import DatabaseTestCase
+from szurubooru.tests.api import util
+
+class TestPasswordReset(DatabaseTestCase):
+ def setUp(self):
+ super().setUp()
+ config_mock = {
+ 'basic': {
+ 'secret': 'x',
+ 'base_url': 'http://example.com/',
+ 'name': 'Test instance',
+ },
+ }
+ self.old_config = config.config
+ config.config = config_mock
+ self.context = misc.dotdict()
+ self.context.session = self.session
+ self.context.request = {}
+ self.context.user = db.User()
+ self.api = api.PasswordResetApi()
+
+ def test_reset_non_existing(self):
+ self.assertRaises(errors.NotFoundError, self.api.get, self.context, 'u1')
+
+ def test_reset_without_email(self):
+ user = util.mock_user('u1', 'regular_user')
+ user.email = None
+ self.session.add(user)
+ self.assertRaises(errors.ValidationError, self.api.get, self.context, 'u1')
+
+ def test_reset_sending_email(self):
+ user = util.mock_user('u1', 'regular_user')
+ user.email = 'user@example.com'
+ self.session.add(user)
+ for getter in ['u1', 'user@example.com']:
+ with mock.MagicMock() as mock_method:
+ mailer.send_mail = mock_method
+ self.assertEqual({}, self.api.get(self.context, getter))
+ mock_method.assert_called_once_with(
+ 'noreply@Test instance',
+ 'user@example.com',
+ 'Password reset for Test instance',
+ 'You (or someone else) requested to reset your password ' +
+ 'on Test instance.\nIf you wish to proceed, click this l' +
+ 'ink: http://example.com/password-reset/u1:4ac0be176fb36' +
+ '4f13ee6b634c43220e2\nOtherwise, please ignore this email.')
+
+ def test_confirmation_non_existing(self):
+ self.assertRaises(errors.NotFoundError, self.api.post, self.context, 'u1')
+
+ def test_confirmation_no_token(self):
+ user = util.mock_user('u1', 'regular_user')
+ user.email = 'user@example.com'
+ self.session.add(user)
+ self.context.request = {}
+ self.assertRaises(errors.ValidationError, self.api.post, self.context, 'u1')
+
+ def test_confirmation_bad_token(self):
+ user = util.mock_user('u1', 'regular_user')
+ user.email = 'user@example.com'
+ self.session.add(user)
+ self.context.request = {'token': 'bad'}
+ self.assertRaises(errors.ValidationError, self.api.post, self.context, 'u1')
+
+ def test_confirmation_good_token(self):
+ user = util.mock_user('u1', 'regular_user')
+ user.email = 'user@example.com'
+ old_hash = user.password_hash
+ self.session.add(user)
+ self.context.request = {'token': '4ac0be176fb364f13ee6b634c43220e2'}
+ result = self.api.post(self.context, 'u1')
+ self.assertNotEqual(user.password_hash, old_hash)
+ self.assertTrue(auth.is_valid_password(user, result['password']))
diff --git a/server/szurubooru/tests/api/test_user_api.py b/server/szurubooru/tests/api/test_user_api.py
index 362a0ada..5d106c20 100644
--- a/server/szurubooru/tests/api/test_user_api.py
+++ b/server/szurubooru/tests/api/test_user_api.py
@@ -2,30 +2,7 @@ from datetime import datetime
from szurubooru import api, db, errors, config
from szurubooru.util import auth, misc
from szurubooru.tests.database_test_case import DatabaseTestCase
-
-def _create_user(name, rank='admin'):
- user = db.User()
- user.name = name
- user.password = 'dummy'
- user.password_salt = 'dummy'
- user.password_hash = 'dummy'
- user.email = 'dummy'
- user.access_rank = rank
- user.creation_time = datetime(1997, 1, 1)
- user.avatar_style = db.User.AVATAR_GRAVATAR
- return user
-
-def _mock_params(context, params):
- def get_param_as_string(key, default=None):
- if key not in params:
- return default
- return params[key]
- def get_param_as_int(key, default=None):
- if key not in params:
- return default
- return int(params[key])
- context.get_param_as_string = get_param_as_string
- context.get_param_as_int = get_param_as_int
+from szurubooru.tests.api import util
class TestRetrievingUsers(DatabaseTestCase):
def setUp(self):
@@ -48,10 +25,10 @@ class TestRetrievingUsers(DatabaseTestCase):
self.context.user = db.User()
def test_retrieving_multiple(self):
- user1 = _create_user('u1', 'mod')
- user2 = _create_user('u2', 'mod')
+ user1 = util.mock_user('u1', 'mod')
+ user2 = util.mock_user('u2', 'mod')
self.session.add_all([user1, user2])
- _mock_params(self.context, {'query': '', 'page': 1})
+ util.mock_params(self.context, {'query': '', 'page': 1})
self.context.user.access_rank = 'regular_user'
api_ = api.UserListApi()
result = api_.get(self.context)
@@ -63,15 +40,15 @@ class TestRetrievingUsers(DatabaseTestCase):
def test_retrieving_multiple_without_privileges(self):
self.context.user.access_rank = 'anonymous'
- _mock_params(self.context, {'query': '', 'page': 1})
+ util.mock_params(self.context, {'query': '', 'page': 1})
api_ = api.UserListApi()
self.assertRaises(errors.AuthError, api_.get, self.context)
def test_retrieving_single(self):
- user = _create_user('u1', 'regular_user')
+ user = util.mock_user('u1', 'regular_user')
self.session.add(user)
self.context.user.access_rank = 'regular_user'
- _mock_params(self.context, {'query': '', 'page': 1})
+ util.mock_params(self.context, {'query': '', 'page': 1})
api_ = api.UserDetailApi()
result = api_.get(self.context, 'u1')
self.assertEqual(result['user']['id'], user.user_id)
@@ -83,13 +60,13 @@ class TestRetrievingUsers(DatabaseTestCase):
def test_retrieving_non_existing(self):
self.context.user.access_rank = 'regular_user'
- _mock_params(self.context, {'query': '', 'page': 1})
+ util.mock_params(self.context, {'query': '', 'page': 1})
api_ = api.UserDetailApi()
self.assertRaises(errors.NotFoundError, api_.get, self.context, '-')
def test_retrieving_single_without_privileges(self):
self.context.user.access_rank = 'anonymous'
- _mock_params(self.context, {'query': '', 'page': 1})
+ util.mock_params(self.context, {'query': '', 'page': 1})
api_ = api.UserDetailApi()
self.assertRaises(errors.AuthError, api_.get, self.context, '-')
@@ -200,7 +177,7 @@ class TestUpdatingUser(DatabaseTestCase):
config.config = self.old_config
def test_update_changing_nothing(self):
- admin_user = _create_user('u1', 'admin')
+ admin_user = util.mock_user('u1', 'admin')
self.session.add(admin_user)
self.context.user = admin_user
self.api.put(self.context, 'u1')
@@ -210,13 +187,13 @@ class TestUpdatingUser(DatabaseTestCase):
self.assertEqual(admin_user.access_rank, 'admin')
def test_updating_non_existing_user(self):
- admin_user = _create_user('u1', 'admin')
+ admin_user = util.mock_user('u1', 'admin')
self.session.add(admin_user)
self.context.user = admin_user
self.assertRaises(errors.NotFoundError, self.api.put, self.context, 'u2')
def test_admin_updating_everything_for_themselves(self):
- admin_user = _create_user('u1', 'admin')
+ admin_user = util.mock_user('u1', 'admin')
self.session.add(admin_user)
self.context.user = admin_user
self.context.request = {
@@ -234,7 +211,7 @@ class TestUpdatingUser(DatabaseTestCase):
self.assertFalse(auth.is_valid_password(admin_user, 'invalid'))
def test_removing_email(self):
- admin_user = _create_user('u1', 'admin')
+ admin_user = util.mock_user('u1', 'admin')
self.session.add(admin_user)
self.context.user = admin_user
self.context.request = {'email': ''}
@@ -243,7 +220,7 @@ class TestUpdatingUser(DatabaseTestCase):
self.assertEqual(admin_user.email, None)
def test_invalid_inputs(self):
- admin_user = _create_user('u1', 'admin')
+ admin_user = util.mock_user('u1', 'admin')
self.session.add(admin_user)
self.context.user = admin_user
self.context.request = {'name': '.'}
@@ -260,8 +237,8 @@ class TestUpdatingUser(DatabaseTestCase):
errors.ValidationError, self.api.put, self.context, 'u1')
def test_user_trying_to_update_someone_else(self):
- user1 = _create_user('u1', 'regular_user')
- user2 = _create_user('u2', 'regular_user')
+ user1 = util.mock_user('u1', 'regular_user')
+ user2 = util.mock_user('u2', 'regular_user')
self.session.add_all([user1, user2])
self.context.user = user1
for request in [
@@ -274,26 +251,28 @@ class TestUpdatingUser(DatabaseTestCase):
errors.AuthError, self.api.put, self.context, user2.name)
def test_user_trying_to_become_someone_else(self):
- user1 = _create_user('me', 'regular_user')
- user2 = _create_user('her', 'regular_user')
+ user1 = util.mock_user('me', 'regular_user')
+ user2 = util.mock_user('her', 'regular_user')
self.session.add_all([user1, user2])
self.context.user = user1
self.context.request = {'name': 'her'}
self.assertRaises(
errors.IntegrityError, self.api.put, self.context, 'me')
+ self.session.rollback()
def test_user_trying_to_become_someone_else_insensitive(self):
- user1 = _create_user('me', 'regular_user')
- user2 = _create_user('her', 'regular_user')
+ user1 = util.mock_user('me', 'regular_user')
+ user2 = util.mock_user('her', 'regular_user')
self.session.add_all([user1, user2])
self.context.user = user1
self.context.request = {'name': 'HER'}
self.assertRaises(
errors.IntegrityError, self.api.put, self.context, 'me')
+ self.session.rollback()
def test_mods_trying_to_become_admin(self):
- user1 = _create_user('u1', 'mod')
- user2 = _create_user('u2', 'mod')
+ user1 = util.mock_user('u1', 'mod')
+ user2 = util.mock_user('u2', 'mod')
self.session.add_all([user1, user2])
self.context.user = user1
self.context.request = {'accessRank': 'admin'}
diff --git a/server/szurubooru/tests/api/util.py b/server/szurubooru/tests/api/util.py
new file mode 100644
index 00000000..31e1d20a
--- /dev/null
+++ b/server/szurubooru/tests/api/util.py
@@ -0,0 +1,26 @@
+from datetime import datetime
+from szurubooru import db
+
+def mock_user(name, rank='admin'):
+ user = db.User()
+ user.name = name
+ user.password = 'dummy'
+ user.password_salt = 'dummy'
+ user.password_hash = 'dummy'
+ user.email = 'dummy'
+ user.access_rank = rank
+ user.creation_time = datetime(1997, 1, 1)
+ user.avatar_style = db.User.AVATAR_GRAVATAR
+ return user
+
+def mock_params(context, params):
+ def get_param_as_string(key, default=None):
+ if key not in params:
+ return default
+ return params[key]
+ def get_param_as_int(key, default=None):
+ if key not in params:
+ return default
+ return int(params[key])
+ context.get_param_as_string = get_param_as_string
+ context.get_param_as_int = get_param_as_int
diff --git a/server/szurubooru/util/auth.py b/server/szurubooru/util/auth.py
index 741e58b2..1283301f 100644
--- a/server/szurubooru/util/auth.py
+++ b/server/szurubooru/util/auth.py
@@ -53,7 +53,7 @@ def verify_privilege(user, privilege_name):
def generate_authentication_token(user):
''' Generate nonguessable challenge (e.g. links in password reminder). '''
- digest = hashlib.sha256()
+ digest = hashlib.md5()
digest.update(config.config['basic']['secret'].encode('utf8'))
digest.update(user.password_salt.encode('utf8'))
return digest.hexdigest()
diff --git a/server/szurubooru/util/users.py b/server/szurubooru/util/users.py
index edaa3ce5..0455eb77 100644
--- a/server/szurubooru/util/users.py
+++ b/server/szurubooru/util/users.py
@@ -68,3 +68,11 @@ def get_by_name(session, name):
return session.query(db.User) \
.filter(func.lower(db.User.name) == func.lower(name)) \
.first()
+
+def get_by_name_or_email(session, name_or_email):
+ ''' Retrieve an user by its name or email. '''
+ return session.query(db.User) \
+ .filter(
+ (func.lower(db.User.name) == func.lower(name_or_email))
+ | (func.lower(db.User.email) == func.lower(name_or_email))) \
+ .first()