From dc06d32ec9e2706eb7a4aa54d31be52863cd1de0 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Tue, 13 Jan 2015 14:17:33 -0500 Subject: [PATCH 1/8] Initial password reset token feature. --- config/galaxy.ini.sample | 4 - lib/galaxy/config.py | 1 - lib/galaxy/model/__init__.py | 11 +- lib/galaxy/model/mapping.py | 9 ++ .../migrate/versions/0126_password_reset.py | 43 ++++++ lib/galaxy/webapps/galaxy/controllers/user.py | 123 +++++++++++------- templates/user/change_password.mako | 31 +++++ templates/user/index.mako | 3 +- templates/user/info.mako | 23 ---- 9 files changed, 174 insertions(+), 74 deletions(-) create mode 100644 lib/galaxy/model/migrate/versions/0126_password_reset.py create mode 100644 templates/user/change_password.mako diff --git a/config/galaxy.ini.sample b/config/galaxy.ini.sample index 45e6291c540..4bb608814e6 100644 --- a/config/galaxy.ini.sample +++ b/config/galaxy.ini.sample @@ -354,10 +354,6 @@ paste.app_factory = galaxy.web.buildapp:app_factory # If no message specified the warning box will not be shown. #registration_warning_message = Please register only one account - we provide this service free of charge and have limited computational resources. Multi-accounts are tracked and will be subjected to account termination and data deletion. -# When users opt to reset passwords, new ones are created, this option -# specifies the length of these passwords. -#reset_password_length = 15 - # -- Account activation diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index d0dff35bfae..94a40dc8e48 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -161,7 +161,6 @@ class Configuration( object ): self.job_walltime_delta = timedelta( 0, s, 0, 0, m, h ) self.admin_users = kwargs.get( "admin_users", "" ) self.admin_users_list = [u.strip() for u in self.admin_users.split(',') if u] - self.reset_password_length = int( kwargs.get('reset_password_length', '15') ) self.mailing_join_addr = kwargs.get('mailing_join_addr', 'galaxy-announce-join@bx.psu.edu') self.error_email_to = kwargs.get( 'error_email_to', None ) self.activation_email = kwargs.get( 'activation_email', None ) diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index fcb1587a3e2..2eb08bd76e3 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -18,6 +18,7 @@ import json import socket import time import numbers +from datetime import datetime, timedelta from uuid import UUID, uuid4 from string import Template from itertools import ifilter @@ -31,7 +32,7 @@ from galaxy.model.item_attrs import Dictifiable, UsesAnnotations import galaxy.model.orm.now from galaxy.security import get_permitted_actions from galaxy.util import is_multi_byte, nice_size, Params, restore_text, send_mail -from galaxy.util import ready_name_for_url +from galaxy.util import ready_name_for_url, unique_id from galaxy.util.bunch import Bunch from galaxy.util.hash_util import new_secure_hash from galaxy.util.directory_hash import directory_hash_id @@ -261,6 +262,14 @@ class User( object, Dictifiable ): environment = User.user_template_environment( user ) return Template( in_string ).safe_substitute( environment ) +class PasswordResetToken( object ): + def __init__( self, user, token=None): + if token: + self.token = token + else: + self.token = unique_id() + self.user = user + self.expiration_time = datetime.now() + timedelta(hours=24) class BaseJobMetric( object ): diff --git a/lib/galaxy/model/mapping.py b/lib/galaxy/model/mapping.py index 80920fbbd61..0af2b858e94 100644 --- a/lib/galaxy/model/mapping.py +++ b/lib/galaxy/model/mapping.py @@ -68,6 +68,11 @@ model.UserOpenID.table = Table( "galaxy_user_openid", metadata, Column( "provider", TrimmedString( 255 ) ), ) +model.PasswordResetToken.table = Table("password_reset_token", metadata, + Column( "token", String( 32 ), primary_key=True, unique=True, index=True ), + Column( "expiration_time", DateTime ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ) ) + model.History.table = Table( "history", metadata, Column( "id", Integer, primary_key=True), Column( "create_time", DateTime, default=now ), @@ -1564,6 +1569,10 @@ mapper( model.User, model.User.table, api_keys=relation( model.APIKeys, backref="user", order_by=desc( model.APIKeys.table.c.create_time ) ), ) ) +mapper( model.PasswordResetToken, model.PasswordResetToken.table, + properties=dict( user=relation( model.User, backref="reset_tokens") ) ) + + # Set up proxy so that this syntax is possible: # .preferences[pref_name] = pref_value model.User.preferences = association_proxy('_preferences', 'value', creator=model.UserPreference) diff --git a/lib/galaxy/model/migrate/versions/0126_password_reset.py b/lib/galaxy/model/migrate/versions/0126_password_reset.py new file mode 100644 index 00000000000..2a8277ce586 --- /dev/null +++ b/lib/galaxy/model/migrate/versions/0126_password_reset.py @@ -0,0 +1,43 @@ +""" +Migration script for the password reset table +""" + +from sqlalchemy import * +from sqlalchemy.orm import * +from migrate import * +from migrate.changeset import * +from galaxy.model.custom_types import * + +import datetime +now = datetime.datetime.utcnow + +import logging +log = logging.getLogger( __name__ ) + +metadata = MetaData() + +PasswordResetToken_table = Table("password_reset_token", metadata, + Column( "token", String( 32 ), primary_key=True, unique=True, index=True ), + Column( "expiration_time", DateTime ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True )) + + +def upgrade(migrate_engine): + metadata.bind = migrate_engine + print __doc__ + metadata.reflect() + try: + PasswordResetToken_table.create() + except Exception as e: + print str(e) + log.exception("Creating %s table failed: %s" % (PasswordResetToken_table.name, str( e ) ) ) + + +def downgrade(migrate_engine): + metadata.bind = migrate_engine + metadata.reflect() + try: + PasswordResetToken_table.drop() + except Exception as e: + print str(e) + log.exception("Dropping %s table failed: %s" % (PasswordResetToken_table.name, str( e ) ) ) diff --git a/lib/galaxy/webapps/galaxy/controllers/user.py b/lib/galaxy/webapps/galaxy/controllers/user.py index b2560ed8922..b1c15759f83 100644 --- a/lib/galaxy/webapps/galaxy/controllers/user.py +++ b/lib/galaxy/webapps/galaxy/controllers/user.py @@ -7,7 +7,6 @@ import logging import os import random import socket -import string import urllib from datetime import datetime, timedelta @@ -41,6 +40,13 @@ require_login_template = """

