Enabled authentication via email (#5037)

main
Kirill Lakhov 3 years ago committed by GitHub
parent f719f58df4
commit b7371ede35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- `api/docs`, `api/swagger`, `api/schema` endpoints now allow unauthorized access (<https://github.com/opencv/cvat/pull/4928>) - `api/docs`, `api/swagger`, `api/schema` endpoints now allow unauthorized access (<https://github.com/opencv/cvat/pull/4928>)
- Datumaro version (<https://github.com/opencv/cvat/pull/4984>) - Datumaro version (<https://github.com/opencv/cvat/pull/4984>)
- Enabled authentication via email (<https://github.com/opencv/cvat/pull/5037>)
### Deprecated ### Deprecated
- TDB - TDB

@ -1,6 +1,6 @@
{ {
"name": "cvat-core", "name": "cvat-core",
"version": "7.0.0", "version": "7.0.1",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration", "description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "src/api.ts", "main": "src/api.ts",
"scripts": { "scripts": {

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -12,6 +13,10 @@ export function isInteger(value): boolean {
return typeof value === 'number' && Number.isInteger(value); return typeof value === 'number' && Number.isInteger(value);
} }
export function isEmail(value): boolean {
return typeof value === 'string' && RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/).test(value);
}
// Called with specific Enum context // Called with specific Enum context
export function isEnum(value): boolean { export function isEnum(value): boolean {
for (const key in this) { for (const key in this) {

@ -3,6 +3,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { isEmail } from './common';
import { StorageLocation, WebhookSourceType } from './enums'; import { StorageLocation, WebhookSourceType } from './enums';
import { Storage } from './storage'; import { Storage } from './storage';
@ -325,9 +326,9 @@ class ServerProxy {
return response.data; return response.data;
} }
async function login(username, password) { async function login(credential, password) {
const authenticationData = [ const authenticationData = [
`${encodeURIComponent('username')}=${encodeURIComponent(username)}`, `${encodeURIComponent(isEmail(credential) ? 'email' : 'username')}=${encodeURIComponent(credential)}`,
`${encodeURIComponent('password')}=${encodeURIComponent(password)}`, `${encodeURIComponent('password')}=${encodeURIComponent(password)}`,
] ]
.join('&') .join('&')

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -100,11 +101,11 @@ export const registerAsync = (
} }
}; };
export const loginAsync = (username: string, password: string): ThunkAction => async (dispatch) => { export const loginAsync = (credential: string, password: string): ThunkAction => async (dispatch) => {
dispatch(authActions.login()); dispatch(authActions.login());
try { try {
await cvat.server.login(username, password); await cvat.server.login(credential, password);
const users = await cvat.users.get({ self: true }); const users = await cvat.users.get({ self: true });
dispatch(authActions.loginSuccess(users[0])); dispatch(authActions.loginSuccess(users[0]));
} catch (error) { } catch (error) {

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -9,7 +10,7 @@ import Input from 'antd/lib/input';
import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined } from '@ant-design/icons';
export interface LoginData { export interface LoginData {
username: string; credential: string;
password: string; password: string;
} }
@ -24,18 +25,18 @@ function LoginFormComponent(props: Props): JSX.Element {
<Form onFinish={onSubmit} className='login-form'> <Form onFinish={onSubmit} className='login-form'>
<Form.Item <Form.Item
hasFeedback hasFeedback
name='username' name='credential'
rules={[ rules={[
{ {
required: true, required: true,
message: 'Please specify a username', message: 'Please specify a email or username',
}, },
]} ]}
> >
<Input <Input
autoComplete='username' autoComplete='credential'
prefix={<UserOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />} prefix={<UserOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />}
placeholder='Username' placeholder='Email or Username'
/> />
</Form.Item> </Form.Item>

@ -16,7 +16,7 @@ import LoginForm, { LoginData } from './login-form';
interface LoginPageComponentProps { interface LoginPageComponentProps {
fetching: boolean; fetching: boolean;
renderResetPassword: boolean; renderResetPassword: boolean;
onLogin: (username: string, password: string) => void; onLogin: (credential: string, password: string) => void;
} }
function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps): JSX.Element { function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps): JSX.Element {
@ -40,7 +40,7 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps
<LoginForm <LoginForm
fetching={fetching} fetching={fetching}
onSubmit={(loginData: LoginData): void => { onSubmit={(loginData: LoginData): void => {
onLogin(loginData.username, loginData.password); onLogin(loginData.credential, loginData.password);
}} }}
/> />
<Row justify='start' align='top'> <Row justify='start' align='top'>

@ -1,8 +1,9 @@
// Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React, { useState } from 'react';
import { UserAddOutlined, MailOutlined, LockOutlined } from '@ant-design/icons'; import { UserAddOutlined, MailOutlined, LockOutlined } from '@ant-design/icons';
import Form, { RuleRender, RuleObject } from 'antd/lib/form'; import Form, { RuleRender, RuleObject } from 'antd/lib/form';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
@ -98,8 +99,11 @@ const validateAgreement: ((userAgreements: UserAgreement[]) => RuleRender) = (
function RegisterFormComponent(props: Props): JSX.Element { function RegisterFormComponent(props: Props): JSX.Element {
const { fetching, userAgreements, onSubmit } = props; const { fetching, userAgreements, onSubmit } = props;
const [form] = Form.useForm();
const [usernameEdited, setUsernameEdited] = useState(false);
return ( return (
<Form <Form
form={form}
onFinish={(values: Record<string, string | boolean>) => { onFinish={(values: Record<string, string | boolean>) => {
const agreements = Object.keys(values) const agreements = Object.keys(values)
.filter((key: string):boolean => key.startsWith('agreement:')); .filter((key: string):boolean => key.startsWith('agreement:'));
@ -155,44 +159,50 @@ function RegisterFormComponent(props: Props): JSX.Element {
</Row> </Row>
<Form.Item <Form.Item
hasFeedback hasFeedback
name='username' name='email'
rules={[ rules={[
{ {
required: true, type: 'email',
message: 'Please specify a username', message: 'The input is not valid E-mail!',
}, },
{ {
validator: validateUsername, required: true,
message: 'Please specify an email address',
}, },
]} ]}
> >
<Input <Input
prefix={<UserAddOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />} autoComplete='email'
placeholder='Username' prefix={<MailOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />}
placeholder='Email address'
onChange={(event) => {
const { value } = event.target;
if (!usernameEdited) {
const [username] = value.split('@');
form.setFieldsValue({ username });
}
}}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
hasFeedback hasFeedback
name='email' name='username'
rules={[ rules={[
{ {
type: 'email', required: true,
message: 'The input is not valid E-mail!', message: 'Please specify a username',
}, },
{ {
required: true, validator: validateUsername,
message: 'Please specify an email address',
}, },
]} ]}
> >
<Input <Input
autoComplete='email' prefix={<UserAddOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />}
prefix={<MailOutlined style={{ color: 'rgba(0, 0, 0, 0.25)' }} />} placeholder='Username'
placeholder='Email address' onChange={() => setUsernameEdited(true)}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
hasFeedback hasFeedback
name='password1' name='password1'

@ -4,8 +4,11 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from dj_rest_auth.registration.serializers import RegisterSerializer from dj_rest_auth.registration.serializers import RegisterSerializer
from dj_rest_auth.serializers import PasswordResetSerializer from dj_rest_auth.serializers import PasswordResetSerializer, LoginSerializer
from rest_framework.exceptions import ValidationError
from rest_framework import serializers from rest_framework import serializers
from allauth.account import app_settings
from allauth.account.utils import filter_users_by_email
from django.conf import settings from django.conf import settings
@ -38,3 +41,21 @@ class PasswordResetSerializerEx(PasswordResetSerializer):
return { return {
'domain_override': domain 'domain_override': domain
} }
class LoginSerializerEx(LoginSerializer):
def get_auth_user_using_allauth(self, username, email, password):
# Authentication through email
if settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL:
return self._validate_email(email, password)
# Authentication through username
if settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME:
return self._validate_username(username, password)
# Authentication through either username or email
if email:
users = filter_users_by_email(email)
if not users or len(users) > 1:
raise ValidationError('Unable to login with provided credentials')
return self._validate_username_email(username, email, password)

@ -188,6 +188,7 @@ REST_AUTH_REGISTER_SERIALIZERS = {
} }
REST_AUTH_SERIALIZERS = { REST_AUTH_SERIALIZERS = {
'LOGIN_SERIALIZER': 'cvat.apps.iam.serializers.LoginSerializerEx',
'PASSWORD_RESET_SERIALIZER': 'cvat.apps.iam.serializers.PasswordResetSerializerEx', 'PASSWORD_RESET_SERIALIZER': 'cvat.apps.iam.serializers.PasswordResetSerializerEx',
} }
@ -258,6 +259,7 @@ AUTHENTICATION_BACKENDS = [
# https://github.com/pennersr/django-allauth # https://github.com/pennersr/django-allauth
ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
# set UI url to redirect after a successful e-mail confirmation # set UI url to redirect after a successful e-mail confirmation
#changed from '/auth/login' to '/auth/email-confirmation' for email confirmation message #changed from '/auth/login' to '/auth/email-confirmation' for email confirmation message
ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/auth/email-confirmation' ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/auth/email-confirmation'

@ -1,6 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# Copyright (C) 2020-2022 Intel Corporation # Copyright (C) 2020-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
@ -8,7 +9,7 @@ from cvat.settings.production import *
# https://github.com/pennersr/django-allauth # https://github.com/pennersr/django-allauth
ACCOUNT_AUTHENTICATION_METHOD = 'username' ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_CONFIRM_EMAIL_ON_GET = True ACCOUNT_CONFIRM_EMAIL_ON_GET = True
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = 'mandatory' ACCOUNT_EMAIL_VERIFICATION = 'mandatory'

@ -472,7 +472,7 @@ to enable email verification (ACCOUNT_EMAIL_VERIFICATION = 'mandatory').
Access is denied until the user's email address is verified. Access is denied until the user's email address is verified.
```python ```python
ACCOUNT_AUTHENTICATION_METHOD = 'username' ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_CONFIRM_EMAIL_ON_GET = True ACCOUNT_CONFIRM_EMAIL_ON_GET = True
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = 'mandatory' ACCOUNT_EMAIL_VERIFICATION = 'mandatory'

@ -10,8 +10,8 @@ context('When clicking on the Logout button, get the user session closed.', () =
const issueId = '1810'; const issueId = '1810';
let taskId; let taskId;
function login(userName, password) { function login(credential, password) {
cy.get('[placeholder="Username"]').clear().type(userName); cy.get('[placeholder="Email or Username"]').clear().type(credential);
cy.get('[placeholder="Password"]').clear().type(password); cy.get('[placeholder="Password"]').clear().type(password);
cy.get('[type="submit"]').click(); cy.get('[type="submit"]').click();
} }
@ -73,6 +73,12 @@ context('When clicking on the Logout button, get the user session closed.', () =
}); });
}); });
it('Login via email', () => {
cy.logout();
login(Cypress.env('email'), Cypress.env('password'));
cy.url().should('contain', '/tasks');
});
it('Incorrect user and correct password', () => { it('Incorrect user and correct password', () => {
cy.logout(); cy.logout();
login('randomUser123', Cypress.env('password')); login('randomUser123', Cypress.env('password'));

@ -20,7 +20,7 @@ require('cy-verify-downloads').addCustomCommand();
let selectedValueGlobal = ''; let selectedValueGlobal = '';
Cypress.Commands.add('login', (username = Cypress.env('user'), password = Cypress.env('password'), page = 'tasks') => { Cypress.Commands.add('login', (username = Cypress.env('user'), password = Cypress.env('password'), page = 'tasks') => {
cy.get('[placeholder="Username"]').type(username); cy.get('[placeholder="Email or Username"]').type(username);
cy.get('[placeholder="Password"]').type(password); cy.get('[placeholder="Password"]').type(password);
cy.get('[type="submit"]').click(); cy.get('[type="submit"]').click();
cy.url().should('contain', `/${page}`); cy.url().should('contain', `/${page}`);

Loading…
Cancel
Save