From b00bc653ff1b90887bafcaac61711cfacb5f05a1 Mon Sep 17 00:00:00 2001 From: Maria Khrustaleva Date: Thu, 12 Jan 2023 19:36:34 +0200 Subject: [PATCH] Social account authentication tests (#5444) Depends on #5349 Related #5432 Added tests for social account authentication functionality: cypress test with dummy auth server --- .github/workflows/full.yml | 4 +- .github/workflows/main.yml | 5 +- .github/workflows/schedule.yml | 4 +- CHANGELOG.md | 2 + cvat-core/package.json | 2 +- cvat-core/src/api-implementation.ts | 7 +- cvat-core/src/api.ts | 4 +- cvat-core/src/auth-methods.ts | 57 +++++ cvat-core/src/server-proxy.ts | 6 +- cvat-ui/src/actions/auth-actions.ts | 28 +-- .../src/components/login-page/login-page.tsx | 49 ++-- .../login-with-social-app.tsx | 7 +- .../signing-common/social-account-link.tsx | 18 +- .../src/containers/login-page/login-page.tsx | 13 +- cvat-ui/src/cvat-core-wrapper.ts | 3 + cvat-ui/src/icons.tsx | 4 - cvat-ui/src/reducers/auth-reducer.ts | 31 ++- cvat-ui/src/reducers/index.ts | 17 +- cvat/apps/engine/views.py | 35 +-- cvat/apps/iam/adapters.py | 2 + cvat/apps/iam/permissions.py | 1 - cvat/apps/iam/rules/tests/configs/server.csv | 2 +- cvat/apps/iam/serializers.py | 6 + .../social-github-logo.svg | 0 .../social-google-logo.svg | 0 cvat/apps/iam/urls.py | 2 + cvat/apps/iam/views.py | 65 +++++- cvat/settings/base.py | 9 +- .../en/docs/contributing/running-tests.md | 4 +- .../actions_users/issue_1810_login_logout.js | 31 +++ tests/python/social_auth/.env | 4 + tests/python/social_auth/adapters.py | 35 +++ tests/python/social_auth/docker-compose.yml | 47 ++++ tests/python/social_auth/server.py | 217 ++++++++++++++++++ tests/python/social_auth/settings.py | 11 + 35 files changed, 592 insertions(+), 140 deletions(-) create mode 100644 cvat-core/src/auth-methods.ts rename {cvat-ui/src/assets => cvat/apps/iam/static/social_authentication}/social-github-logo.svg (100%) rename {cvat-ui/src/assets => cvat/apps/iam/static/social_authentication}/social-google-logo.svg (100%) create mode 100644 tests/python/social_auth/.env create mode 100644 tests/python/social_auth/adapters.py create mode 100644 tests/python/social_auth/docker-compose.yml create mode 100644 tests/python/social_auth/server.py create mode 100644 tests/python/social_auth/settings.py diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml index 53f8c5c9..e5f504fe 100644 --- a/.github/workflows/full.yml +++ b/.github/workflows/full.yml @@ -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: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0817490e..39fb91ec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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" diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml index 619cdf44..7a4ee37a 100644 --- a/.github/workflows/schedule.yml +++ b/.github/workflows/schedule.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b498d4..700f75e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 () - YOLO v7 serverless feature added using ONNX backend () +- Cypress test for social account authentication () +- Dummy github and google authentication servers () ### Changed - The Docker Compose files now use the Compose Specification version diff --git a/cvat-core/package.json b/cvat-core/package.json index 5405acbc..0448cd2d 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -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": { diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index df4276b7..5a018f14 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -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) => { diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 3b3dfedc..bff5ed74 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -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) { diff --git a/cvat-core/src/auth-methods.ts b/cvat-core/src/auth-methods.ts new file mode 100644 index 00000000..a2a159a8 --- /dev/null +++ b/cvat-core/src/auth-methods.ts @@ -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[]; diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 4cd12163..65fa03b7 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -2250,10 +2250,10 @@ async function receiveWebhookEvents(type: WebhookSourceType): Promise } } -async function advancedAuthentication(): Promise { +async function socialAuthentication(): Promise { 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, diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index 589792c7..cf97e8c9 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -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 => { - dispatch(authActions.loadAdvancedAuth()); +export const loadSocialAuthAsync = (): ThunkAction => async (dispatch): Promise => { + 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)); } }; diff --git a/cvat-ui/src/components/login-page/login-page.tsx b/cvat-ui/src/components/login-page/login-page.tsx index c0b35938..e612a21a 100644 --- a/cvat-ui/src/components/login-page/login-page.tsx +++ b/cvat-ui/src/components/login-page/login-page.tsx @@ -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) ? ( + + {`Continue with ${item.publicName}`} + + ) : <>)); +}; + 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 - {githubAuthentication && ( - - Continue with GitHub - - )} - {googleAuthentication && ( - - Continue with Google - - )} + {renderSocialAuthMethods(socialAuthMethods)} ) : null} onSubmit={(loginData: LoginData): void => { diff --git a/cvat-ui/src/components/login-with-social-app/login-with-social-app.tsx b/cvat-ui/src/components/login-with-social-app/login-with-social-app.tsx index 4b8ed0bf..4074ab54 100644 --- a/cvat-ui/src/components/login-with-social-app/login-with-social-app.tsx +++ b/cvat-ui/src/components/login-with-social-app/login-with-social-app.tsx @@ -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 ; + } + return (
diff --git a/cvat-ui/src/components/signing-common/social-account-link.tsx b/cvat-ui/src/components/signing-common/social-account-link.tsx index 7cea010c..9b443bf4 100644 --- a/cvat-ui/src/components/signing-common/social-account-link.tsx +++ b/cvat-ui/src/components/signing-common/social-account-link.tsx @@ -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; + 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 ( @@ -28,7 +35,10 @@ function SocialAccountLink(props: SocialAccountLinkProps): JSX.Element { > - +
{children} diff --git a/cvat-ui/src/containers/login-page/login-page.tsx b/cvat-ui/src/containers/login-page/login-page.tsx index be23a08f..24f182ff 100644 --- a/cvat-ui/src/containers/login-page/login-page.tsx +++ b/cvat-ui/src/containers/login-page/login-page.tsx @@ -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); diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 16c49c47..a04e5c6e 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -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, }; diff --git a/cvat-ui/src/icons.tsx b/cvat-ui/src/icons.tsx index e078b5a6..598de3f2 100644 --- a/cvat-ui/src/icons.tsx +++ b/cvat-ui/src/icons.tsx @@ -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 => ) export const BackArrowIcon = React.memo((): JSX.Element => ); export const ClearIcon = React.memo((): JSX.Element => ); export const ShowPasswordIcon = React.memo((): JSX.Element => ); -export const SocialGithubLogo = React.memo((): JSX.Element => ); -export const SocialGoogleLogo = React.memo((): JSX.Element => ); export const PlusIcon = React.memo((): JSX.Element => ); export const CheckIcon = React.memo((): JSX.Element => ); diff --git a/cvat-ui/src/reducers/auth-reducer.ts b/cvat-ui/src/reducers/auth-reducer.ts index a7687bd4..c7c981dd 100644 --- a/cvat-ui/src/reducers/auth-reducer.ts +++ b/cvat-ui/src/reducers/auth-reducer.ts @@ -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: { diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index 5f688d4f..8adf2b10 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -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 { diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 2e06ee91..8685ba83 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -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( diff --git a/cvat/apps/iam/adapters.py b/cvat/apps/iam/adapters.py index e09737db..649bf472 100644 --- a/cvat/apps/iam/adapters.py +++ b/cvat/apps/iam/adapters.py @@ -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 diff --git a/cvat/apps/iam/permissions.py b/cvat/apps/iam/permissions.py index ea5ac9dc..58e4caff 100644 --- a/cvat/apps/iam/permissions.py +++ b/cvat/apps/iam/permissions.py @@ -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): diff --git a/cvat/apps/iam/rules/tests/configs/server.csv b/cvat/apps/iam/rules/tests/configs/server.csv index 03aa75b7..7291fb6c 100644 --- a/cvat/apps/iam/rules/tests/configs/server.csv +++ b/cvat/apps/iam/rules/tests/configs/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, /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 \ No newline at end of file diff --git a/cvat/apps/iam/serializers.py b/cvat/apps/iam/serializers.py index e6548ead..a4f8ffef 100644 --- a/cvat/apps/iam/serializers.py +++ b/cvat/apps/iam/serializers.py @@ -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() diff --git a/cvat-ui/src/assets/social-github-logo.svg b/cvat/apps/iam/static/social_authentication/social-github-logo.svg similarity index 100% rename from cvat-ui/src/assets/social-github-logo.svg rename to cvat/apps/iam/static/social_authentication/social-github-logo.svg diff --git a/cvat-ui/src/assets/social-google-logo.svg b/cvat/apps/iam/static/social_authentication/social-google-logo.svg similarity index 100% rename from cvat-ui/src/assets/social-google-logo.svg rename to cvat/apps/iam/static/social_authentication/social-google-logo.svg diff --git a/cvat/apps/iam/urls.py b/cvat/apps/iam/urls.py index d5b7ecbe..ca6161b4 100644 --- a/cvat/apps/iam/urls.py +++ b/cvat/apps/iam/urls.py @@ -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: diff --git a/cvat/apps/iam/views.py b/cvat/apps/iam/views.py index 1112a166..3d8a664c 100644 --- a/cvat/apps/iam/views.py +++ b/cvat/apps/iam/views.py @@ -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) diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 8d2c5d60..6684dd46 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -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', }, } diff --git a/site/content/en/docs/contributing/running-tests.md b/site/content/en/docs/contributing/running-tests.md index cee2f65e..024533bc 100644 --- a/site/content/en/docs/contributing/running-tests.md +++ b/site/content/en/docs/contributing/running-tests.md @@ -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: ``` diff --git a/tests/cypress/integration/actions_users/issue_1810_login_logout.js b/tests/cypress/integration/actions_users/issue_1810_login_logout.js index bf9b4d46..b0b1d789 100644 --- a/tests/cypress/integration/actions_users/issue_1810_login_logout.js +++ b/tests/cypress/integration/actions_users/issue_1810_login_logout.js @@ -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); + }); + } + }); + }); }); }); diff --git a/tests/python/social_auth/.env b/tests/python/social_auth/.env new file mode 100644 index 00000000..1d2e360a --- /dev/null +++ b/tests/python/social_auth/.env @@ -0,0 +1,4 @@ +GOOGLE_SERVER_PORT=4320 +GOOGLE_SERVER_HOST="test-google" +GITHUB_SERVER_PORT=4321 +GITHUB_SERVER_HOST="test-github" \ No newline at end of file diff --git a/tests/python/social_auth/adapters.py b/tests/python/social_auth/adapters.py new file mode 100644 index 00000000..d2c6596a --- /dev/null +++ b/tests/python/social_auth/adapters.py @@ -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 diff --git a/tests/python/social_auth/docker-compose.yml b/tests/python/social_auth/docker-compose.yml new file mode 100644 index 00000000..7a5cbb80 --- /dev/null +++ b/tests/python/social_auth/docker-compose.yml @@ -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} diff --git a/tests/python/social_auth/server.py b/tests/python/social_auth/server.py new file mode 100644 index 00000000..c5b7060c --- /dev/null +++ b/tests/python/social_auth/server.py @@ -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() diff --git a/tests/python/social_auth/settings.py b/tests/python/social_auth/settings.py new file mode 100644 index 00000000..6188313b --- /dev/null +++ b/tests/python/social_auth/settings.py @@ -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"