diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2f7bc279e4..c4cf8371fceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) () - Added Webhooks () +- Authentication with social accounts google & github () ### Changed - `api/docs`, `api/swagger`, `api/schema`, `server/about` endpoints now allow unauthorized access (, ) diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 7f6a0e5db3d1..2fa44cc8b8e7 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -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); }; diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index ccd216ae207e..97a9dd904536 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -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 diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 5e163160b923..0cabe75518b1 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -2196,6 +2196,18 @@ class ServerProxy { } } + async function advancedAuthentication(): Promise { + 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, diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index 143a8be16f84..aafce8dd26ad 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -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; @@ -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 => { + dispatch(authActions.loadAdvancedAuth()); + try { + const list: AdvancedAuthMethodsList = await cvat.server.advancedAuthentication(); + dispatch(authActions.loadAdvancedAuthSuccess(list)); + } catch (error) { + dispatch(authActions.loadAdvancedAuthFailed(error)); + } +}; diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index f38a0b01b9e5..7edaa6fbc67f 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -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 + + + + + +

Please, confirm your email

+ + +
+
+ + ); +} diff --git a/cvat-ui/src/components/email-confirmation-pages/incorrect-email-confirmation.tsx b/cvat-ui/src/components/email-confirmation-pages/incorrect-email-confirmation.tsx new file mode 100644 index 000000000000..6a11e6179071 --- /dev/null +++ b/cvat-ui/src/components/email-confirmation-pages/incorrect-email-confirmation.tsx @@ -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 ( + + + + +

+ This e-mail confirmation link expired or is invalid. +

+

+ Please issue a new e-mail confirmation request. +

+ + +
+
+
+ ); +} diff --git a/cvat-ui/src/components/email-confirmation-page/styles.scss b/cvat-ui/src/components/email-confirmation-pages/styles.scss similarity index 50% rename from cvat-ui/src/components/email-confirmation-page/styles.scss rename to cvat-ui/src/components/email-confirmation-pages/styles.scss index ee645cbba63f..5701e244bf30 100644 --- a/cvat-ui/src/components/email-confirmation-page/styles.scss +++ b/cvat-ui/src/components/email-confirmation-pages/styles.scss @@ -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; } diff --git a/cvat-ui/src/components/login-page/login-page.tsx b/cvat-ui/src/components/login-page/login-page.tsx index 9572a1ac476e..ee15d4557456 100644 --- a/cvat-ui/src/components/login-page/login-page.tsx +++ b/cvat-ui/src/components/login-page/login-page.tsx @@ -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 ( @@ -43,6 +66,41 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps onLogin(loginData.credential, loginData.password); }} /> + {(googleAuthentication || githubAuthentication) && + ( + <> + + + or + + + + {googleAuthentication && ( + + + + )} + {githubAuthentication && ( + + + + )} + + + )} diff --git a/cvat-ui/src/containers/login-page/login-page.tsx b/cvat-ui/src/containers/login-page/login-page.tsx index 3f6e4d205959..be23a08f84f4 100644 --- a/cvat-ui/src/containers/login-page/login-page.tsx +++ b/cvat-ui/src/containers/login-page/login-page.tsx @@ -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); diff --git a/cvat-ui/src/reducers/auth-reducer.ts b/cvat-ui/src/reducers/auth-reducer.ts index 362130977498..a7687bd4bb43 100644 --- a/cvat-ui/src/reducers/auth-reducer.ts +++ b/cvat-ui/src/reducers/auth-reducer.ts @@ -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 }; } diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index 5c7c5c1d5e85..ae59994cf95f 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -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 { diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 53b4c7635410..a4dba89ba896 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -28,7 +28,7 @@ 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 @@ def plugins(request): '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 @@ def get_queryset(self): 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 @@ def perform_update(self, serializer): 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 @@ def get_serializer_class(self): 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 @@ def get_serializer_class(self): 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']) diff --git a/cvat/apps/iam/adapters.py b/cvat/apps/iam/adapters.py new file mode 100644 index 000000000000..e09737db2c5b --- /dev/null +++ b/cvat/apps/iam/adapters.py @@ -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 diff --git a/cvat/apps/iam/permissions.py b/cvat/apps/iam/permissions.py index 4774ea378b59..0d25c6d0abf9 100644 --- a/cvat/apps/iam/permissions.py +++ b/cvat/apps/iam/permissions.py @@ -275,7 +275,8 @@ def get_scopes(request, view, obj): '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): diff --git a/cvat/apps/iam/rules/server.csv b/cvat/apps/iam/rules/server.csv index 7291fb6c0878..03aa75b78755 100644 --- a/cvat/apps/iam/rules/server.csv +++ b/cvat/apps/iam/rules/server.csv @@ -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 \ No newline at end of file diff --git a/cvat/apps/iam/serializers.py b/cvat/apps/iam/serializers.py index a805385431e4..9fa85b8a2b47 100644 --- a/cvat/apps/iam/serializers.py +++ b/cvat/apps/iam/serializers.py @@ -44,6 +44,26 @@ def get_email_options(self): 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) diff --git a/cvat/apps/iam/templates/account/email/email_confirmation_message.html b/cvat/apps/iam/templates/account/email/email_confirmation_message.html new file mode 100644 index 000000000000..8cd8294636d3 --- /dev/null +++ b/cvat/apps/iam/templates/account/email/email_confirmation_message.html @@ -0,0 +1,288 @@ + +{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %} +{% load static %} + + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + Logo + +
+ +
+ + + + + +
+

