Skip to content

Commit

Permalink
Chapter 8: Email address changes (8h)
Browse files Browse the repository at this point in the history
  • Loading branch information
miguelgrinberg committed Jun 9, 2019
1 parent 4711144 commit 809d39f
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 1 deletion.
11 changes: 11 additions & 0 deletions app/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,14 @@ class PasswordResetForm(FlaskForm):
DataRequired(), EqualTo('password2', message='Passwords must match')])
password2 = PasswordField('Confirm password', validators=[DataRequired()])
submit = SubmitField('Reset Password')


class ChangeEmailForm(FlaskForm):
email = StringField('New Email', validators=[DataRequired(), Length(1, 64),
Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Update Email Address')

def validate_email(self, field):
if User.query.filter_by(email=field.data.lower()).first():
raise ValidationError('Email already registered.')
32 changes: 31 additions & 1 deletion app/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from ..models import User
from ..email import send_email
from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
PasswordResetRequestForm, PasswordResetForm
PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm


@auth.before_app_request
Expand Down Expand Up @@ -136,3 +136,33 @@ def password_reset(token):
else:
return redirect(url_for('main.index'))
return render_template('auth/reset_password.html', form=form)


@auth.route('/change_email', methods=['GET', 'POST'])
@login_required
def change_email_request():
form = ChangeEmailForm()
if form.validate_on_submit():
if current_user.verify_password(form.password.data):
new_email = form.email.data.lower()
token = current_user.generate_email_change_token(new_email)
send_email(new_email, 'Confirm your email address',
'auth/email/change_email',
user=current_user, token=token)
flash('An email with instructions to confirm your new email '
'address has been sent to you.')
return redirect(url_for('main.index'))
else:
flash('Invalid email or password.')
return render_template("auth/change_email.html", form=form)


@auth.route('/change_email/<token>')
@login_required
def change_email(token):
if current_user.change_email(token):
db.session.commit()
flash('Your email address has been updated.')
else:
flash('Invalid request.')
return redirect(url_for('main.index'))
22 changes: 22 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ def reset_password(token, new_password):
db.session.add(user)
return True

def generate_email_change_token(self, new_email, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps(
{'change_email': self.id, 'new_email': new_email}).decode('utf-8')

def change_email(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
if data.get('change_email') != self.id:
return False
new_email = data.get('new_email')
if new_email is None:
return False
if self.query.filter_by(email=new_email).first() is not None:
return False
self.email = new_email
db.session.add(self)
return True

def __repr__(self):
return '<User %r>' % self.username

Expand Down
13 changes: 13 additions & 0 deletions app/templates/auth/change_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Change Email Address{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Change Your Email Address</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
7 changes: 7 additions & 0 deletions app/templates/auth/email/change_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p>Dear {{ user.username }},</p>
<p>To confirm your new email address <a href="{{ url_for('auth.change_email', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.change_email', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>
11 changes: 11 additions & 0 deletions app/templates/auth/email/change_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Dear {{ user.username }},

To confirm your new email address click on the following link:

{{ url_for('auth.change_email', token=token, _external=True) }}

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
1 change: 1 addition & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Account <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li>
<li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
</ul>
</li>
Expand Down
28 changes: 28 additions & 0 deletions tests/test_user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,31 @@ def test_invalid_reset_token(self):
token = u.generate_reset_token()
self.assertFalse(User.reset_password(token + 'a', 'horse'))
self.assertTrue(u.verify_password('cat'))

def test_valid_email_change_token(self):
u = User(email='[email protected]', password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_email_change_token('[email protected]')
self.assertTrue(u.change_email(token))
self.assertTrue(u.email == '[email protected]')

def test_invalid_email_change_token(self):
u1 = User(email='[email protected]', password='cat')
u2 = User(email='[email protected]', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
token = u1.generate_email_change_token('[email protected]')
self.assertFalse(u2.change_email(token))
self.assertTrue(u2.email == '[email protected]')

def test_duplicate_email_change_token(self):
u1 = User(email='[email protected]', password='cat')
u2 = User(email='[email protected]', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
token = u2.generate_email_change_token('[email protected]')
self.assertFalse(u2.change_email(token))
self.assertTrue(u2.email == '[email protected]')

4 comments on commit 809d39f

@positronn
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, I'm sorry if my question is very obvious or dumb but,
Why do these tests pass when I clone the repo, go to this commit and do the unit tests if
there is no data-dev.sqlite file?
When I do this tests with my own data-dev.sqlite I have to delete the file,
upgrade the database to the last version and do the tests, because if I do several tests in a row
I keep having this error of violating the unique constraint on e-mail column for the users table, namely:

sqlalchemy.exc.InvalidRequestError: This Session's transaction has been rolled back due to a previous exception during flush. To begin a new transaction with this Session, first issue Session.rollback(). Original exception was: (sqlite3.IntegrityError) UNIQUE constraint failed: users.email
[SQL: INSERT INTO users (email, username, password_hash, role_id, confirmed) VALUES (?, ?, ?, ?, ?)]
[parameters: ('[email protected]', None, 'pbkdf2:sha256:150000$ykEI5rIq$93b554b632c1c2de64e333edd5a93110662ed9c17d6e71897773eb82f4d3292a', None, 0)]

Thanks.

@miguelgrinberg
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Cmarsa the data-dev.sqlite database is used by your development web server, not by the tests. If this database is used when you run your tests, then you have an issue with your test configuration. The tests should override the DATABASE_URL setting to install their own database.

@positronn
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I forgot to write the setUp and tearDown methods on the UserModelTestCase class.
Thank you.

@ezebunandu
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I fell prey to the setUp and tearDown issue a few weeks ago as well. Took me a couple of days to figure it out.

Please sign in to comment.