""" +PASSWORD_RESET_TEMPLATE = """ +To reset your Galaxy password for the instance at %s, use the following link: + + %s + +If you did not make this request, no action is necessary on your part, though +you may want to notify an administrator.""" class UserOpenIDGrid( grids.Grid ): use_panels = False @@ -1031,38 +1037,6 @@ class User( BaseUIController, UsesFormDefinitionsMixin, CreatesUsersMixin, Creat trans.sa_session.add( user ) trans.sa_session.flush() message = 'The login information has been updated with the changes.' - elif user and params.get( 'change_password_button', False ): - # Editing password. Do not sanitize passwords, so get from kwd - # and not params (which were sanitized). - password = kwd.get( 'password', '' ) - confirm = kwd.get( 'confirm', '' ) - ok = True - if not is_admin: - # If the current user is changing their own password, validate their current password - current = kwd.get( 'current', '' ) - if not trans.user.check_password( current ): - message = 'Invalid current password' - status = 'error' - ok = False - if ok: - # Validate the new password - message = validate_password( trans, password, confirm ) - if message: - status = 'error' - else: - # Save new password - user.set_password_cleartext( password ) - # Invalidate all other sessions - for other_galaxy_session in trans.sa_session.query( trans.app.model.GalaxySession ) \ - .filter( and_( trans.app.model.GalaxySession.table.c.user_id == trans.user.id, - trans.app.model.GalaxySession.table.c.is_valid is True, - trans.app.model.GalaxySession.table.c.id != trans.galaxy_session.id ) ): - other_galaxy_session.is_valid = False - trans.sa_session.add( other_galaxy_session ) - trans.sa_session.add( user ) - trans.sa_session.flush() - trans.log_event( "User change password" ) - message = 'The password has been changed and any other existing Galaxy sessions have been logged out (but jobs in histories in those sessions will not be interrupted).' elif user and params.get( 'edit_user_info_button', False ): # Edit user information - webapp MUST BE 'galaxy' user_type_fd_id = params.get( 'user_type_fd_id', 'none' ) @@ -1105,38 +1079,99 @@ class User( BaseUIController, UsesFormDefinitionsMixin, CreatesUsersMixin, Creat **kwd ) ) @web.expose - def reset_password( self, trans, email=None, **kwd ): + def change_password( self, trans, token=None, **kwd): + """ + Provides a form with which one can change their password. If token is + provided, don't require current password. + """ + status = None + message = None + user = None + if kwd.get( 'change_password_button', False ): + password = kwd.get( 'password', '' ) + confirm = kwd.get( 'confirm', '' ) + current = kwd.get( 'current', '' ) + token_result = None + if token: + # If a token was supplied, validate and set user + token_result = trans.sa_session.query( trans.app.model.PasswordResetToken ).get(token) + if token_result and token_result.expiration_time > datetime.now(): + user = token_result.user + else: + return trans.show_error_message("Invalid or expired password reset token, please request a new one.") + else: + # The user is changing their own password, validate their current password + if trans.user.check_password( current ): + user = trans.user + else: + message = 'Invalid current password' + status = 'error' + if user: + # Validate the new password + message = validate_password( trans, password, confirm ) + if message: + status = 'error' + else: + # Save new password + user.set_password_cleartext( password ) + # if we used a token, invalidate it and log the user in. + if token_result: + trans.handle_user_login(token_result.user) + token_result.expiration_time = datetime.now() + trans.sa_session.add(token_result) + # Invalidate all other sessions + for other_galaxy_session in trans.sa_session.query( trans.app.model.GalaxySession ) \ + .filter( and_( trans.app.model.GalaxySession.table.c.user_id == user.id, + trans.app.model.GalaxySession.table.c.is_valid is True, + trans.app.model.GalaxySession.table.c.id != trans.galaxy_session.id ) ): + other_galaxy_session.is_valid = False + trans.sa_session.add( other_galaxy_session ) + trans.sa_session.add( user ) + trans.sa_session.flush() + trans.log_event( "User change password" ) + return trans.show_ok_message('The password has been changed and any other existing Galaxy sessions have been logged out (but jobs in histories in those sessions will not be interrupted).') + return trans.fill_template( '/user/change_password.mako', + token=token, + status=status, + message=message ) + + + @web.expose + def reset_password( self, trans, email=None, token=None, **kwd ): """Reset the user's password. Send an email with the new password.""" if trans.app.config.smtp_server is None: - return trans.show_error_message( "Mail is not configured for this Galaxy instance. Please contact your local Galaxy administrator." ) - message = '' - status = 'done' + return trans.show_error_message( "Mail is not configured for this" + "Galaxy instance and password reset information cannot be sent." + "Please contact your local Galaxy administrator." ) + message = None + status = None if kwd.get( 'reset_password_button', False ): reset_user = trans.sa_session.query( trans.app.model.User ).filter( trans.app.model.User.table.c.email == email ).first() user = trans.get_user() if reset_user: if user and user.id != reset_user.id: + # This doesn't make any sense because all they have to do is log + # out and then try it again #TODO revisit why this exists. message = "You may only reset your own password." status = 'error' else: - chars = string.letters + string.digits - new_pass = "" + prt = model.PasswordResetToken(reset_user) + trans.sa_session.add(prt) + trans.sa_session.flush() reset_password_length = getattr( trans.app.config, "reset_password_length", 15 ) - for i in range( reset_password_length ): - new_pass = new_pass + random.choice( chars ) host = trans.request.host.split( ':' )[ 0 ] if host == 'localhost': host = socket.getfqdn() - body = 'Your password on %s has been reset to:\n\n %s\n' % ( host, new_pass ) + reset_url = url_for( controller='user', action="change_password", token=prt.token, qualified=True) + body = PASSWORD_RESET_TEMPLATE % ( host, reset_url, reset_url ) frm = 'galaxy-no-reply@' + host subject = 'Galaxy Password Reset' try: util.send_mail( frm, email, subject, body, trans.app.config ) - reset_user.set_password_cleartext( new_pass ) trans.sa_session.add( reset_user ) trans.sa_session.flush() trans.log_event( "User reset password: %s" % email ) - message = "Password has been reset and emailed to: %s. Click here to return to the login form." % ( escape( email ), web.url_for( controller='user', action='login', noredirect='true' ) ) + message = "An email has been sent to %s. Please refer to that email for more instructions." % ( escape( email ) ) except Exception, e: status = 'error' message = 'Failed to reset password: %s' % str( e ) diff --git a/templates/user/change_password.mako b/templates/user/change_password.mako new file mode 100644 index 00000000000..0f9debea659 --- /dev/null +++ b/templates/user/change_password.mako @@ -0,0 +1,31 @@ +<%inherit file="/base.mako"/> +<%namespace file="/message.mako" import="render_msg" /> + +%if message: + ${render_msg( message, status )} +%endif + +

+
+
Change Password
+ %if token: + + %elif not is_admin: +
+ + +
+ %endif +
+ + +
+
+ + +
+
+ +
+
+
diff --git a/templates/user/index.mako b/templates/user/index.mako index 019ad82adc1..1e76e76b578 100644 --- a/templates/user/index.mako +++ b/templates/user/index.mako @@ -6,7 +6,8 @@