CVAT authentication (#5147)

main
Maria Khrustaleva 3 years ago committed by GitHub
parent 8a44a36c74
commit bb487165dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mask tools are supported now (brush, eraser, polygon-plus, polygon-minus, returning masks
from online detectors & interactors) (<https://github.com/opencv/cvat/pull/4543>)
- Added Webhooks (<https://github.com/opencv/cvat/pull/4863>)
- Authentication with social accounts google & github (<https://github.com/opencv/cvat/pull/5147>)
### Changed
- `api/docs`, `api/swagger`, `api/schema`, `server/about` endpoints now allow unauthorized access (<https://github.com/opencv/cvat/pull/4928>, <https://github.com/opencv/cvat/pull/4935>)

@ -88,6 +88,11 @@ const config = require('./config').default;
await serverProxy.server.logout();
};
cvat.server.advancedAuthentication.implementation = async () => {
const result = await serverProxy.server.advancedAuthentication();
return result;
};
cvat.server.changePassword.implementation = async (oldPassword, newPassword1, newPassword2) => {
await serverProxy.server.changePassword(oldPassword, newPassword1, newPassword2);
};

@ -173,6 +173,18 @@ function build() {
const result = await PluginRegistry.apiWrapper(cvat.server.logout);
return result;
},
/**
* Method returns enabled advanced authentication methods
* @method advancedAuthentication
* @async
* @memberof module:API.cvat.server
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async advancedAuthentication() {
const result = await PluginRegistry.apiWrapper(cvat.server.advancedAuthentication);
return result;
},
/**
* Method allows to change user password
* @method changePassword

@ -2196,6 +2196,18 @@ class ServerProxy {
}
}
async function advancedAuthentication(): Promise<any> {
const { backendAPI } = config;
try {
const response = await Axios.get(`${backendAPI}/server/advanced-auth`, {
proxy: config.proxy,
});
return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
Object.defineProperties(
this,
Object.freeze({
@ -2207,6 +2219,7 @@ class ServerProxy {
exception,
login,
logout,
advancedAuthentication,
changePassword,
requestPasswordReset,
resetPassword,

@ -7,6 +7,7 @@ import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
import { UserConfirmation } from 'components/register-page/register-form';
import { getCore } from 'cvat-core-wrapper';
import isReachable from 'utils/url-checker';
import { AdvancedAuthMethodsList } from '../reducers';
const cvat = getCore();
@ -35,6 +36,9 @@ export enum AuthActionTypes {
LOAD_AUTH_ACTIONS = 'LOAD_AUTH_ACTIONS',
LOAD_AUTH_ACTIONS_SUCCESS = 'LOAD_AUTH_ACTIONS_SUCCESS',
LOAD_AUTH_ACTIONS_FAILED = 'LOAD_AUTH_ACTIONS_FAILED',
LOAD_ADVANCED_AUTHENTICATION = 'LOAD_ADVANCED_AUTHENTICATION',
LOAD_ADVANCED_AUTHENTICATION_SUCCESS = 'LOAD_ADVANCED_AUTHENTICATION_SUCCESS',
LOAD_ADVANCED_AUTHENTICATION_FAILED = 'LOAD_ADVANCED_AUTHENTICATION_FAILED',
}
export const authActions = {
@ -42,7 +46,9 @@ export const authActions = {
authorizeFailed: (error: any) => createAction(AuthActionTypes.AUTHORIZED_FAILED, { error }),
login: () => createAction(AuthActionTypes.LOGIN),
loginSuccess: (user: any) => createAction(AuthActionTypes.LOGIN_SUCCESS, { user }),
loginFailed: (error: any) => createAction(AuthActionTypes.LOGIN_FAILED, { error }),
loginFailed: (error: any, hasEmailVerificationBeenSent = false) => (
createAction(AuthActionTypes.LOGIN_FAILED, { error, hasEmailVerificationBeenSent })
),
register: () => createAction(AuthActionTypes.REGISTER),
registerSuccess: (user: any) => createAction(AuthActionTypes.REGISTER_SUCCESS, { user }),
registerFailed: (error: any) => createAction(AuthActionTypes.REGISTER_FAILED, { error }),
@ -69,6 +75,13 @@ export const authActions = {
})
),
loadServerAuthActionsFailed: (error: any) => createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED, { error }),
loadAdvancedAuth: () => createAction(AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION),
loadAdvancedAuthSuccess: (list: AdvancedAuthMethodsList) => (
createAction(AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_SUCCESS, { list })
),
loadAdvancedAuthFailed: (error: any) => (
createAction(AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_FAILED, { error })
),
};
export type AuthActions = ActionUnion<typeof authActions>;
@ -109,7 +122,8 @@ export const loginAsync = (credential: string, password: string): ThunkAction =>
const users = await cvat.users.get({ self: true });
dispatch(authActions.loginSuccess(users[0]));
} catch (error) {
dispatch(authActions.loginFailed(error));
const hasEmailVerificationBeenSent = error.message.includes('Unverified email');
dispatch(authActions.loginFailed(error, hasEmailVerificationBeenSent));
}
};
@ -197,3 +211,13 @@ export const loadAuthActionsAsync = (): ThunkAction => async (dispatch) => {
dispatch(authActions.loadServerAuthActionsFailed(error));
}
};
export const loadAdvancedAuthAsync = (): ThunkAction => async (dispatch): Promise<void> => {
dispatch(authActions.loadAdvancedAuth());
try {
const list: AdvancedAuthMethodsList = await cvat.server.advancedAuthentication();
dispatch(authActions.loadAdvancedAuthSuccess(list));
} catch (error) {
dispatch(authActions.loadAdvancedAuthFailed(error));
}
};

@ -63,7 +63,9 @@ import showPlatformNotification, {
showUnsupportedNotification,
} from 'utils/platform-checker';
import '../styles.scss';
import EmailConfirmationPage from './email-confirmation-page/email-confirmed';
import EmailConfirmationPage from './email-confirmation-pages/email-confirmed';
import EmailVerificationSentPage from './email-confirmation-pages/email-verification-sent';
import IncorrectEmailConfirmationPage from './email-confirmation-pages/incorrect-email-confirmation';
interface CVATAppProps {
loadFormats: () => void;
@ -427,6 +429,8 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
<GlobalErrorBoundary>
<Switch>
<Route exact path='/auth/register' component={RegisterPageContainer} />
<Route exact path='/auth/email-verification-sent' component={EmailVerificationSentPage} />
<Route exact path='/auth/incorrect-email-confirmation' component={IncorrectEmailConfirmationPage} />
<Route exact path='/auth/login' component={LoginPageContainer} />
<Route
exact

@ -0,0 +1,32 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Col, Row } from 'antd/lib/grid';
import Layout from 'antd/lib/layout';
import Button from 'antd/lib/button';
import './styles.scss';
const { Content } = Layout;
/**
* Component for displaying message that email should be verified
*/
export default function EmailVerificationSentPage(): JSX.Element {
return (
<Layout>
<Content>
<Row justify='center' align='middle' id='email-verification-sent-page-container'>
<Col>
<h1>Please, confirm your email</h1>
<Button type='link' href='/auth/login'>
Go to login page
</Button>
</Col>
</Row>
</Content>
</Layout>
);
}

@ -0,0 +1,37 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Col, Row } from 'antd/lib/grid';
import Layout from 'antd/lib/layout';
import Button from 'antd/lib/button';
import './styles.scss';
const { Content } = Layout;
/**
* Component for displaying message that email confirmation URL is incorrect
*/
export default function IncorrectEmailConfirmationPage(): JSX.Element {
return (
<Layout>
<Content>
<Row justify='center' align='middle' id='incorrect-email-confirmation-page-container'>
<Col>
<h1>
This e-mail confirmation link expired or is invalid.
</h1>
<p>
Please issue a new e-mail confirmation request.
</p>
<Button type='link' href='/auth/login'>
Go to login page
</Button>
</Col>
</Row>
</Content>
</Layout>
);
}

@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: MIT
#email-confirmation-page-container {
#email-confirmation-page-container,
#email-verification-sent-page-container,
#incorrect-email-confirmation-page-container {
height: 100%;
text-align: center;
}

@ -3,23 +3,35 @@
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { RouteComponentProps } from 'react-router';
import React, { useEffect } from 'react';
import { RouteComponentProps, useHistory } from 'react-router';
import { Link, withRouter } from 'react-router-dom';
import Button from 'antd/lib/button';
import Title from 'antd/lib/typography/Title';
import Text from 'antd/lib/typography/Text';
import { Row, Col } from 'antd/lib/grid';
import Layout from 'antd/lib/layout';
import Space from 'antd/lib/space';
import { GithubOutlined, GooglePlusOutlined } from '@ant-design/icons';
import LoginForm, { LoginData } from './login-form';
import { getCore } from '../../cvat-core-wrapper';
const cvat = getCore();
interface LoginPageComponentProps {
fetching: boolean;
renderResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
googleAuthentication: boolean;
githubAuthentication: boolean;
onLogin: (credential: string, password: string) => void;
loadAdvancedAuthenticationMethods: () => void;
}
function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps): JSX.Element {
const history = useHistory();
const { backendAPI } = cvat.config;
const sizes = {
style: {
width: 400,
@ -28,7 +40,18 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps
const { Content } = Layout;
const { fetching, onLogin, renderResetPassword } = props;
const {
fetching, renderResetPassword, hasEmailVerificationBeenSent,
googleAuthentication, githubAuthentication, onLogin, loadAdvancedAuthenticationMethods,
} = props;
if (hasEmailVerificationBeenSent) {
history.push('/auth/email-verification-sent');
}
useEffect(() => {
loadAdvancedAuthenticationMethods();
}, []);
return (
<Layout>
@ -43,6 +66,41 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps
onLogin(loginData.credential, loginData.password);
}}
/>
{(googleAuthentication || githubAuthentication) &&
(
<>
<Row justify='center' align='top'>
<Col>
or
</Col>
</Row>
<Row justify='space-between' align='middle'>
{googleAuthentication && (
<Col span={11}>
<Button href={`${backendAPI}/auth/google/login`}>
<Space>
<GooglePlusOutlined />
Continue with Google
</Space>
</Button>
</Col>
)}
{githubAuthentication && (
<Col
span={11}
offset={googleAuthentication ? 1 : 0}
>
<Button href={`${backendAPI}/auth/github/login`}>
<Space>
<GithubOutlined />
Continue with Github
</Space>
</Button>
</Col>
)}
</Row>
</>
)}
<Row justify='start' align='top'>
<Col>
<Text strong>

@ -5,26 +5,34 @@
import { connect } from 'react-redux';
import LoginPageComponent from 'components/login-page/login-page';
import { CombinedState } from 'reducers';
import { loginAsync } from 'actions/auth-actions';
import { loginAsync, loadAdvancedAuthAsync } from 'actions/auth-actions';
interface StateToProps {
fetching: boolean;
renderResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
googleAuthentication: boolean;
githubAuthentication: boolean;
}
interface DispatchToProps {
onLogin: typeof loginAsync;
loadAdvancedAuthenticationMethods: typeof loadAdvancedAuthAsync;
}
function mapStateToProps(state: CombinedState): StateToProps {
return {
fetching: state.auth.fetching,
renderResetPassword: state.auth.allowResetPassword,
hasEmailVerificationBeenSent: state.auth.hasEmailVerificationBeenSent,
googleAuthentication: state.auth.advancedAuthList.GOOGLE_ACCOUNT_AUTHENTICATION,
githubAuthentication: state.auth.advancedAuthList.GITHUB_ACCOUNT_AUTHENTICATION,
};
}
const mapDispatchToProps: DispatchToProps = {
onLogin: loginAsync,
loadAdvancedAuthenticationMethods: loadAdvancedAuthAsync,
};
export default connect(mapStateToProps, mapDispatchToProps)(LoginPageComponent);

@ -15,6 +15,13 @@ const defaultState: AuthState = {
allowChangePassword: false,
showChangePasswordDialog: false,
allowResetPassword: false,
hasEmailVerificationBeenSent: false,
advancedAuthFetching: false,
advancedAuthInitialized: false,
advancedAuthList: {
GOOGLE_ACCOUNT_AUTHENTICATION: false,
GITHUB_ACCOUNT_AUTHENTICATION: false,
},
};
export default function (state = defaultState, action: AuthActions | BoundariesActions): AuthState {
@ -40,12 +47,16 @@ export default function (state = defaultState, action: AuthActions | BoundariesA
...state,
fetching: false,
user: action.payload.user,
hasEmailVerificationBeenSent: false,
};
case AuthActionTypes.LOGIN_FAILED:
case AuthActionTypes.LOGIN_FAILED: {
const { hasEmailVerificationBeenSent } = action.payload;
return {
...state,
fetching: false,
hasEmailVerificationBeenSent,
};
}
case AuthActionTypes.LOGOUT:
return {
...state,
@ -149,6 +160,29 @@ export default function (state = defaultState, action: AuthActions | BoundariesA
allowChangePassword: false,
allowResetPassword: false,
};
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION: {
return {
...state,
advancedAuthFetching: true,
advancedAuthInitialized: false,
};
}
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_SUCCESS: {
const { list } = action.payload;
return {
...state,
advancedAuthFetching: false,
advancedAuthInitialized: true,
advancedAuthList: list,
};
}
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_FAILED: {
return {
...state,
advancedAuthFetching: false,
advancedAuthInitialized: true,
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR: {
return { ...defaultState };
}

@ -14,6 +14,15 @@ export type StringObject = {
[index: string]: string;
};
enum AdvancedAuthMethods {
GOOGLE_ACCOUNT_AUTHENTICATION = 'GOOGLE_ACCOUNT_AUTHENTICATION',
GITHUB_ACCOUNT_AUTHENTICATION = 'GITHUB_ACCOUNT_AUTHENTICATION',
}
export type AdvancedAuthMethodsList = {
[name in AdvancedAuthMethods]: boolean;
};
export interface AuthState {
initialized: boolean;
fetching: boolean;
@ -23,6 +32,10 @@ export interface AuthState {
showChangePasswordDialog: boolean;
allowChangePassword: boolean;
allowResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
advancedAuthFetching: boolean;
advancedAuthInitialized: boolean;
advancedAuthList: AdvancedAuthMethodsList;
}
export interface ProjectsQuery {

@ -28,7 +28,7 @@ from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter, OpenApiResponse, PolymorphicProxySerializer,
extend_schema_view, extend_schema
extend_schema_view, extend_schema, inline_serializer
)
from drf_spectacular.plumbing import build_array_type, build_basic_type
@ -234,7 +234,40 @@ class ServerViewSet(viewsets.ViewSet):
'GIT_INTEGRATION': apps.is_installed('cvat.apps.dataset_repo'),
'ANALYTICS': strtobool(os.environ.get("CVAT_ANALYTICS", '0')),
'MODELS': strtobool(os.environ.get("CVAT_SERVERLESS", '0')),
'PREDICT':False # FIXME: it is unused anymore (for UI only)
'PREDICT': False, # FIXME: it is unused anymore (for UI only)
}
return Response(response)
@staticmethod
@extend_schema(
summary='Method provides a list with advanced integrated authentication methods (e.g. social accounts)',
responses={
'200': OpenApiResponse(response=inline_serializer(
name='AdvancedAuthentication',
fields={
'GOOGLE_ACCOUNT_AUTHENTICATION': serializers.BooleanField(),
'GITHUB_ACCOUNT_AUTHENTICATION': serializers.BooleanField(),
}
)),
}
)
@action(detail=False, methods=['GET'], url_path='advanced-auth', permission_classes=[])
def advanced_authentication(request):
use_social_auth = settings.USE_ALLAUTH_SOCIAL_ACCOUNTS
integrated_auth_providers = settings.SOCIALACCOUNT_PROVIDERS.keys() if use_social_auth else []
google_auth_is_enabled = (
'google' in integrated_auth_providers
and settings.SOCIAL_AUTH_GOOGLE_CLIENT_ID
and settings.SOCIAL_AUTH_GOOGLE_CLIENT_SECRET
)
github_auth_is_enabled = (
'github' in integrated_auth_providers
and settings.SOCIAL_AUTH_GITHUB_CLIENT_ID
and settings.SOCIAL_AUTH_GITHUB_CLIENT_SECRET
)
response = {
'GOOGLE_ACCOUNT_AUTHENTICATION': google_auth_is_enabled,
'GITHUB_ACCOUNT_AUTHENTICATION': github_auth_is_enabled,
}
return Response(response)
@ -306,7 +339,7 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
queryset = perm.filter(queryset)
return queryset
def perform_create(self, serializer):
def perform_create(self, serializer, **kwargs):
super().perform_create(
serializer,
owner=self.request.user,
@ -814,7 +847,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
if updated_instance.project:
updated_instance.project.save()
def perform_create(self, serializer):
def perform_create(self, serializer, **kwargs):
super().perform_create(
serializer,
owner=self.request.user,
@ -1741,7 +1774,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
else:
return IssueWriteSerializer
def perform_create(self, serializer):
def perform_create(self, serializer, **kwargs):
super().perform_create(serializer, owner=self.request.user)
@extend_schema(summary='The action returns all comments of a specific issue',
@ -1816,7 +1849,7 @@ class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
else:
return CommentWriteSerializer
def perform_create(self, serializer):
def perform_create(self, serializer, **kwargs):
super().perform_create(serializer, owner=self.request.user)
@extend_schema(tags=['users'])

@ -0,0 +1,48 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from django.contrib.auth import get_user_model
from django.http import HttpResponseRedirect, HttpResponseBadRequest
from django.conf import settings
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.exceptions import ImmediateHttpResponse
from allauth.account.utils import filter_users_by_email
UserModel = get_user_model()
class DefaultAccountAdapterEx(DefaultAccountAdapter):
def respond_email_verification_sent(self, request, user):
return HttpResponseRedirect(settings.ACCOUNT_EMAIL_VERIFICATION_SENT_REDIRECT_URL)
class SocialAccountAdapterEx(DefaultSocialAccountAdapter):
def pre_social_login(self, request, sociallogin):
"""
Invoked just after a user successfully authenticates via a
social provider, but before the login is actually processed
(and before the pre_social_login signal is emitted).
"""
if sociallogin.is_existing:
return
if not sociallogin.email_addresses:
raise ImmediateHttpResponse(response=HttpResponseBadRequest('No email is associated with this social account'))
users = filter_users_by_email(sociallogin.user.email)
if len(users) > 1:
raise ImmediateHttpResponse(HttpResponseBadRequest(f'Cannot connect account with ${sociallogin.user.email} email.'))
elif users:
sociallogin.connect(request, users[0])
return
class GitHubAdapter(GitHubOAuth2Adapter):
def get_callback_url(self, request, app):
return settings.GITHUB_CALLBACK_URL
class GoogleAdapter(GoogleOAuth2Adapter):
def get_callback_url(self, request, app):
return settings.GOOGLE_CALLBACK_URL

@ -275,7 +275,8 @@ class ServerPermission(OpenPolicyAgentPermission):
'plugins': 'view',
'exception': 'send:exception',
'logs': 'send:logs',
'share': 'list:content'
'share': 'list:content',
'advanced_authentication': 'view',
}.get(view.action, None)]
def get_resource(self):

@ -1,5 +1,5 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
view,N/A,N/A,N/A,,GET,"/server/about, /server/annotation/formats, /server/plugins",None,N/A
view,N/A,N/A,N/A,,GET,"/server/about, /server/annotation/formats, /server/plugins, /server/advanced-auth",None,N/A
send:exception,N/A,N/A,N/A,,POST,/server/exception,None,N/A
send:logs,N/A,N/A,N/A,,POST,/server/logs,None,N/A
list:content,N/A,N/A,N/A,,GET,/server/share,Worker,N/A
1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 view N/A N/A N/A GET /server/about, /server/annotation/formats, /server/plugins /server/about, /server/annotation/formats, /server/plugins, /server/advanced-auth None N/A
3 send:exception N/A N/A N/A POST /server/exception None N/A
4 send:logs N/A N/A N/A POST /server/logs None N/A
5 list:content N/A N/A N/A GET /server/share Worker N/A

@ -44,6 +44,26 @@ class PasswordResetSerializerEx(PasswordResetSerializer):
class LoginSerializerEx(LoginSerializer):
def get_auth_user_using_allauth(self, username, email, password):
def is_email_authentication():
return settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL
def is_username_authentication():
return settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME
# check that the server settings match the request
if is_username_authentication() and not username and email:
raise ValidationError(
'Attempt to authenticate with email/password. '
'But username/password are used for authentication on the server. '
'Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD.')
if is_email_authentication() and not email and username:
raise ValidationError(
'Attempt to authenticate with username/password. '
'But email/password are used for authentication on the server. '
'Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD.')
# Authentication through email
if settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL:
return self._validate_email(email, password)

@ -0,0 +1,288 @@
<!DOCTYPE html>
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}
{% load static %}
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Email Confirmation</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
/**
* Google webfonts. Recommended to include the .woff version for cross-client compatibility.
*/
@media screen {
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v10/ODelI1aHBYDBqgeIAH2zlBM0YzuT7MdOe03otPbuUS0.woff) format('woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v10/toadOcfmlt9b38dHJxOBGFkQc6VGVFSmCnC_l7QZG60.woff) format('woff');
}
}
/**
* Avoid browser level font resizing.
* 1. Windows Mobile
* 2. iOS / OSX
*/
body,
table,
td,
a {
-ms-text-size-adjust: 100%; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove extra space added to tables and cells in Outlook.
*/
table,
td {
mso-table-rspace: 0pt;
mso-table-lspace: 0pt;
}
/**
* Better fluid images in Internet Explorer.
*/
img {
-ms-interpolation-mode: bicubic;
}
/**
* Remove blue links for iOS devices.
*/
a[x-apple-data-detectors] {
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
color: inherit !important;
text-decoration: none !important;
}
/**
* Fix centering issues in Android 4.4.
*/
div[style*="margin: 16px 0;"] {
margin: 0 !important;
}
body {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
/**
* Collapse table borders to avoid space between cells.
*/
table {
border-collapse: collapse !important;
}
a {
color: #1a82e2;
}
img {
height: auto;
line-height: 100%;
text-decoration: none;
border: 0;
outline: none;
}
</style>
</head>
<body style="background-color: #e9ecef;">
<!-- start preheader -->
<div class="preheader" style="display: none; max-width: 0; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #fff; opacity: 0;">
A preheader is the short summary text that follows the subject line when an email is viewed in the inbox.
</div>
<!-- end preheader -->
<!-- start body -->
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- start logo -->
<tr>
<td align="center" bgcolor="#e9ecef">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<tr>
<td align="center" valign="bottom">
<a href="https://www.cvat.ai/" target="_blank" style="display: inline-block;">
<img
src="https://github.com/opencv/cvat/blob/develop/site/content/en/images/cvat_poster_with_name.png?raw=true"
alt="Logo"
border="0"
width="100%"
style="display: block; width: 100%;"
>
</a>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end logo -->
<!-- start hero -->
<tr>
<td align="center" bgcolor="#e9ecef">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 36px 24px 0; font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif; border-top: 3px solid #d4dadf;">
<h1 style="margin: 0; font-size: 32px; font-weight: 700; letter-spacing: -1px; line-height: 48px;">
Confirm Your Email Address
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end hero -->
<!-- start copy block -->
<tr>
<td align="center" bgcolor="#e9ecef">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- start copy -->
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 24px; font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px;">
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}
<p>
Thank you for signing up for CVAT!
</p>
<p style="margin: 0;">
To complete registration and start annotating, simply tap the button below and confirm your email address.
If you didn't create an account with <strong>{{ site_name }}</strong>,
you can safely delete this email.
</p>
{% endblocktrans %}
</td>
</tr>
<!-- end copy -->
<!-- start button -->
<tr>
<td align="left" bgcolor="#ffffff">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 12px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#FCA836" style="border-radius: 6px;">
<a
href="{{ activate_url }}"
target="_blank"
style="display: inline-block; padding: 16px 36px;"
"font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif;"
"font-size: 16px; color: #ffffff; text-decoration: none; border-radius: 6px;"
rel="noopener noreferrer"
>
Confirm
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- end button -->
<!-- start copy -->
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 24px; font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; border-bottom: 3px solid #d4dadf">
<hr>
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}
<p style="margin: 0;">
<strong>{{ site_domain }}</strong>
</p>
{% endblocktrans %} {% endautoescape %}
</td>
</tr>
<!-- end copy -->
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end copy block -->
<!-- start footer -->
<tr>
<td align="center" bgcolor="#e9ecef" style="padding: 24px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- start permission -->
<tr>
<td align="center" bgcolor="#e9ecef" style="padding: 12px 24px; font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 20px; color: #666;">
<p style="margin: 0;">If you didn't request this, please ignore this email.</p>
</td>
</tr>
<!-- end permission -->
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end footer -->
</table>
<!-- end body -->
</body>
</html>

@ -1,14 +1 @@
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}
Hello from {{ site_name }}!
<p>
You're receiving this e-mail because user <strong>{{ user_display }}</strong> has given yours as an e-mail address
to connect their account.
</p>
<p>To confirm this is correct, go to <a href="{{ activate_url }}">{{ activate_url }}</a></p>
{% endblocktrans %}
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}
<strong>{{ site_domain }}</strong>
{% endblocktrans %} {% endautoescape %}
{% include "account/email/email_confirmation_message.html" %}

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Confirm your email adress{% endblocktrans %}
{% endautoescape %}

@ -8,12 +8,13 @@ from rest_framework.test import APITestCase, APIClient
from rest_framework.authtoken.models import Token
from django.test import override_settings
from cvat.apps.iam.urls import urlpatterns as iam_url_patterns
from cvat.apps.iam.views import ConfirmEmailViewEx
from django.urls import path, re_path
from allauth.account.views import ConfirmEmailView, EmailVerificationSentView
from allauth.account.views import EmailVerificationSentView
urlpatterns = iam_url_patterns + [
re_path(r'^account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(),
re_path(r'^account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailViewEx.as_view(),
name='account_confirm_email'),
path('register/account-email-verification-sent', EmailVerificationSentView.as_view(),
name='account_email_verification_sent'),

@ -7,15 +7,23 @@ from django.urls import path, re_path
from django.conf import settings
from django.urls.conf import include
from dj_rest_auth.views import (
LoginView, LogoutView, PasswordChangeView,
LogoutView, PasswordChangeView,
PasswordResetView, PasswordResetConfirmView)
from allauth.account.views import ConfirmEmailView, EmailVerificationSentView
from allauth.account import app_settings as allauth_settings
from cvat.apps.iam.views import SigningView, RegisterViewEx, RulesView
from cvat.apps.iam.views import (
SigningView, RegisterViewEx, RulesView, ConfirmEmailViewEx,
)
from cvat.apps.iam.views import (
github_oauth2_login as github_login,
github_oauth2_callback as github_callback,
google_oauth2_login as google_login,
google_oauth2_callback as google_callback,
LoginViewEx,
)
urlpatterns = [
path('login', LoginView.as_view(), name='rest_login'),
path('login', LoginViewEx.as_view(), name='rest_login'),
path('logout', LogoutView.as_view(), name='rest_logout'),
path('signing', SigningView.as_view(), name='signing'),
path('rules', RulesView.as_view(), name='rules'),
@ -24,6 +32,7 @@ urlpatterns = [
if settings.IAM_TYPE == 'BASIC':
urlpatterns += [
path('register', RegisterViewEx.as_view(), name='rest_register'),
# password
path('password/reset', PasswordResetView.as_view(),
name='rest_password_reset'),
path('password/reset/confirm', PasswordResetConfirmView.as_view(),
@ -33,11 +42,18 @@ if settings.IAM_TYPE == 'BASIC':
]
if allauth_settings.EMAIL_VERIFICATION != \
allauth_settings.EmailVerificationMethod.NONE:
# emails
urlpatterns += [
re_path(r'^account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(),
re_path(r'^account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailViewEx.as_view(),
name='account_confirm_email'),
path('register/account-email-verification-sent', EmailVerificationSentView.as_view(),
name='account_email_verification_sent'),
]
if settings.USE_ALLAUTH_SOCIAL_ACCOUNTS:
# social accounts
urlpatterns += [
path('github/login/', github_login, name='github_login'),
path('github/login/callback/', github_callback, name='github_callback'),
path('google/login/', google_login, name='google_login'),
path('google/login/callback/', google_callback, name='google_callback'),
]
urlpatterns = [path('auth/', include(urlpatterns))]

@ -8,6 +8,7 @@ import hashlib
from django.core.exceptions import BadRequest
from django.utils.functional import SimpleLazyObject
from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
from rest_framework import views, serializers
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny
@ -16,12 +17,18 @@ from django.http import HttpResponse
from django.views.decorators.http import etag as django_etag
from rest_framework.response import Response
from dj_rest_auth.registration.views import RegisterView
from dj_rest_auth.views import LoginView
from allauth.account import app_settings as allauth_settings
from allauth.account.views import ConfirmEmailView
from allauth.account.utils import has_verified_email, send_email_confirmation
from allauth.socialaccount.providers.oauth2.views import OAuth2CallbackView, OAuth2LoginView
from furl import furl
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer, extend_schema_view
from drf_spectacular.contrib.rest_auth import get_token_serializer_class
from cvat.apps.iam.adapters import GitHubAdapter, GoogleAdapter
from .authentication import Signer
def get_context(request):
@ -111,6 +118,47 @@ class SigningView(views.APIView):
url = furl(url).add({Signer.QUERY_PARAM: sign}).url
return Response(url)
class LoginViewEx(LoginView):
"""
Check the credentials and return the REST Token
if the credentials are valid and authenticated.
If email verification is enabled and the user has the unverified email,
an email with a confirmation link will be sent.
Calls Django Auth login method to register User ID
in Django session framework.
Accept the following POST parameters: username, email, password
Return the REST Framework Token Object's key.
"""
@extend_schema(responses=get_token_serializer_class())
def post(self, request, *args, **kwargs):
self.request = request
self.serializer = self.get_serializer(data=self.request.data)
try:
self.serializer.is_valid(raise_exception=True)
except ValidationError:
user = self.serializer.get_auth_user(
self.serializer.data.get('username'),
self.serializer.data.get('email'),
self.serializer.data.get('password')
)
if not user:
raise
# Check that user's email is verified.
# If not, send a verification email.
if not has_verified_email(user):
send_email_confirmation(request, user)
# we cannot use redirect to ACCOUNT_EMAIL_VERIFICATION_SENT_REDIRECT_URL here
# because redirect will make a POST request and we'll get a 404 code
# (although in the browser request method will be displayed like GET)
return HttpResponseBadRequest('Unverified email')
except Exception: # nosec
pass
self.login()
return self.get_response()
class RegisterViewEx(RegisterView):
def get_response_data(self, user):
data = self.get_serializer(user).data
@ -163,3 +211,28 @@ class RulesView(views.APIView):
def get(self, request):
file_obj = open(self._get_bundle_path() ,"rb")
return HttpResponse(file_obj, content_type='application/x-tar')
class OAuth2CallbackViewEx(OAuth2CallbackView):
def dispatch(self, request, *args, **kwargs):
# Distinguish cancel from error
if (auth_error := request.GET.get('error', None)) and \
auth_error == self.adapter.login_cancelled_error:
return HttpResponseRedirect(settings.SOCIALACCOUNT_CALLBACK_CANCELLED_URL)
return super().dispatch(request, *args, **kwargs)
github_oauth2_login = OAuth2LoginView.adapter_view(GitHubAdapter)
github_oauth2_callback = OAuth2CallbackViewEx.adapter_view(GitHubAdapter)
google_oauth2_login = OAuth2LoginView.adapter_view(GoogleAdapter)
google_oauth2_callback = OAuth2CallbackViewEx.adapter_view(GoogleAdapter)
class ConfirmEmailViewEx(ConfirmEmailView):
template_name = 'account/email/email_confirmation_signup_message.html'
def get(self, *args, **kwargs):
try:
if not allauth_settings.CONFIRM_EMAIL_ON_GET:
return super().get(*args, **kwargs)
return self.post(*args, **kwargs)
except Http404:
return HttpResponseRedirect(settings.INCORRECT_EMAIL_CONFIRMATION_URL)

@ -120,6 +120,9 @@ INSTALLED_APPS = [
'allauth.account',
'corsheaders',
'allauth.socialaccount',
# social providers
'allauth.socialaccount.providers.github',
'allauth.socialaccount.providers.google',
'dj_rest_auth.registration',
'cvat.apps.iam',
'cvat.apps.dataset_manager',
@ -262,6 +265,8 @@ ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
# set UI url to redirect after a successful e-mail confirmation
#changed from '/auth/login' to '/auth/email-confirmation' for email confirmation message
ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/auth/email-confirmation'
ACCOUNT_EMAIL_VERIFICATION_SENT_REDIRECT_URL = '/auth/email-verification-sent'
INCORRECT_EMAIL_CONFIRMATION_URL = '/auth/incorrect-email-confirmation'
OLD_PASSWORD_FIELD_ENABLED = True
@ -574,3 +579,52 @@ SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': '/api',
'SCHEMA_PATH_PREFIX_TRIM': False,
}
# allauth configuration
USE_ALLAUTH_SOCIAL_ACCOUNTS = strtobool(os.getenv('USE_ALLAUTH_SOCIAL_ACCOUNTS') or 'False')
ACCOUNT_ADAPTER = 'cvat.apps.iam.adapters.DefaultAccountAdapterEx'
# the same in UI
ACCOUNT_USERNAME_MIN_LENGTH = 5
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
if USE_ALLAUTH_SOCIAL_ACCOUNTS:
SOCIALACCOUNT_ADAPTER = 'cvat.apps.iam.adapters.SocialAccountAdapterEx'
SOCIALACCOUNT_LOGIN_ON_GET = True
# It's required to define email in the case when a user has a private hidden email.
# (e.g in github account set keep my email addresses private)
# default = ACCOUNT_EMAIL_REQUIRED
SOCIALACCOUNT_QUERY_EMAIL = True
SOCIALACCOUNT_CALLBACK_CANCELLED_URL = '/auth/login'
GITHUB_CALLBACK_URL = 'http://localhost:8080/api/auth/github/login/callback/'
GOOGLE_CALLBACK_URL = 'http://localhost:8080/api/auth/google/login/callback/'
SOCIAL_AUTH_GOOGLE_CLIENT_ID = os.getenv('SOCIAL_AUTH_GOOGLE_CLIENT_ID')
SOCIAL_AUTH_GOOGLE_CLIENT_SECRET = os.getenv('SOCIAL_AUTH_GOOGLE_CLIENT_SECRET')
SOCIAL_AUTH_GITHUB_CLIENT_ID = os.getenv('SOCIAL_AUTH_GITHUB_CLIENT_ID')
SOCIAL_AUTH_GITHUB_CLIENT_SECRET = os.getenv('SOCIAL_AUTH_GITHUB_CLIENT_SECRET')
SOCIALACCOUNT_PROVIDERS = {
'google': {
'APP': {
'client_id': SOCIAL_AUTH_GOOGLE_CLIENT_ID,
'secret': SOCIAL_AUTH_GOOGLE_CLIENT_SECRET,
'key': ''
},
'SCOPE': [ 'profile', 'email', 'openid'],
'AUTH_PARAMS': {
'access_type': 'online',
}
},
'github': {
'APP': {
'client_id': SOCIAL_AUTH_GITHUB_CLIENT_ID,
'secret': SOCIAL_AUTH_GITHUB_CLIENT_SECRET,
'key': ''
},
'SCOPE': [ 'read:user', 'user:email' ],
},
}

@ -39,7 +39,14 @@ if UI_PORT and UI_PORT != '80':
UI_URL += ':{}'.format(UI_PORT)
# set UI url to redirect to after successful e-mail confirmation
ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '{}/auth/email-confirmation'.format(UI_URL)
ACCOUNT_EMAIL_VERIFICATION_SENT_REDIRECT_URL = '{}/auth/email-verification-sent'.format(UI_URL)
INCORRECT_EMAIL_CONFIRMATION_URL = '{}/auth/incorrect-email-confirmation'.format(UI_URL)
CORS_ORIGIN_WHITELIST = [UI_URL]
CORS_REPLACE_HTTPS_REFERER = True
IAM_OPA_DATA_URL = 'http://localhost:8181/v1/data'
if USE_ALLAUTH_SOCIAL_ACCOUNTS:
GITHUB_CALLBACK_URL = f'{UI_URL}/api/auth/github/login/callback/'
GOOGLE_CALLBACK_URL = f'{UI_URL}/api/auth/google/login/callback/'
SOCIALACCOUNT_CALLBACK_CANCELLED_URL = f'{UI_URL}/auth/login'

@ -42,6 +42,11 @@ services:
IAM_OPA_BUNDLE: '1'
no_proxy: elasticsearch,kibana,logstash,nuclio,opa,${no_proxy}
NUMPROCS: 1
USE_ALLAUTH_SOCIAL_ACCOUNTS: ""
SOCIAL_AUTH_GOOGLE_CLIENT_ID: ""
SOCIAL_AUTH_GOOGLE_CLIENT_SECRET: ""
SOCIAL_AUTH_GITHUB_CLIENT_ID: ""
SOCIAL_AUTH_GITHUB_CLIENT_SECRET: ""
command: -c supervisord/server.conf
labels:
- traefik.enable=true

@ -0,0 +1,13 @@
{{- if .Values.cvat.backend.server.secret.create }}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Release.Name }}-{{ .Values.cvat.backend.server.secret.name }}"
namespace: {{ .Release.Namespace }}
type: generic
stringData:
googleClientId: {{ .Values.cvat.backend.server.secret.socialAccountAuthentication.googleClientId | b64enc }}
googleClientSecret: {{ .Values.cvat.backend.server.secret.socialAccountAuthentication.googleClientSecret | b64enc }}
githubClientId: {{ .Values.cvat.backend.server.secret.socialAccountAuthentication.githubClientId | b64enc }}
githubClientSecret: {{ .Values.cvat.backend.server.secret.socialAccountAuthentication.githubClientSecret | b64enc }}
{{- end }}

@ -56,6 +56,30 @@ spec:
value: {{ .Values.cvat.backend.server.envs.ALLOWED_HOSTS | squote}}
- name: DJANGO_MODWSGI_EXTRA_ARGS
value: {{ .Values.cvat.backend.server.envs.DJANGO_MODWSGI_EXTRA_ARGS}}
- name: USE_ALLAUTH_SOCIAL_ACCOUNTS
value: {{ .Values.cvat.backend.server.envs.USE_ALLAUTH_SOCIAL_ACCOUNTS | squote }}
{{- if .Values.cvat.backend.server.envs.USE_ALLAUTH_SOCIAL_ACCOUNTS }}
- name: SOCIAL_AUTH_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Values.cvat.backend.server.secret.name }}"
key: googleClientId
- name: SOCIAL_AUTH_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Values.cvat.backend.server.secret.name }}"
key: googleClientSecret
- name: SOCIAL_AUTH_GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Values.cvat.backend.server.secret.name }}"
key: githubClientId
- name: SOCIAL_AUTH_GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Values.cvat.backend.server.secret.name }}"
key: googleClientSecret
{{- end }}
- name: IAM_OPA_BUNDLE
value: "1"
{{- if .Values.redis.enabled }}

@ -19,6 +19,15 @@ cvat:
envs:
ALLOWED_HOSTS: "*"
DJANGO_MODWSGI_EXTRA_ARGS: ""
USE_ALLAUTH_SOCIAL_ACCOUNTS: false
secret:
create: true
name: cvat-server-secret
socialAccountAuthentication:
googleClientId: ""
googleClientSecret: ""
githubClientId: ""
githubClientSecret: ""
additionalEnv: []
additionalVolumes: []
additionalVolumeMounts: []

@ -56,7 +56,7 @@ helm dependency update
traefik:
service:
externalIPs:
- "your minikube IP (can be obtained with `minicube ip` command)"
- "your minikube IP (can be obtained with `minikube ip` command)"
```
- Also ensure that your CVAT ingress appears on your hosts file (/etc/hosts).
You can do this by running this command:

Loading…
Cancel
Save