From 8359db3580a1a2f5c0d2387cc88e35934bd4a328 Mon Sep 17 00:00:00 2001 From: Nikita Manovich <40690625+nmanovic@users.noreply.github.com> Date: Mon, 9 Sep 2019 23:15:26 +0300 Subject: [PATCH] cvat-ui in docker (serve using nginx) (#658) --- .dockerignore | 5 +-- cvat-ui/.dockerignore | 2 ++ cvat-ui/.env | 3 -- cvat-ui/.env.production | 9 ++++++ cvat-ui/Dockerfile | 36 ++++++++++++++++++++++ cvat-ui/package.json | 3 +- cvat-ui/react_nginx.conf | 7 +++++ cvat-ui/src/components/app/app.tsx | 4 +++ cvat/apps/authentication/api_urls.py | 2 ++ cvat/apps/authentication/auth.py | 27 ++++++++++++++++ cvat/apps/authentication/signature.py | 44 +++++++++++++++++++++++++++ cvat/apps/authentication/views.py | 18 +++++++++++ cvat/requirements/base.txt | 2 ++ cvat/requirements/development.txt | 3 +- cvat/settings/base.py | 12 ++++++++ cvat/settings/development.py | 14 --------- docker-compose.yml | 22 ++++++++++++++ 17 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 cvat-ui/.dockerignore create mode 100644 cvat-ui/.env.production create mode 100644 cvat-ui/Dockerfile create mode 100644 cvat-ui/react_nginx.conf create mode 100644 cvat/apps/authentication/signature.py diff --git a/.dockerignore b/.dockerignore index f2db49ab..44c9d2c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,5 +6,6 @@ /.vscode /db.sqlite3 /keys -package-lock.json -node_modules +**/node_modules +cvat-ui +cvat-canvas diff --git a/cvat-ui/.dockerignore b/cvat-ui/.dockerignore new file mode 100644 index 00000000..e3fbd983 --- /dev/null +++ b/cvat-ui/.dockerignore @@ -0,0 +1,2 @@ +build +node_modules diff --git a/cvat-ui/.env b/cvat-ui/.env index f0902663..ac71a091 100644 --- a/cvat-ui/.env +++ b/cvat-ui/.env @@ -6,7 +6,4 @@ REACT_APP_API_PORT=7000 REACT_APP_API_HOST_URL=${REACT_APP_API_PROTOCOL}://${REACT_APP_API_HOST}:${REACT_APP_API_PORT} REACT_APP_API_FULL_URL=${REACT_APP_API_PROTOCOL}://${REACT_APP_API_HOST}:${REACT_APP_API_PORT}/api/v1 -REACT_APP_LOGIN=admin -REACT_APP_PASSWORD=admin - SKIP_PREFLIGHT_CHECK=true diff --git a/cvat-ui/.env.production b/cvat-ui/.env.production new file mode 100644 index 00000000..d5af5e40 --- /dev/null +++ b/cvat-ui/.env.production @@ -0,0 +1,9 @@ +REACT_APP_VERSION=${npm_package_version} + +REACT_APP_API_PROTOCOL=http +REACT_APP_API_HOST=localhost +REACT_APP_API_PORT=8080 +REACT_APP_API_HOST_URL=${REACT_APP_API_PROTOCOL}://${REACT_APP_API_HOST}:${REACT_APP_API_PORT} +REACT_APP_API_FULL_URL=${REACT_APP_API_PROTOCOL}://${REACT_APP_API_HOST}:${REACT_APP_API_PORT}/api/v1 + +SKIP_PREFLIGHT_CHECK=true diff --git a/cvat-ui/Dockerfile b/cvat-ui/Dockerfile new file mode 100644 index 00000000..1b5ce8eb --- /dev/null +++ b/cvat-ui/Dockerfile @@ -0,0 +1,36 @@ +FROM ubuntu:18.04 AS cvat-ui + +ARG http_proxy +ARG https_proxy +ARG no_proxy +ARG socks_proxy + +ENV TERM=xterm \ + http_proxy=${http_proxy} \ + https_proxy=${https_proxy} \ + no_proxy=${no_proxy} \ + socks_proxy=${socks_proxy} + +ENV LANG='C.UTF-8' \ + LC_ALL='C.UTF-8' + +# Install necessary apt packages +RUN apt update && apt install -yq nodejs npm curl && \ + npm install -g n && n 10.16.3 + +# Create output directory +RUN mkdir /tmp/cvat-ui +WORKDIR /tmp/cvat-ui/ + +# Install dependencies +COPY package*.json /tmp/cvat-ui/ +RUN npm install + +# Build source code +COPY . /tmp/cvat-ui/ +RUN mv .env.production .env && npm run build + +FROM nginx +# Replace default.conf configuration to remove unnecessary rules +COPY react_nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=cvat-ui /tmp/cvat-ui/build /usr/share/nginx/html/ diff --git a/cvat-ui/package.json b/cvat-ui/package.json index e198d615..cc5aaff0 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -24,7 +24,8 @@ "react-redux": "^7.1.0", "react-router-dom": "^5.0.1", "react-scripts": "3.0.1", - "redux": "^4.0.3", + "react-scripts-ts": "^3.1.0", + "redux": "^4.0.4", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", "source-map-explorer": "^1.8.0", diff --git a/cvat-ui/react_nginx.conf b/cvat-ui/react_nginx.conf new file mode 100644 index 00000000..9a8bbc92 --- /dev/null +++ b/cvat-ui/react_nginx.conf @@ -0,0 +1,7 @@ +server { + root /usr/share/nginx/html; + # Any route that doesn't have a file extension (e.g. /devices) + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/cvat-ui/src/components/app/app.tsx b/cvat-ui/src/components/app/app.tsx index 0110e6f0..8df1edb6 100644 --- a/cvat-ui/src/components/app/app.tsx +++ b/cvat-ui/src/components/app/app.tsx @@ -39,6 +39,10 @@ const ProtectedRoute = ({ component: Component, ...rest }: any) => { }; class App extends PureComponent { + componentDidMount() { + (window as any).cvat.config.backendAPI = process.env.REACT_APP_API_FULL_URL; + } + render() { return( diff --git a/cvat/apps/authentication/api_urls.py b/cvat/apps/authentication/api_urls.py index b6951ac3..a3752ad5 100644 --- a/cvat/apps/authentication/api_urls.py +++ b/cvat/apps/authentication/api_urls.py @@ -8,10 +8,12 @@ from rest_auth.views import ( LoginView, LogoutView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView) from rest_auth.registration.views import RegisterView +from .views import SigningView urlpatterns = [ path('login', LoginView.as_view(), name='rest_login'), path('logout', LogoutView.as_view(), name='rest_logout'), + path('signing', SigningView.as_view(), name='signing') ] if settings.DJANGO_AUTH_TYPE == 'BASIC': diff --git a/cvat/apps/authentication/auth.py b/cvat/apps/authentication/auth.py index 0bb3296e..da4613bf 100644 --- a/cvat/apps/authentication/auth.py +++ b/cvat/apps/authentication/auth.py @@ -7,7 +7,10 @@ from django.conf import settings from django.db.models import Q import rules from . import AUTH_ROLE +from . import signature from rest_framework.permissions import BasePermission +from django.core import signing +from rest_framework import authentication, exceptions def register_signals(): from django.db.models.signals import post_migrate, post_save @@ -30,6 +33,30 @@ def register_signals(): django_auth_ldap.backend.populate_user.connect(create_user) +class SignatureAuthentication(authentication.BaseAuthentication): + """ + Authentication backend for signed URLs. + """ + def authenticate(self, request): + """ + Returns authenticated user if URL signature is valid. + """ + signer = signature.Signer() + sign = request.query_params.get(signature.QUERY_PARAM) + if not sign: + return + + try: + user = signer.unsign(sign, request.build_absolute_uri()) + except signing.SignatureExpired: + raise exceptions.AuthenticationFailed('This URL has expired.') + except signing.BadSignature: + raise exceptions.AuthenticationFailed('Invalid signature.') + if not user.is_active: + raise exceptions.AuthenticationFailed('User inactive or deleted.') + + return (user, None) + # AUTH PREDICATES has_admin_role = rules.is_group_member(str(AUTH_ROLE.ADMIN)) has_user_role = rules.is_group_member(str(AUTH_ROLE.USER)) diff --git a/cvat/apps/authentication/signature.py b/cvat/apps/authentication/signature.py new file mode 100644 index 00000000..fdcb0403 --- /dev/null +++ b/cvat/apps/authentication/signature.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core import signing +from furl import furl +import hashlib + +QUERY_PARAM = 'sign' +MAX_AGE = 30 + +# Got implementation ideas in https://github.com/marcgibbons/drf_signed_auth +class Signer: + @classmethod + def get_salt(cls, url): + normalized_url = furl(url).remove(QUERY_PARAM).url.encode('utf-8') + salt = hashlib.sha256(normalized_url).hexdigest() + return salt + + def sign(self, user, url): + """ + Create a signature for a user object. + """ + data = { + 'user_id': user.pk, + 'username': user.get_username() + } + + return signing.dumps(data, salt=self.get_salt(url)) + + def unsign(self, signature, url): + """ + Return a user object for a valid signature. + """ + User = get_user_model() + data = signing.loads(signature, salt=self.get_salt(url), max_age=MAX_AGE) + + if not isinstance(data, dict): + raise signing.BadSignature() + + try: + return User.objects.get(**{ + 'pk': data.get('user_id'), + User.USERNAME_FIELD: data.get('username') + }) + except User.DoesNotExist: + raise signing.BadSignature() diff --git a/cvat/apps/authentication/views.py b/cvat/apps/authentication/views.py index 5e8493e7..1e1bbb10 100644 --- a/cvat/apps/authentication/views.py +++ b/cvat/apps/authentication/views.py @@ -5,8 +5,13 @@ from django.shortcuts import render, redirect from django.conf import settings from django.contrib.auth import login, authenticate +from rest_framework import views +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from furl import furl from . import forms +from . import signature def register_user(request): if request.method == 'POST': @@ -21,3 +26,16 @@ def register_user(request): else: form = forms.NewUserForm() return render(request, 'register.html', {'form': form}) + +class SigningView(views.APIView): + def post(self, request): + url = request.data.get('url') + if not url: + raise ValidationError('Please provide `url` parameter') + + signer = signature.Signer() + url = self.request.build_absolute_uri(url) + sign = signer.sign(self.request.user, url) + + url = furl(url).add({signature.QUERY_PARAM: sign}).url + return Response(url) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 1f8422d8..f5ec239e 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -40,3 +40,5 @@ cython==0.29.13 matplotlib==3.0.3 scikit-image>=0.14.0 tensorflow==1.12.3 +django-cors-headers==3.0.2 +furl==2.0.0 diff --git a/cvat/requirements/development.txt b/cvat/requirements/development.txt index faffa2cc..fc0333ad 100644 --- a/cvat/requirements/development.txt +++ b/cvat/requirements/development.txt @@ -9,6 +9,5 @@ pylint-plugin-utils==0.2.6 rope==0.11 wrapt==1.10.11 django-extensions==2.0.6 -Werkzeug==0.14.1 +Werkzeug==0.15.3 snakeviz==0.4.2 -django-cors-headers==3.0.2 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index ac43a1df..061fe175 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -111,6 +111,7 @@ INSTALLED_APPS = [ 'django.contrib.sites', 'allauth', 'allauth.account', + 'corsheaders', 'allauth.socialaccount', 'rest_auth.registration' ] @@ -123,6 +124,7 @@ REST_FRAMEWORK = { ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', + 'cvat.apps.authentication.auth.SignatureAuthentication', 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication' ], @@ -173,8 +175,18 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'dj_pagination.middleware.PaginationMiddleware', + 'corsheaders.middleware.CorsMiddleware', ] +# Cross-Origin Resource Sharing settings for CVAT UI +UI_SCHEME = os.environ.get('UI_SCHEME', 'http') +UI_HOST = os.environ.get('UI_HOST', 'localhost') +UI_PORT = os.environ.get('UI_PORT', '3000') +CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = [UI_HOST] +UI_URL = '{}://{}:{}'.format(UI_SCHEME, UI_HOST, UI_PORT) +CORS_ORIGIN_WHITELIST = [UI_URL] + STATICFILES_FINDERS = [ 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', diff --git a/cvat/settings/development.py b/cvat/settings/development.py index 936b0e3f..9205c8d5 100644 --- a/cvat/settings/development.py +++ b/cvat/settings/development.py @@ -9,20 +9,6 @@ DEBUG = True INSTALLED_APPS += [ 'django_extensions', - 'corsheaders', -] - -MIDDLEWARE += [ - 'corsheaders.middleware.CorsMiddleware', -] - -CORS_ALLOW_CREDENTIALS = True - -CSRF_TRUSTED_ORIGINS = [ - 'http://localhost:3000' -] -CORS_ORIGIN_WHITELIST = [ - "http://localhost:3000", ] ALLOWED_HOSTS.append('testserver') diff --git a/docker-compose.yml b/docker-compose.yml index c637279e..163bbba5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,12 +53,34 @@ services: OPENVINO_TOOLKIT: "no" environment: DJANGO_MODWSGI_EXTRA_ARGS: "" + UI_PORT: 9080 + volumes: - cvat_data:/home/django/data - cvat_keys:/home/django/keys - cvat_logs:/home/django/logs - cvat_models:/home/django/models + cvat_ui: + container_name: cvat_ui + image: nginx + build: + context: cvat-ui + args: + http_proxy: + https_proxy: + no_proxy: + socks_proxy: + dockerfile: Dockerfile + networks: + default: + aliases: + - ui + depends_on: + - cvat + ports: + - "9080:80" + volumes: cvat_db: cvat_data: