Skip to content

Commit dbb9914

Browse files
authored
Merge pull request #91 from GeoinformationSystems/feature/email_change_confirmation
Email change must be confirmed
2 parents 7f0f683 + 4a6ac55 commit dbb9914

File tree

8 files changed

+226
-26
lines changed

8 files changed

+226
-26
lines changed

optimap/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ OPTIMAP_DEBUG=False
33
OPTIMAP_CACHE=default
44
OPTIMAP_CACHE_SECONDS=3600
55

6+
OPTIMAP_BASE_URL=...
7+
68
OPTIMAP_DB_HOST=localhost
79
OPTIMAP_DB_NAME=optimap
810
OPTIMAP_DB_PASS=...

optimap/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@
184184
EMAIL_HOST_PASSWORD = env('OPTIMAP_EMAIL_HOST_PASSWORD', default='')
185185
EMAIL_USE_TLS = env('OPTIMAP_EMAIL_USE_TLS', default=False)
186186
EMAIL_USE_SSL = env('OPTIMAP_EMAIL_USE_SSL', default=False)
187+
BASE_URL = env("OPTIMAP_BASE_URL", default="http://localhost:8000")
187188
EMAIL_IMAP_SENT_FOLDER = env('OPTIMAP_EMAIL_IMAP_SENT_FOLDER', default='')
188189
EMAIL_SEND_DELAY = 2
189190

publications/serializers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
User = get_user_model()
77

88
from publications.models import Publication,Subscription
9+
from django.contrib.auth import get_user_model
10+
User = get_user_model()
911

1012
class PublicationSerializer(serializers.GeoFeatureModelSerializer):
1113
"""publication GeoJSON serializer."""
@@ -26,6 +28,19 @@ class Meta:
2628
geo_field = "search_area"
2729
auto_bbox = True
2830

31+
class EmailChangeSerializer(serializers.ModelSerializer):
32+
"""Handles email change requests."""
33+
34+
class Meta:
35+
model = User
36+
fields = ['email']
37+
38+
def validate_email(self, value):
39+
"""Ensure the new email is not already in use."""
40+
if User.objects.filter(email=value).exists():
41+
raise serializers.ValidationError("This email is already registered.")
42+
return value
43+
2944
class UserSerializer(serializers.ModelSerializer):
3045
class Meta:
3146
model = User

publications/templates/privacy.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<h1 class="py-2">Privacy policy</h1>
1010

1111
<p class="lead">OPTIMAP does not collect or log your personal data. We only store your email to identify your user
12-
account and nothing else.</p>
12+
account and minimal metadata, such as the date of registration and the last login, to identify and handle stale accounts.</p>
1313

1414
<p>The address of the website is {{ site | urlize }}.</p>
1515

publications/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@
3333
path("confirm-delete/<str:token>/", views.confirm_account_deletion, name="confirm_delete"),
3434
path("finalize-delete/", views.finalize_account_deletion, name="finalize_delete"),
3535
path("changeuser/", views.change_useremail, name="changeuser"),
36+
path("confirm-email/<str:token>/<str:email_new>/", views.confirm_email_change, name="confirm-email-change"),
3637
]

publications/views.py

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535

3636
LOGIN_TOKEN_LENGTH = 32
3737
LOGIN_TOKEN_TIMEOUT_SECONDS = 10 * 60
38+
EMAIL_CONFIRMATION_TIMEOUT_SECONDS = 10 * 60
3839
ACCOUNT_DELETE_TOKEN_TIMEOUT_SECONDS = 10 * 60
39-
USER_DELETE_TOKEN_PREFIX = "user_delete_token"
40+
USER_DELETE_TOKEN_PREFIX = "user_delete_token"
4041

4142
def main(request):
4243
return render(request,"main.html")
@@ -113,6 +114,11 @@ def data(request):
113114
def Confirmationlogin(request):
114115
return render(request,'confirmation_login.html')
115116

117+
def login_user(request, user):
118+
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
119+
#user.last_login = now # this should be set by Django's UserManager
120+
user.save()
121+
116122
@require_GET
117123
def authenticate_via_magic_link(request: HttpRequest, token: str):
118124
email = cache.get(token)
@@ -145,7 +151,7 @@ def authenticate_via_magic_link(request: HttpRequest, token: str):
145151
user = User.objects.create_user(username=email, email=email)
146152
is_new = True
147153

148-
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
154+
login_user(request, user)
149155

