Repository: tournesol-app/tournesol

Issue:
Disable creating a username that is an email address
As username is public (#444) we should not allow to use an email as username.

Also contact users who have a mail as username to ask them to change that.

Options: 
1. reject "@" in usernames
2. reject based on a regexp for emails: `r'.+@.+\..+'`

Include: 
- Adapt login component and routes to enable login with username OR email
- Password reset from email
[back] feat: password reset from email



Discussion:

Resolving patch:
diff --git a/backend/core/migrations/0006_remove_email_used_as_username.py b/backend/core/migrations/0006_remove_email_used_as_username.py
new file mode 100644
--- /dev/null
+++ b/backend/core/migrations/0006_remove_email_used_as_username.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.11 on 2022-01-24 17:44
+
+from django.db import migrations
+from django.db.models import F
+
+
+def remove_email_used_as_username(apps, schema_editor):
+    User = apps.get_model("core", "User")
+    users_to_update = User.objects.filter(email=F("username"))
+    for u in users_to_update.iterator():
+        u.username = f"user{u.id:04}"
+        u.save(update_fields=["username"])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0005_email_unique_in_db'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=remove_email_used_as_username,
+            reverse_code=migrations.RunPython.noop
+        )
+    ]
diff --git a/backend/core/models/user.py b/backend/core/models/user.py
--- a/backend/core/models/user.py
+++ b/backend/core/models/user.py
@@ -24,6 +24,9 @@
 
 
 class User(AbstractUser):
+    # Fields used by django-rest-registation to find a user.
+    # Used by reset password mechanism.
+    LOGIN_FIELDS = ("username", "email")
 
     email = models.EmailField(_('email address'), unique=True)
     is_demo = models.BooleanField(default=False, help_text="Is a demo account?")
diff --git a/backend/core/oauth_validator.py b/backend/core/oauth_validator.py
--- a/backend/core/oauth_validator.py
+++ b/backend/core/oauth_validator.py
@@ -2,6 +2,8 @@
 Defines additional validators for Tournesol's authentication
 """
 
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ObjectDoesNotExist
 from oauth2_provider.oauth2_validators import OAuth2Validator
 
 
@@ -20,3 +22,22 @@ def get_additional_claims(self, request):
     def get_userinfo_claims(self, request):
         claims = super().get_userinfo_claims(request)
         return claims
+
+    def validate_user(self, username, password, client, request, *args, **kwargs):
+        user_found = super().validate_user(username, password, client, request, *args, **kwargs)
+
+        # Support authentication with email used as username
+        if not user_found and username and "@" in username:
+            try:
+                user = get_user_model().objects.get(email=username)
+            except ObjectDoesNotExist:
+                return False
+            return super().validate_user(user.username, password, client, request, *args, **kwargs)
+
+        return user_found
+
+    def save_bearer_token(self, token, request, *args, **kwargs):
+        # Add 'username' field in token response
+        if request.user.is_authenticated:
+            token["username"] = request.user.username
+        return super().save_bearer_token(token, request, *args, **kwargs)


Test patch:
diff --git a/tests/cypress/integration/tournesol.spec.ts b/tests/cypress/integration/tournesol.spec.ts
--- a/tests/cypress/integration/tournesol.spec.ts
+++ b/tests/cypress/integration/tournesol.spec.ts
@@ -15,6 +15,19 @@ describe('Tournesol basic tests', () => {
     cy.contains('Logout').click();
     cy.contains('Log in').should('be.visible');
   })
+
+  it('can login with email', () => {
+    cy.visit('/');
+    cy.contains('Log in').click();
+    cy.location('pathname').should('equal', '/login');
+    cy.focused().type('user1@tournesol.app');
+    cy.get('input[name="password"]').click().type('tournesol').type('{enter}');
+    cy.location('pathname').should('equal', '/');
+    cy.contains('.MuiToolbar-root', 'user1').should('be.visible');
+    cy.contains('Log in').should('not.exist');
+    cy.contains('Logout').click();
+    cy.contains('Log in').should('be.visible');
+  })
 })
 
 
@@ -61,26 +74,28 @@ describe('Account creation', () => {
 })
 
 describe('Password reset flow', () => {
-  before(() => {
-    cy.recreateUser("test-pwreset", "test@example.com", "forgottenPassword");
-  })
+  beforeEach(() => {
+    cy.recreateUser("username-pwreset", "test@example.com", "forgottenPassword");
+  });
 
-  it('can reset password', () => {
-    cy.visit('/login');
-    cy.contains('Forgot').click();
-    cy.location('pathname').should('equal', '/forgot');
-    cy.get('input[name=login]').type('test-pwreset').type('{enter}');
-    cy.contains('email will be sent').should('be.visible');
-    cy.getEmailLink().then(resetLink => cy.visit(resetLink));
-    cy.contains('New password').should('be.visible');
-    cy.get('input[name=password]').type('tournesol-new-password');
-    cy.get('input[name=confirm_password]').type('tournesol-new-password').type('{enter}');
-    cy.contains('password has been modified').should('be.visible');
-    cy.location('pathname').should('equal', '/login');
-    // Login with the new password
-    cy.get('input[name=username]').type('test-pwreset');
-    cy.get('input[name=password]').type('tournesol-new-password').type('{enter}');
-    cy.contains('a[href="/settings/profile"]', 'test-pwreset').should('be.visible');
-  })
+  ["username-pwreset", "test@example.com"].forEach(login => {
+    it(`can reset password for ${login}`, () => {
+      cy.visit('/login');
+      cy.contains('Forgot').click();
+      cy.location('pathname').should('equal', '/forgot');
+      cy.get('input[name=login]').type(login).type('{enter}');
+      cy.contains('email will be sent').should('be.visible');
+      cy.getEmailLink().then(resetLink => cy.visit(resetLink));
+      cy.contains('New password').should('be.visible');
+      cy.get('input[name=password]').type('tournesol-new-password');
+      cy.get('input[name=confirm_password]').type('tournesol-new-password').type('{enter}');
+      cy.contains('password has been modified').should('be.visible');
+      cy.location('pathname').should('equal', '/login');
+      // Login with the new password
+      cy.get('input[name=username]').type('username-pwreset');
+      cy.get('input[name=password]').type('tournesol-new-password').type('{enter}');
+      cy.contains('a[href="/settings/profile"]', 'username-pwreset').should('be.visible');
+    })
+  });
 })
 
diff --git a/tests/cypress/support/index.js b/tests/cypress/support/index.js
--- a/tests/cypress/support/index.js
+++ b/tests/cypress/support/index.js
@@ -18,3 +18,10 @@ import './commands'
 
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
+
+// Force english as navigator language during e2e tests
+Cypress.on('window:before:load', window => {
+  Object.defineProperty(window.navigator, 'language', { value: 'en' });
+  Object.defineProperty(window.navigator, 'languages', { value: ['en'] });
+  Object.defineProperty(window.navigator, 'accept_languages', { value: ['en'] });
+});