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 3eee00a4cde..e0f6a6804bf 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 fc6d61561c1..4ad92937f69 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 ), @@ -1565,6 +1570,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..0ce8b79febf 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 @@ -29,18 +28,24 @@ from galaxy.web.base.controller import (BaseUIController, UsesFormDefinitionsMixin) from galaxy.web.form_builder import build_select_field, CheckboxField from galaxy.web.framework.helpers import escape, grids, time_ago -from galaxy.web.framework.helpers import time_ago, grids -from markupsafe import escape log = logging.getLogger( __name__ ) -require_login_template = """ +REQUIRE_LOGIN_TEMPLATE = """

This %s has been configured such that only users who are logged in may use it.%s

""" +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 @@ -477,14 +482,14 @@ class User( BaseUIController, UsesFormDefinitionsMixin, CreatesUsersMixin, Creat create_account_str = " If you don't already have an account, you may create one." % \ web.url_for( controller='user', action='create', cntrller='user' ) if trans.webapp.name == 'galaxy': - header = require_login_template % ( "Galaxy instance", create_account_str ) + header = REQUIRE_LOGIN_TEMPLATE % ( "Galaxy instance", create_account_str ) else: - header = require_login_template % ( "Galaxy tool shed", create_account_str ) + header = REQUIRE_LOGIN_TEMPLATE % ( "Galaxy tool shed", create_account_str ) else: if trans.webapp.name == 'galaxy': - header = require_login_template % ( "Galaxy instance", "" ) + header = REQUIRE_LOGIN_TEMPLATE % ( "Galaxy instance", "" ) else: - header = require_login_template % ( "Galaxy tool shed", "" ) + header = REQUIRE_LOGIN_TEMPLATE % ( "Galaxy tool shed", "" ) return trans.fill_template( '/user/login.mako', email=email, header=header, @@ -1031,38 +1036,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,47 +1078,104 @@ 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 = '' + 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 = 'done' if kwd.get( 'reset_password_button', False ): + # Default to a non-userinfo-leaking response message + message = "Your reset request for %s has been received. Please check your email account for more instructions. If you do not receive an email shortly, please contact an administrator." % ( escape( email ) ) 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 = "" - 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 ) + prt = model.PasswordResetToken( reset_user ) + trans.sa_session.add( prt ) + trans.sa_session.flush() 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' ) ) except Exception, e: status = 'error' message = 'Failed to reset password: %s' % str( e ) log.exception( 'Unable to reset password.' ) - elif email is not None: - message = "The specified user does not exist." - status = 'error' - elif email is None: - email = "" return trans.fill_template( '/user/reset_password.mako', message=message, status=status ) 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..a905a5dc662 100644 --- a/templates/user/index.mako +++ b/templates/user/index.mako @@ -6,7 +6,8 @@