150156
cache.delete(token)
151157
return render(request, "confirmation_login.html", {
@@ -209,47 +215,112 @@ def delete_account(request):
209215
messages.info(request, 'Your account has been successfully deleted.')
210216
return render(request, 'deleteaccount.html')
211217

218+
@login_required
212219
def change_useremail(request):
213220
email_new = request.POST.get('email_new', False)
214221
currentuser = request.user
215222
email_old = currentuser.email
216-
logger.info('User requests to change email from %s to %s', email_old, email_new)
217223

218-
if is_email_blocked(email):
219-
logger.warning('Attempted login with blocked email: %s', email)
224+
if is_email_blocked(email_new):
225+
logger.warning('Attempted login with blocked email: %s', email_new)
220226
return render(request, "error.html", {
221227
'error': {
222228
'class': 'danger',
223229
'title': 'Login failed!',
224230
'text': 'You attempted to change your email to an address that is blocked. Please contact support for assistance.'
225231
}
226232
})
227-
228-
if email_new:
229-
currentuser.email = email_new
230-
currentuser.username = email_new
231-
currentuser.save()
232-
#send email
233-
subject = 'Change Email'
234-
link = get_login_link(request, email_new)
235-
message =f"""Hello {email_new},
236-
237-
You requested to change your email address from {email_old} to {email_new}.
233+
messages.error(request, "Invalid email change request.")
234+
return render(request, 'changeuser.html')
235+
236+
if not email_new or email_new == email_old:
237+
messages.error(request, "Invalid email change request.")
238+
return render(request, 'changeuser.html')
239+
240+
if User.objects.filter(email=email_new).exists():
241+
messages.error(request, "This email is already in use.")
242+
return render(request, 'changeuser.html')
243+
244+
token = secrets.token_urlsafe(32)
245+
cache.set(
246+
f"email_confirmation_{email_new}",
247+
{"token": token, "old_email": request.user.email},
248+
timeout=EMAIL_CONFIRMATION_TIMEOUT_SECONDS,
249+
)
250+
251+
confirm_url = request.build_absolute_uri(
252+
reverse("optimap:confirm-email-change", args=[token, email_new])
253+
)
254+
255+
subject = 'Confirm Your Email Change'
256+
message = f"""Hello,
257+
258+
You requested to change your email from {email_old} to {email_new}.
238259
Please confirm the new email by clicking on this link:
239260
240-
{link}
261+
{confirm_url}
262+
263+
This link will expire in 10 minutes.
241264
242265
Thank you for using OPTIMAP!
243266
"""
244-
send_mail(
245-
subject,
246-
message,
247-
from_email = settings.EMAIL_HOST_USER,
248-
recipient_list=[email_new]
249-
)
250-
logout(request)
267+
send_mail(subject, message, settings.EMAIL_HOST_USER, [email_new])
268+
messages.info(request, "A confirmation email has been sent.")
269+
logout(request)
270+
271+
return render(request, 'changeuser.html')
272+
273+
def confirm_email_change(request, token, email_new):
274+
cached_data = cache.get(f"email_confirmation_{email_new}")
275+
276+
if not cached_data:
277+
messages.error(request, "Invalid or expired confirmation link.")
278+
return HttpResponseRedirect("/")
279+
280+
if isinstance(cached_data, str):
281+
messages.error(request, "Cache error: Expected dictionary, got string.")
282+
return HttpResponseRedirect("/")
283+
284+
stored_token = cached_data.get("token")
285+
old_email = cached_data.get("old_email")
286+
287+
if stored_token != token:
288+
messages.error(request, "Invalid or expired confirmation link.")
289+
return HttpResponseRedirect("/")
290+
291+
user = User.objects.filter(email=old_email).first()
292+
293+
if not user:
294+
messages.error(request, "User not found.")
295+
return HttpResponseRedirect("/")
296+
297+
user.email = email_new
298+
user.username = email_new
299+
user.save()
300+
301+
contactURL = f"{settings.BASE_URL}/contact"
302+
notify_subject = 'Your OPTIMAP Email Was Changed'
303+
notify_message = f"""Hello,
304+
305+
Your email associated with OPTIMAP was changed from {old_email} to {email_new}.
306+
If you did NOT request this change, please contact us immediately at {contactURL}.
307+
308+
Thank you for using OPTIMAP!
309+
"""
310+
311+
send_mail(
312+
notify_subject,
313+
notify_message,
314+
from_email=settings.EMAIL_HOST_USER,
315+
recipient_list=[old_email]
316+
)
317+
318+
cache.delete(f"email_confirmation_{email_new}")
319+
320+
login_user(request, user)
251321

252-
return render(request,'changeuser.html')
322+
messages.success(request, "Your email has been successfully updated!")
323+
return redirect("/usersettings/")
253324

254325
def get_login_link(request, email):
255326
token = secrets.token_urlsafe(nbytes = LOGIN_TOKEN_LENGTH)

tests-ui/test_emailchange.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import os
2+
import django
3+
import unittest
4+
from helium import *
5+
from time import sleep
6+
from django.core.cache import cache
7+
from django.contrib.auth import get_user_model
8+
9+
# Ensure Django settings are configured
10+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "optimap.settings")
11+
django.setup()
12+
13+
User = get_user_model()
14+
15+
class EmailChangeUITest(unittest.TestCase):
16+
def setUp(self):
17+
"""Set up the test user and start browser"""
18+
self.old_email = "testuser@example.com"
19+
self.new_email = "newemail@example.com"
20+
self.token = "mock-token-12345"
21+
self.change_token = "mock-change-token-67890"
22+
23+
User.objects.filter(email=self.old_email).delete()
24+
User.objects.filter(email=self.new_email).delete()
25+
26+
self.user = User.objects.create_user(username=self.old_email, email=self.old_email, password="password")
27+
self.user.save()
28+
29+
cache.set(self.token, self.old_email, timeout=300)
30+
cache.set(f"email_confirmation_{self.new_email}",
31+
{"token": self.change_token, "old_email": self.old_email},
32+
timeout=600
33+
)
34+
35+
self.browser = start_firefox("http://localhost:8000", headless=False)
36+
37+
def test_email_change_process(self):
38+
"""Test the full email change UI process"""
39+
40+
click(S('#navbarDarkDropdown1'))
41+
42+
write(self.old_email, into='email')
43+
click(S('button[type="submit"]'))
44+
45+
sleep(1)
46+
47+
go_to(f"http://localhost:8000/login/{self.token}")
48+
sleep(3)
49+
50+
go_to("http://localhost:8000/usersettings/")
51+
sleep(2)
52+
53+
click("Change Email")
54+
55+
write(self.new_email, into="Enter your new email")
56+
sleep(1)
57+
click("Save Changes")
58+
sleep(5)
59+
60+
stored_data = cache.get(f"email_confirmation_{self.new_email}")
61+
62+
if stored_data and "token" in stored_data:
63+
correct_token = stored_data["token"]
64+
confirmation_url = f"http://localhost:8000/confirm-email/{correct_token}/{self.new_email}"
65+
go_to(confirmation_url)
66+
sleep(5)
67+
else:
68+
assert False, "Test Failed: Email confirmation token not found in cache!"
69+
70+
self.user.refresh_from_db()
71+
assert self.user.email == self.new_email, "Email was not updated in the database!"
72+
73+
def tearDown(self):
74+
"""Close browser after test"""
75+
if self.browser:
76+
kill_browser()
77+
78+
if __name__ == "__main__":
79+
unittest.main()

tests/test_login.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
import os
22
import unittest
33
from django.test import Client
4+
from django.contrib.auth import get_user_model
5+
User = get_user_model()
6+
from datetime import datetime
47

58
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'optimap.settings')
69

710
class SimpleTest(unittest.TestCase):
811
def setUp(self):
912
self.client = Client()
1013

14+
def test_login(self):
15+
"""Test that fields for logged in users are set correctly"""
16+
self.user = User.objects.create_user(username="test@example.com", email="test@example.com", password="password")
17+
self.client.login(username="testuser", password="password")
18+
19+
# fetch user from DB
20+
user = User.objects.filter(id=self.user.id).first()
21+
22+
self.assertFalse(user.is_staff)
23+
self.assertFalse(user.is_superuser)
24+
self.assertFalse(user.deleted)
25+
26+
# check the default fields which we do not want to use are emptly
27+
self.assertEqual(user.first_name, "", "first_name of user must not be set")
28+
self.assertEqual(user.last_name, "", "last_name of user must not be set")
29+
30+
timediff_joined = datetime.now(user.date_joined.timetz().tzinfo) - user.date_joined
31+
self.assertLess(timediff_joined.total_seconds(), 10)
32+
33+
self.assertEqual(user.username, user.email, "Email and username must be the same")
34+
1135
@unittest.skip('UI tests need to adjusted for new UI')
1236
def test_login_page(self):
1337
response = self.client.get('/login/')
@@ -19,6 +43,13 @@ def test_login_page(self):
1943
self.assertEqual(response.status_code, 302)
2044
self.assertRegex(response.url, 'success')
2145

46+
# FIXME test login above does not trigger setting the last_login field
47+
user = User.objects.filter(id=self.user.id).first()
48+
timediff_login = datetime.now(user.last_login.timetz().tzinfo) - user.last_login
49+
self.assertLess(timediff_login.total_seconds(), 10)
50+
51+
# see also https://github.com/GeoinformationSystems/optimap/issues/125
52+
2253
@unittest.skip('UI tests need to adjusted for new UI')
2354
def test_login_page_errors(self):
2455
response = self.client.put('/login/')

0 commit comments

Comments
 (0)