Social account authentication tests (#5444)

Depends on #5349
Related #5432
Added tests for social account authentication functionality: cypress
test with dummy auth server
main
Maria Khrustaleva 3 years ago committed by GitHub
parent 71a0aaf2bb
commit b00bc653ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -327,11 +327,13 @@ jobs:
- name: Run CVAT instance
run: |
docker compose \
--env-file="tests/python/social_auth/.env" \
-f docker-compose.yml \
-f docker-compose.dev.yml \
-f components/serverless/docker-compose.serverless.yml \
-f tests/docker-compose.minio.yml \
-f tests/docker-compose.file_share.yml up -d
-f tests/docker-compose.file_share.yml \
-f tests/python/social_auth/docker-compose.yml up -d
- name: Waiting for server
env:

@ -294,12 +294,13 @@ jobs:
- name: Run CVAT instance
run: |
docker compose \
--env-file="tests/python/social_auth/.env" \
-f docker-compose.yml \
-f docker-compose.dev.yml \
-f components/serverless/docker-compose.serverless.yml \
-f tests/docker-compose.minio.yml \
-f tests/docker-compose.file_share.yml up -d
-f tests/docker-compose.file_share.yml \
-f tests/python/social_auth/docker-compose.yml up -d
- name: Waiting for server
env:
API_ABOUT_PAGE: "localhost:8080/api/server/about"

@ -319,11 +319,13 @@ jobs:
- name: Run CVAT instance
run: |
docker compose \
--env-file="tests/python/social_auth/.env" \
-f docker-compose.yml \
-f docker-compose.dev.yml \
-f tests/docker-compose.file_share.yml \
-f tests/docker-compose.minio.yml \
-f components/serverless/docker-compose.serverless.yml up -d
-f components/serverless/docker-compose.serverless.yml \
-f tests/python/social_auth/docker-compose.yml up -d
- name: Waiting for server
id: wait-server

@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- \[SDK\] A PyTorch adapter setting to disable cache updates
(<https://github.com/opencv/cvat/pull/5549>)
- YOLO v7 serverless feature added using ONNX backend (<https://github.com/opencv/cvat/pull/5552>)
- Cypress test for social account authentication (<https://github.com/opencv/cvat/pull/5444>)
- Dummy github and google authentication servers (<https://github.com/opencv/cvat/pull/5444>)
### Changed
- The Docker Compose files now use the Compose Specification version

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

@ -3,6 +3,7 @@
//
// SPDX-License-Identifier: MIT
import { SocialAuthMethod, SocialAuthMethodsRawType } from './auth-methods';
import config from './config';
import PluginRegistry from './plugins';
@ -85,9 +86,9 @@ export default function implementAPI(cvat) {
await serverProxy.server.logout();
};
cvat.server.advancedAuthentication.implementation = async () => {
const result = await serverProxy.server.advancedAuthentication();
return result;
cvat.server.socialAuthentication.implementation = async () => {
const result: SocialAuthMethodsRawType = await serverProxy.server.socialAuthentication();
return Object.entries(result).map(([provider, value]) => new SocialAuthMethod({ ...value, provider }));
};
cvat.server.changePassword.implementation = async (oldPassword, newPassword1, newPassword2) => {

@ -77,8 +77,8 @@ function build() {
const result = await PluginRegistry.apiWrapper(cvat.server.logout);
return result;
},
async advancedAuthentication() {
const result = await PluginRegistry.apiWrapper(cvat.server.advancedAuthentication);
async socialAuthentication() {
const result = await PluginRegistry.apiWrapper(cvat.server.socialAuthentication);
return result;
},
async changePassword(oldPassword, newPassword1, newPassword2) {

@ -0,0 +1,57 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
interface SocialAuthMethodCamelCase {
provider: string;
publicName: string;
isEnabled: boolean;
icon: string;
}
interface SocialAuthMethodSnakeCase {
public_name: string;
is_enabled: boolean;
icon: string;
provider?: string;
}
export class SocialAuthMethod {
public provider: string;
public publicName: string;
public isEnabled: boolean;
public icon: string;
constructor(initialData: SocialAuthMethodSnakeCase) {
const data: SocialAuthMethodCamelCase = {
provider: initialData.provider,
publicName: initialData.public_name,
isEnabled: initialData.is_enabled,
icon: initialData.icon,
};
Object.defineProperties(
this,
Object.freeze({
provider: {
get: () => data.provider,
},
publicName: {
get: () => data.publicName,
},
isEnabled: {
get: () => data.isEnabled,
},
icon: {
get: () => data.icon,
},
}),
);
}
}
export type SocialAuthMethodsRawType = {
[index: string]: SocialAuthMethodSnakeCase;
};
export type SocialAuthMethods = SocialAuthMethod[];

@ -2250,10 +2250,10 @@ async function receiveWebhookEvents(type: WebhookSourceType): Promise<string[]>
}
}
async function advancedAuthentication(): Promise<any> {
async function socialAuthentication(): Promise<any> {
const { backendAPI } = config;
try {
const response = await Axios.get(`${backendAPI}/server/advanced-auth`, {
const response = await Axios.get(`${backendAPI}/auth/social/methods`, {
proxy: config.proxy,
});
return response.data;
@ -2270,7 +2270,7 @@ export default Object.freeze({
exception,
login,
logout,
advancedAuthentication,
socialAuthentication,
changePassword,
requestPasswordReset,
resetPassword,

@ -7,7 +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';
import { SocialAuthMethods } from '../cvat-core-wrapper';
const cvat = getCore();
@ -36,9 +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',
LOAD_SOCIAL_AUTHENTICATION = 'LOAD_SOCIAL_AUTHENTICATION',
LOAD_SOCIAL_AUTHENTICATION_SUCCESS = 'LOAD_SOCIAL_AUTHENTICATION_SUCCESS',
LOAD_SOCIAL_AUTHENTICATION_FAILED = 'LOAD_SOCIAL_AUTHENTICATION_FAILED',
}
export const authActions = {
@ -75,12 +75,12 @@ 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 })
loadSocialAuth: () => createAction(AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION),
loadSocialAuthSuccess: (methods: SocialAuthMethods) => (
createAction(AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION_SUCCESS, { methods })
),
loadAdvancedAuthFailed: (error: any) => (
createAction(AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_FAILED, { error })
loadSocialAuthFailed: (error: any) => (
createAction(AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION_FAILED, { error })
),
};
@ -210,12 +210,12 @@ export const loadAuthActionsAsync = (): ThunkAction => async (dispatch) => {
}
};
export const loadAdvancedAuthAsync = (): ThunkAction => async (dispatch): Promise<void> => {
dispatch(authActions.loadAdvancedAuth());
export const loadSocialAuthAsync = (): ThunkAction => async (dispatch): Promise<void> => {
dispatch(authActions.loadSocialAuth());
try {
const list: AdvancedAuthMethodsList = await cvat.server.advancedAuthentication();
dispatch(authActions.loadAdvancedAuthSuccess(list));
const methods: SocialAuthMethods = await cvat.server.socialAuthentication();
dispatch(authActions.loadSocialAuthSuccess(methods));
} catch (error) {
dispatch(authActions.loadAdvancedAuthFailed(error));
dispatch(authActions.loadSocialAuthFailed(error));
}
};

@ -10,9 +10,8 @@ import { Row, Col } from 'antd/lib/grid';
import SigningLayout, { formSizes } from 'components/signing-common/signing-layout';
import SocialAccountLink from 'components/signing-common/social-account-link';
import { SocialGithubLogo, SocialGoogleLogo } from 'icons';
import LoginForm, { LoginData } from './login-form';
import { getCore } from '../../cvat-core-wrapper';
import { getCore, SocialAuthMethods, SocialAuthMethod } from '../../cvat-core-wrapper';
const cvat = getCore();
@ -20,18 +19,31 @@ interface LoginPageComponentProps {
fetching: boolean;
renderResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
googleAuthentication: boolean;
githubAuthentication: boolean;
socialAuthMethods: SocialAuthMethods;
onLogin: (credential: string, password: string) => void;
loadAdvancedAuthenticationMethods: () => void;
loadSocialAuthenticationMethods: () => void;
}
const renderSocialAuthMethods = (methods: SocialAuthMethods): JSX.Element[] => {
const { backendAPI } = cvat.config;
return methods.map((item: SocialAuthMethod) => ((item.isEnabled) ? (
<SocialAccountLink
key={item.provider}
icon={item.icon}
href={`${backendAPI}/auth/${item.provider}/login`}
className={`cvat-social-authentication-${item.provider}`}
>
{`Continue with ${item.publicName}`}
</SocialAccountLink>
) : <></>));
};
function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps): JSX.Element {
const history = useHistory();
const { backendAPI } = cvat.config;
const {
fetching, renderResetPassword, hasEmailVerificationBeenSent,
googleAuthentication, githubAuthentication, onLogin, loadAdvancedAuthenticationMethods,
socialAuthMethods, onLogin, loadSocialAuthenticationMethods,
} = props;
if (hasEmailVerificationBeenSent) {
@ -39,7 +51,7 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps
}
useEffect(() => {
loadAdvancedAuthenticationMethods();
loadSocialAuthenticationMethods();
}, []);
return (
@ -50,26 +62,9 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps
<LoginForm
fetching={fetching}
renderResetPassword={renderResetPassword}
socialAuthentication={(googleAuthentication || githubAuthentication) ? (
socialAuthentication={(socialAuthMethods) ? (
<div className='cvat-social-authentication'>
{githubAuthentication && (
<SocialAccountLink
icon={SocialGithubLogo}
href={`${backendAPI}/auth/github/login`}
className='cvat-social-authentication-github'
>
Continue with GitHub
</SocialAccountLink>
)}
{googleAuthentication && (
<SocialAccountLink
icon={SocialGoogleLogo}
href={`${backendAPI}/auth/google/login`}
className='cvat-social-authentication-google'
>
Continue with Google
</SocialAccountLink>
)}
{renderSocialAuthMethods(socialAuthMethods)}
</div>
) : null}
onSubmit={(loginData: LoginData): void => {

@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
import React, { useEffect } from 'react';
import { useLocation, useHistory } from 'react-router';
import { Redirect, useLocation, useHistory } from 'react-router';
import notification from 'antd/lib/notification';
import Spin from 'antd/lib/spin';
@ -29,6 +29,7 @@ export default function LoginWithSocialAppComponent(): JSX.Element {
.catch((exception: Error) => {
if (exception.message.includes('Unverified email')) {
history.push('/auth/email-verification-sent');
return Promise.resolve();
}
history.push('/auth/login');
notification.error({
@ -40,6 +41,10 @@ export default function LoginWithSocialAppComponent(): JSX.Element {
}
}, []);
if (localStorage.getItem('token')) {
return <Redirect to={search.get('next') || '/tasks'} />;
}
return (
<div className='cvat-login-page cvat-spinner-container'>
<Spin size='large' className='cvat-spinner' />

@ -5,20 +5,27 @@ import './styles.scss';
import React from 'react';
import { Col, Row } from 'antd/lib/grid';
import Button from 'antd/lib/button/button';
import Icon from '@ant-design/icons';
import { CustomIconComponentProps } from '@ant-design/icons/lib/components/Icon';
interface SocialAccountLinkProps {
children: string;
className?: string;
href: string;
icon: React.ForwardRefExoticComponent<CustomIconComponentProps>;
icon: string;
}
function SocialAccountLink(props: SocialAccountLinkProps): JSX.Element {
const svgWrapperRef = React.useRef();
const {
children, className, href, icon,
} = props;
React.useEffect(() => {
if (icon) {
// eslint-disable-next-line no-unsanitized/property
svgWrapperRef.current.innerHTML = icon;
}
}, [icon, svgWrapperRef.current]);
return (
<Row>
<Col flex='auto'>
@ -28,7 +35,10 @@ function SocialAccountLink(props: SocialAccountLinkProps): JSX.Element {
>
<Row align='middle' style={{ width: '100%' }}>
<Col>
<Icon component={icon} />
<div
ref={svgWrapperRef as any}
className='cvat-social-authentication-icon'
/>
</Col>
<Col flex='auto'>
{children}

@ -5,19 +5,19 @@
import { connect } from 'react-redux';
import LoginPageComponent from 'components/login-page/login-page';
import { CombinedState } from 'reducers';
import { loginAsync, loadAdvancedAuthAsync } from 'actions/auth-actions';
import { loginAsync, loadSocialAuthAsync } from 'actions/auth-actions';
import { SocialAuthMethods } from 'cvat-core-wrapper';
interface StateToProps {
fetching: boolean;
renderResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
googleAuthentication: boolean;
githubAuthentication: boolean;
socialAuthMethods: SocialAuthMethods;
}
interface DispatchToProps {
onLogin: typeof loginAsync;
loadAdvancedAuthenticationMethods: typeof loadAdvancedAuthAsync;
loadSocialAuthenticationMethods: typeof loadSocialAuthAsync;
}
function mapStateToProps(state: CombinedState): StateToProps {
@ -25,14 +25,13 @@ function mapStateToProps(state: CombinedState): StateToProps {
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,
socialAuthMethods: state.auth.socialAuthMethods,
};
}
const mapDispatchToProps: DispatchToProps = {
onLogin: loginAsync,
loadAdvancedAuthenticationMethods: loadAdvancedAuthAsync,
loadSocialAuthenticationMethods: loadSocialAuthAsync,
};
export default connect(mapStateToProps, mapDispatchToProps)(LoginPageComponent);

@ -10,6 +10,7 @@ import {
} from 'cvat-core/src/labels';
import { ShapeType, LabelType } from 'cvat-core/src/enums';
import { Storage, StorageData } from 'cvat-core/src/storage';
import { SocialAuthMethods, SocialAuthMethod } from 'cvat-core/src/auth-methods';
const cvat: any = _cvat;
@ -31,10 +32,12 @@ export {
LabelType,
Storage,
Webhook,
SocialAuthMethod,
};
export type {
RawAttribute,
RawLabel,
StorageData,
SocialAuthMethods,
};

@ -62,8 +62,6 @@ import SVGMultiPlusIcon from './assets/multi-plus-icon.svg';
import SVGBackArrowIcon from './assets/back-arrow-icon.svg';
import SVGClearIcon from './assets/clear-icon.svg';
import SVGShowPasswordIcon from './assets/show-password.svg';
import SVGSocialGithubLogo from './assets/social-github-logo.svg';
import SVGSocialGoogleLogo from './assets/social-google-logo.svg';
import SVGPlusIcon from './assets/plus-icon.svg';
import SVGCheckIcon from './assets/check-icon.svg';
@ -124,7 +122,5 @@ export const MutliPlusIcon = React.memo((): JSX.Element => <SVGMultiPlusIcon />)
export const BackArrowIcon = React.memo((): JSX.Element => <SVGBackArrowIcon />);
export const ClearIcon = React.memo((): JSX.Element => <SVGClearIcon />);
export const ShowPasswordIcon = React.memo((): JSX.Element => <SVGShowPasswordIcon />);
export const SocialGithubLogo = React.memo((): JSX.Element => <SVGSocialGithubLogo />);
export const SocialGoogleLogo = React.memo((): JSX.Element => <SVGSocialGoogleLogo />);
export const PlusIcon = React.memo((): JSX.Element => <SVGPlusIcon />);
export const CheckIcon = React.memo((): JSX.Element => <SVGCheckIcon />);

@ -16,12 +16,9 @@ const defaultState: AuthState = {
showChangePasswordDialog: false,
allowResetPassword: false,
hasEmailVerificationBeenSent: false,
advancedAuthFetching: false,
advancedAuthInitialized: false,
advancedAuthList: {
GOOGLE_ACCOUNT_AUTHENTICATION: false,
GITHUB_ACCOUNT_AUTHENTICATION: false,
},
socialAuthFetching: false,
socialAuthInitialized: false,
socialAuthMethods: [],
};
export default function (state = defaultState, action: AuthActions | BoundariesActions): AuthState {
@ -160,27 +157,27 @@ export default function (state = defaultState, action: AuthActions | BoundariesA
allowChangePassword: false,
allowResetPassword: false,
};
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION: {
case AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION: {
return {
...state,
advancedAuthFetching: true,
advancedAuthInitialized: false,
socialAuthFetching: true,
socialAuthInitialized: false,
};
}
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_SUCCESS: {
const { list } = action.payload;
case AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION_SUCCESS: {
const { methods } = action.payload;
return {
...state,
advancedAuthFetching: false,
advancedAuthInitialized: true,
advancedAuthList: list,
socialAuthFetching: false,
socialAuthInitialized: true,
socialAuthMethods: methods,
};
}
case AuthActionTypes.LOAD_ADVANCED_AUTHENTICATION_FAILED: {
case AuthActionTypes.LOAD_SOCIAL_AUTHENTICATION_FAILED: {
return {
...state,
advancedAuthFetching: false,
advancedAuthInitialized: true,
socialAuthFetching: false,
socialAuthInitialized: true,
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR: {

@ -5,7 +5,7 @@
import { Canvas3d } from 'cvat-canvas3d/src/typescript/canvas3d';
import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper';
import { Webhook } from 'cvat-core-wrapper';
import { Webhook, SocialAuthMethods } from 'cvat-core-wrapper';
import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors';
import { KeyMap } from 'utils/mousetrap-react';
import { OpenCVTracker } from 'utils/opencv-wrapper/opencv-interfaces';
@ -14,15 +14,6 @@ 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;
@ -33,9 +24,9 @@ export interface AuthState {
allowChangePassword: boolean;
allowResetPassword: boolean;
hasEmailVerificationBeenSent: boolean;
advancedAuthFetching: boolean;
advancedAuthInitialized: boolean;
advancedAuthList: AdvancedAuthMethodsList;
socialAuthFetching: boolean;
socialAuthInitialized: boolean;
socialAuthMethods: SocialAuthMethods;
}
export interface ProjectsQuery {

@ -27,7 +27,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, inline_serializer
extend_schema_view, extend_schema
)
from drf_spectacular.plumbing import build_array_type, build_basic_type
@ -234,39 +234,6 @@ class ServerViewSet(viewsets.ViewSet):
}
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 = bool(
'google' in integrated_auth_providers
and settings.SOCIAL_AUTH_GOOGLE_CLIENT_ID
and settings.SOCIAL_AUTH_GOOGLE_CLIENT_SECRET
)
github_auth_is_enabled = bool(
'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)
@extend_schema(tags=['projects'])
@extend_schema_view(
list=extend_schema(

@ -40,9 +40,11 @@ class SocialAccountAdapterEx(DefaultSocialAccountAdapter):
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

@ -368,7 +368,6 @@ class ServerPermission(OpenPolicyAgentPermission):
'exception': Scopes.SEND_EXCEPTION,
'logs': Scopes.SEND_LOGS,
'share': Scopes.LIST_CONTENT,
'advanced_authentication': Scopes.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, /server/advanced-auth",None,N/A
view,N/A,N/A,N/A,,GET,"/server/about, /server/annotation/formats, /server/plugins",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/advanced-auth /server/about, /server/annotation/formats, /server/plugins 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

@ -98,3 +98,9 @@ class SocialLoginSerializerEx(SocialLoginSerializer):
}
return social_login
class SocialAuthMethodSerializer(serializers.Serializer):
is_enabled = serializers.BooleanField(default=False)
icon = serializers.CharField(allow_blank=True, allow_null=True, required=False)
public_name = serializers.CharField()

@ -20,6 +20,7 @@ from cvat.apps.iam.views import (
google_oauth2_login as google_login,
google_oauth2_callback as google_callback,
LoginViewEx, GitHubLogin, GoogleLogin,
SocialAuthMethods,
)
urlpatterns = [
@ -39,6 +40,7 @@ if settings.IAM_TYPE == 'BASIC':
name='rest_password_reset_confirm'),
path('password/change', PasswordChangeView.as_view(),
name='rest_password_change'),
path('social/methods/', SocialAuthMethods.as_view(), name='social_auth_methods'),
]
if allauth_settings.EMAIL_VERIFICATION != \
allauth_settings.EmailVerificationMethod.NONE:

@ -1,8 +1,9 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2021-2023 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import os.path as osp
import functools
import hashlib
@ -19,6 +20,7 @@ from django.views.decorators.http import etag as django_etag
from rest_framework.response import Response
from dj_rest_auth.registration.views import RegisterView, SocialLoginView
from dj_rest_auth.views import LoginView
from dj_rest_auth.utils import import_callable
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
@ -34,7 +36,18 @@ from drf_spectacular.contrib.rest_auth import get_token_serializer_class
from cvat.apps.iam.adapters import GitHubAdapter, GoogleAdapter
from .authentication import Signer
from cvat.apps.iam.serializers import SocialLoginSerializerEx
from cvat.apps.iam.serializers import SocialLoginSerializerEx, SocialAuthMethodSerializer
GitHubAdapter = (
import_callable(settings.SOCIALACCOUNT_GITHUB_ADAPTER)
if settings.USE_ALLAUTH_SOCIAL_ACCOUNTS
else None
)
GoogleAdapter = (
import_callable(settings.SOCIALACCOUNT_GOOGLE_ADAPTER)
if settings.USE_ALLAUTH_SOCIAL_ACCOUNTS
else None
)
def get_context(request):
from cvat.apps.organizations.models import Organization, Membership
@ -355,3 +368,51 @@ class GoogleLogin(SocialLoginViewEx):
adapter_class = GoogleAdapter
client_class = OAuth2Client
callback_url = getattr(settings, 'GOOGLE_CALLBACK_URL', None)
@extend_schema_view(
get=extend_schema(
summary='Method provides a list with integrated social accounts authentication.',
responses={
'200': OpenApiResponse(response=inline_serializer(
name="SocialAuthMethodsSerializer",
fields={
'google': SocialAuthMethodSerializer(),
'github': SocialAuthMethodSerializer(),
}
)),
}
)
)
class SocialAuthMethods(views.APIView):
serializer_class = SocialAuthMethodSerializer
permission_classes = [AllowAny]
authentication_classes = []
iam_organization_field = None
def get(self, request, *args, **kwargs):
use_social_auth = settings.USE_ALLAUTH_SOCIAL_ACCOUNTS
integrated_auth_providers = settings.SOCIALACCOUNT_PROVIDERS.keys() if use_social_auth else []
response = dict()
for provider in integrated_auth_providers:
icon = None
is_enabled = bool(
getattr(settings, f'SOCIAL_AUTH_{provider.upper()}_CLIENT_ID', None)
and getattr(settings, f'SOCIAL_AUTH_{provider.upper()}_CLIENT_SECRET', None)
)
icon_path = osp.join(settings.STATIC_ROOT, 'social_authentication', f'social-{provider}-logo.svg')
if is_enabled and osp.exists(icon_path):
with open(icon_path, 'r') as f:
icon = f.read()
serializer = SocialAuthMethodSerializer(data={
'is_enabled': is_enabled,
'icon': icon,
'public_name': settings.SOCIALACCOUNT_PROVIDERS[provider].get('PUBLIC_NAME', provider.title())
})
serializer.is_valid(raise_exception=True)
response[provider] = serializer.validated_data
return Response(response)

@ -632,6 +632,8 @@ ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
if USE_ALLAUTH_SOCIAL_ACCOUNTS:
SOCIALACCOUNT_ADAPTER = 'cvat.apps.iam.adapters.SocialAccountAdapterEx'
SOCIALACCOUNT_GITHUB_ADAPTER = 'cvat.apps.iam.adapters.GitHubAdapter'
SOCIALACCOUNT_GOOGLE_ADAPTER = 'cvat.apps.iam.adapters.GoogleAdapter'
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)
@ -660,7 +662,7 @@ if USE_ALLAUTH_SOCIAL_ACCOUNTS:
'SCOPE': [ 'profile', 'email', 'openid'],
'AUTH_PARAMS': {
'access_type': 'online',
}
},
},
'github': {
'APP': {
@ -669,5 +671,10 @@ if USE_ALLAUTH_SOCIAL_ACCOUNTS:
'key': ''
},
'SCOPE': [ 'read:user', 'user:email' ],
# NOTE: Custom field. This is necessary for the user interface
# to render possible social account authentication option.
# If this field is not specified, then the option with the provider
# key with a capital letter will be used
'PUBLIC_NAME': 'GitHub',
},
}

@ -11,11 +11,13 @@ description: 'Instructions on how to run all existence tests.'
1. Run CVAT instance:
```
docker compose \
--env-file="tests/python/social_auth/.env" \
-f docker-compose.yml \
-f docker-compose.dev.yml \
-f components/serverless/docker-compose.serverless.yml \
-f tests/docker-compose.minio.yml \
-f tests/docker-compose.file_share.yml up -d
-f tests/docker-compose.file_share.yml \
-f tests/python/social_auth/docker-compose.yml up -d
```
1. Add test user in CVAT:
```

@ -96,5 +96,36 @@ context('When clicking on the Logout button, get the user session closed.', () =
cy.url().should('include', '/auth/login');
cy.closeNotification('.cvat-notification-notice-login-failed');
});
it('Login with Google and GitHub. Logout', () => {
let socialAuthMethods;
cy.request({
method: 'GET',
url: '/api/auth/social/methods/',
}).then((response) => {
socialAuthMethods = Object.keys(response.body);
expect(socialAuthMethods).length.gt(0);
cy.visit('auth/login');
cy.get('.cvat-social-authentication-icon').should('have.length', socialAuthMethods.length).within((items) => {
for (const item of items) {
expect(item.children.length).to.be.equal(1); // check that icon was received from the server
}
});
for (const provider of socialAuthMethods) {
let username = '';
cy.get(`.cvat-social-authentication-${provider}`).should('be.visible').click();
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('.cvat-right-header').should('exist').and('be.visible').within(() => {
cy.get('.cvat-header-menu-user-dropdown-user').should(($div) => {
username = $div.text();
});
}).then(() => {
cy.logout(username);
});
}
});
});
});
});

@ -0,0 +1,4 @@
GOOGLE_SERVER_PORT=4320
GOOGLE_SERVER_HOST="test-google"
GITHUB_SERVER_PORT=4321
GITHUB_SERVER_HOST="test-github"

@ -0,0 +1,35 @@
# Copyright (C) 2023 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import os
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from django.conf import settings
GOOGLE_SERVER_PORT = os.environ.get("GOOGLE_SERVER_PORT")
GOOGLE_SERVER_HOST = os.environ.get("GOOGLE_SERVER_HOST")
GITHUB_SERVER_PORT = os.environ.get("GITHUB_SERVER_PORT")
GITHUB_SERVER_HOST = os.environ.get("GITHUB_SERVER_HOST")
class TestGitHubAdapter(GitHubOAuth2Adapter):
access_token_url = (
f"http://{GITHUB_SERVER_HOST}:{GITHUB_SERVER_PORT}/login/oauth/access_token" # nosec
)
authorize_url = f"http://localhost:{GITHUB_SERVER_PORT}/login/oauth/authorize"
profile_url = f"http://{GITHUB_SERVER_HOST}:{GITHUB_SERVER_PORT}/user"
emails_url = f"http://{GITHUB_SERVER_HOST}:{GITHUB_SERVER_PORT}/user/emails"
def get_callback_url(self, request, app):
return settings.GITHUB_CALLBACK_URL
class TestGoogleAdapter(GoogleOAuth2Adapter):
access_token_url = f"http://{GOOGLE_SERVER_HOST}:{GOOGLE_SERVER_PORT}/o/oauth2/token"
authorize_url = f"http://localhost:{GOOGLE_SERVER_PORT}/o/oauth2/auth"
profile_url = f"http://{GOOGLE_SERVER_HOST}:{GOOGLE_SERVER_PORT}/oauth2/v1/userinfo"
def get_callback_url(self, request, app):
return settings.GOOGLE_CALLBACK_URL

@ -0,0 +1,47 @@
services:
cvat_server:
environment:
USE_ALLAUTH_SOCIAL_ACCOUNTS: "True"
SOCIAL_AUTH_GOOGLE_CLIENT_ID: "XXX"
SOCIAL_AUTH_GOOGLE_CLIENT_SECRET: "XXX"
SOCIAL_AUTH_GITHUB_CLIENT_ID: "XXX"
SOCIAL_AUTH_GITHUB_CLIENT_SECRET: "XXX"
DJANGO_SETTINGS_MODULE: social_auth.settings
GOOGLE_SERVER_HOST:
GOOGLE_SERVER_PORT:
GITHUB_SERVER_HOST:
GITHUB_SERVER_PORT:
volumes:
- ./tests/python/social_auth:/home/django/social_auth:ro
google_auth_server:
image: python:3.9-slim
restart: always
environment:
GOOGLE_SERVER_HOST:
GOOGLE_SERVER_PORT:
ports:
- '${GOOGLE_SERVER_PORT}:${GOOGLE_SERVER_PORT}'
command: python3 /tmp/server.py --server "google"
volumes:
- ./tests/python/social_auth:/tmp
networks:
cvat:
aliases:
- ${GOOGLE_SERVER_HOST}
github_auth_server:
image: python:3.9-slim
restart: always
environment:
GITHUB_SERVER_HOST:
GITHUB_SERVER_PORT:
ports:
- '${GITHUB_SERVER_PORT}:${GITHUB_SERVER_PORT}'
command: python3 /tmp/server.py --server "github"
volumes:
- ./tests/python/social_auth:/tmp
networks:
cvat:
aliases:
- ${GITHUB_SERVER_HOST}

@ -0,0 +1,217 @@
# Copyright (C) 2023 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import argparse
import json
import os
import string
from abc import ABC, abstractmethod
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from random import choice, random, sample
from urllib.parse import parse_qsl, urlparse
class CommonRequestHandlerClass(BaseHTTPRequestHandler, ABC):
def _set_headers(self):
self.send_response(406)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(f"Unsupported request. Path: {self.path}".encode("utf8"))
def get_profile(self, token=None):
if not token:
self.send_response(403)
self.end_headers()
return
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(self.PROFILE).encode("utf-8"))
@abstractmethod
def authorize(self, query_params):
pass
@abstractmethod
def generate_access_token(self):
pass
def check_query(self, query_params):
supported_response_type = "code"
if not "client_id" in query_params:
self.send_response(400)
self.wfile.write("Client id not found in query params".encode("utf8"))
return
if not "redirect_uri" in query_params:
self.send_response(400)
self.wfile.write("Redirect uri not found".encode("utf8"))
return
if query_params.get("response_type", "code") != supported_response_type:
self.send_response(400)
self.wfile.write(
"Only code response type is supported by dummy auth server".encode("utf8")
)
return
def do_GET(self):
u = urlparse(self.path)
if u.path == self.AUTHORIZE_PATH:
return self.authorize(dict(parse_qsl(u.query)))
elif u.path == self.PROFILE_PATH:
token = self.headers.get("Authorization") or dict(parse_qsl(u.query)).get(
"access_token"
)
return self.get_profile(token)
self._set_headers()
def do_POST(self):
u = urlparse(self.path)
if u.path == self.TOKEN_PATH:
return self.generate_access_token()
self._set_headers()
class GithubRequestHandlerClass(CommonRequestHandlerClass):
AUTHORIZE_PATH = "/login/oauth/authorize"
PROFILE_PATH = "/user"
TOKEN_PATH = "/login/oauth/access_token"
CODE_LENGTH = 20
AUTH_TOKEN_LENGTH = 40
LOGIN = "test-user"
UID = int(random() * 100)
# demo profile not including all information returned by github
PROFILE = {
"login": LOGIN,
"id": UID,
"avatar_url": f"https://avatars.github.com/u/{UID}",
"url": f"https://api.github.com/users/{LOGIN}",
"html_url": f"https://github.com/{LOGIN}",
"type": "User",
"site_admin": False,
"name": "Test User",
"location": "Germany, Munich",
"email": "github.user@test.com",
"hireable": None,
"created_at": str(datetime.now()),
"updated_at": str(datetime.now()),
"two_factor_authentication": False,
}
def authorize(self, query_params):
super().check_query(query_params)
self.send_response(302)
redirect_to = query_params["redirect_uri"]
generated_code = "".join(sample(string.ascii_lowercase + string.digits, self.CODE_LENGTH))
# add query params
new_query = (
f"?code={generated_code}&state={query_params['state']}&"
f"scope={query_params['scope']}&promt=none"
)
redirect_to += new_query
self.send_header("Location", redirect_to)
self.send_header("Content-type", "text/html")
self.end_headers()
def generate_access_token(self):
self.send_response(200)
self.send_header("Content-type", "application/x-www-form-urlencoded; charset=utf-8")
generated_token = "".join(
sample(string.ascii_letters + string.digits, self.AUTH_TOKEN_LENGTH)
)
scope = "read:user,user:email"
content = f"access_token={generated_token}&scope={scope}&token_type=bearer".encode("utf-8")
self.end_headers()
self.wfile.write(content)
class GoogleRequestHandlerClass(CommonRequestHandlerClass):
AUTHORIZE_PATH = "/o/oauth2/auth"
PROFILE_PATH = "/oauth2/v1/userinfo"
TOKEN_PATH = "/o/oauth2/token"
CODE_LENGTH = 70 # in real case 256 bytes
AUTH_TOKEN_LENGTH = 100 # in real case 2048 bytes
UID = int(random() * 100)
# demo profile not including all information returned by google
PROFILE = {
"id": UID,
"email": "google.user@gmail.com",
"verified_email": True,
"name": "Test User",
"given_name": "Test",
"family_name": "User",
"picture": f"https://avatars.google.com/u/{UID}",
"locale": "en",
}
def authorize(self, query_params):
super().check_query(query_params)
self.send_response(302)
redirect_to = query_params["redirect_uri"]
symbols = string.ascii_letters + string.digits
generated_code = "".join([choice(symbols) for i in range(self.CODE_LENGTH)])
# add query params
new_query = (
f"?code={generated_code}&state={query_params['state']}&"
f"scope={query_params['scope']}&promt=none"
)
redirect_to += new_query
self.send_header("Location", redirect_to)
self.send_header("Content-type", "text/html")
self.end_headers()
def generate_access_token(self):
self.send_response(200)
self.send_header("Content-type", "application/json; charset=utf-8")
symbols = string.ascii_letters + string.digits + string.punctuation
generated_token = "".join([choice(symbols) for i in range(self.AUTH_TOKEN_LENGTH)])
id_token = "".join([choice(symbols) for i in range(self.AUTH_TOKEN_LENGTH)])
scope = "https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email"
content = {
"access_token": generated_token,
"expires_in": 3600, # 1 h
"scope": scope,
"token_type": "Bearer",
"id_token": id_token,
}
self.end_headers()
self.wfile.write(json.dumps(content).encode("utf-8"))
class AuthServer:
SERVER_HOST = "0.0.0.0"
def run(self):
print(f"Starting dummy authentication server on {self.SERVER_HOST}, {self.SERVER_PORT}")
HTTPServer((self.SERVER_HOST, self.SERVER_PORT), self.REQUEST_HANDLER_CLASS).serve_forever()
class GoogleAuthServer(AuthServer):
SERVER_PORT = int(os.environ.get("GOOGLE_SERVER_PORT", "4320"))
REQUEST_HANDLER_CLASS = GoogleRequestHandlerClass
class GithubAuthServer(AuthServer):
SERVER_PORT = int(os.environ.get("GITHUB_SERVER_PORT", "4321"))
REQUEST_HANDLER_CLASS = GithubRequestHandlerClass
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--server", choices=["google", "github"], type=str, default="google")
server = parser.parse_args().server
auth_servers = {
"google": GoogleAuthServer,
"github": GithubAuthServer,
}
server_class = auth_servers[server]
server_class().run()

@ -0,0 +1,11 @@
# Copyright (C) 2023 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from cvat.settings.production import *
ACCOUNT_EMAIL_REQUIRED = True
if USE_ALLAUTH_SOCIAL_ACCOUNTS:
SOCIALACCOUNT_GITHUB_ADAPTER = "social_auth.adapters.TestGitHubAdapter"
SOCIALACCOUNT_GOOGLE_ADAPTER = "social_auth.adapters.TestGoogleAdapter"
Loading…
Cancel
Save