+ Confirm Your Email Address +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ {% blocktrans with site_name=current_site.name site_domain=current_site.domain %} +

+ Thank you for signing up for CVAT! +

+

+ To complete registration and start annotating, simply tap the button below and confirm your email address. + If you didn't create an account with {{ site_name }}, + you can safely delete this email. +

+ {% endblocktrans %} +
+ + + + +
+ + + + +
+ + Confirm + +
+
+
+
+ {% blocktrans with site_name=current_site.name site_domain=current_site.domain %} +

+ {{ site_domain }} +

+ {% endblocktrans %} {% endautoescape %} +
+ +
+ + + + + + + + + +
+

If you didn't request this, please ignore this email.

+
+ +
+ + + + diff --git a/cvat/apps/iam/templates/account/email/email_confirmation_signup_message.html b/cvat/apps/iam/templates/account/email/email_confirmation_signup_message.html index ec80776a07f7..aa4ccd35a9a5 100644 --- a/cvat/apps/iam/templates/account/email/email_confirmation_signup_message.html +++ b/cvat/apps/iam/templates/account/email/email_confirmation_signup_message.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 }}! - -

- You're receiving this e-mail because user {{ user_display }} has given yours as an e-mail address - to connect their account. -

- -

To confirm this is correct, go to {{ activate_url }}

-{% endblocktrans %} -{% blocktrans with site_name=current_site.name site_domain=current_site.domain %} -{{ site_domain }} -{% endblocktrans %} {% endautoescape %} +{% include "account/email/email_confirmation_message.html" %} diff --git a/cvat/apps/iam/templates/account/email/email_confirmation_subject.txt b/cvat/apps/iam/templates/account/email/email_confirmation_subject.txt new file mode 100644 index 000000000000..39df3dedd22f --- /dev/null +++ b/cvat/apps/iam/templates/account/email/email_confirmation_subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Confirm your email adress{% endblocktrans %} +{% endautoescape %} \ No newline at end of file diff --git a/cvat/apps/iam/tests/test_rest_api.py b/cvat/apps/iam/tests/test_rest_api.py index b02850a8d45e..5d539e8a53ea 100644 --- a/cvat/apps/iam/tests/test_rest_api.py +++ b/cvat/apps/iam/tests/test_rest_api.py @@ -8,12 +8,13 @@ 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[-:\w]+)/$', ConfirmEmailView.as_view(), + re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailViewEx.as_view(), name='account_confirm_email'), path('register/account-email-verification-sent', EmailVerificationSentView.as_view(), name='account_email_verification_sent'), diff --git a/cvat/apps/iam/urls.py b/cvat/apps/iam/urls.py index 55e45cb6cfec..959fa9b93873 100644 --- a/cvat/apps/iam/urls.py +++ b/cvat/apps/iam/urls.py @@ -7,15 +7,23 @@ 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 @@ 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 allauth_settings.EMAIL_VERIFICATION != \ allauth_settings.EmailVerificationMethod.NONE: + # emails urlpatterns += [ - re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailView.as_view(), + re_path(r'^account-confirm-email/(?P[-:\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))] diff --git a/cvat/apps/iam/views.py b/cvat/apps/iam/views.py index 42f6edb16fbe..7309f68a443d 100644 --- a/cvat/apps/iam/views.py +++ b/cvat/apps/iam/views.py @@ -8,6 +8,7 @@ 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.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 @@ def post(self, request): 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 @@ def _etag_func(file_path): 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) diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 1ce2842d82a2..cfae16251f91 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -120,6 +120,9 @@ def add_ssh_keys(): '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 @@ def add_ssh_keys(): # 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 @@ def add_ssh_keys(): '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' ], + }, + } diff --git a/cvat/settings/development.py b/cvat/settings/development.py index 06c26f047d63..26b55e0c8fab 100644 --- a/cvat/settings/development.py +++ b/cvat/settings/development.py @@ -39,7 +39,14 @@ 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' diff --git a/docker-compose.yml b/docker-compose.yml index aceefd9168b4..644db0cbac72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/helm-chart/templates/cvat-server-secret.yml b/helm-chart/templates/cvat-server-secret.yml new file mode 100644 index 000000000000..137bc743c741 --- /dev/null +++ b/helm-chart/templates/cvat-server-secret.yml @@ -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 }} \ No newline at end of file diff --git a/helm-chart/templates/cvat_backend/server/deployment.yml b/helm-chart/templates/cvat_backend/server/deployment.yml index 38d1f581b931..13d554db391f 100644 --- a/helm-chart/templates/cvat_backend/server/deployment.yml +++ b/helm-chart/templates/cvat_backend/server/deployment.yml @@ -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 }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 4c6349a3f5da..cb0fd121d3ab 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -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: [] diff --git a/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md b/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md index 084244a5c248..ed2196b0b966 100644 --- a/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md +++ b/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md @@ -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: