client+server: finish password reminders

This commit is contained in:
rr- 2016-04-06 17:56:34 +02:00
parent 1fb2f53914
commit 92dd958866
14 changed files with 246 additions and 56 deletions

View File

@ -20,7 +20,7 @@
<fieldset class='messages'></fieldset>
<fieldset class='buttons'>
<input type='submit' value='Log in'/>
<a>Forgot the password?</a>
<a href='/password-reset'>Forgot the password?</a>
</fieldset>
</form>
</div>

View File

@ -0,0 +1,20 @@
<div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1>
<form autocomplete='off'>
<fieldset class='input'>
<ul>
<li>
<label for='user-name'>User name or e-mail address</label>
<input id='user-name' name='user-name' type='text' autocomplete='off' required/>
</li>
</ul>
</fieldset>
<p><small>Proceeding will send an e-mail that contains a password reset
link. Clicking it is going to generate a new password for your account.
It is recommended to change that password to something else.</small></p>
<fieldset class='messages'></fieldset>
<fieldset class='buttons'>
<input type='submit' value='Proceed'/>
</fieldset>
</form>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import sqlalchemy
from szurubooru import errors, search
from szurubooru.util import auth, users
from szurubooru.api.base_api import BaseApi

View File

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

View File

@ -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']))

View File

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

View File

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

View File

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

View File

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