6
0
Переглянути джерело

Authentication (additional)

Test are now running fine. Additionally, username and password field are cleared on logout.
The authentication process was moved to the socket.
blunk 3 роки тому
батько
коміт
c248d2da79

+ 3 - 0
.gitignore

@@ -41,6 +41,9 @@ external_data
 /labels/
 dist/
 
+.htpasswd
+.test-htpasswd
+
 *.sqlite
 *.sqlite-journal
 *.sqlite3

+ 54 - 26
pycs/frontend/WebServer.py

@@ -17,6 +17,7 @@ from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 from pycs.frontend.endpoints.ListModels import ListModels
 from pycs.frontend.endpoints.ListProjects import ListProjects
 from pycs.frontend.endpoints.additional.FolderInformation import FolderInformation
+from pycs.frontend.endpoints.additional.Authenticate import Authenticate
 from pycs.frontend.endpoints.data.GetCroppedFile import GetCroppedFile
 from pycs.frontend.endpoints.data.GetFile import GetFile
 from pycs.frontend.endpoints.data.GetPreviousAndNextFile import GetPreviousAndNextFile
@@ -162,6 +163,13 @@ class WebServer:
     def define_routes(self):
         """ defines app routes """
 
+        # authentication
+        # additional
+        self.app.add_url_rule(
+            '/authenticate',
+            view_func=self.htpasswd.required( Authenticate.as_view('authenticate') )
+        )
+
         # additional
         self.app.add_url_rule(
             '/folder',
@@ -203,19 +211,23 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/labels',
-            view_func=self.htpasswd.required( CreateLabel.as_view('create_label', self.notifications) )
+            view_func=self.htpasswd.required( CreateLabel.as_view('create_label',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/remove',
-            view_func=self.htpasswd.required( RemoveLabel.as_view('remove_label', self.notifications) )
+            view_func=self.htpasswd.required( RemoveLabel.as_view('remove_label',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/name',
-            view_func=self.htpasswd.required( EditLabelName.as_view('edit_label_name', self.notifications) )
+            view_func=self.htpasswd.required( EditLabelName.as_view('edit_label_name',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/parent',
-            view_func=self.htpasswd.required( EditLabelParent.as_view('edit_label_parent', self.notifications) )
+            view_func=self.htpasswd.required( EditLabelParent.as_view('edit_label_parent',
+                self.notifications) )
         )
 
         # collections
@@ -231,7 +243,8 @@ class WebServer:
         # data
         self.app.add_url_rule(
             '/projects/<int:project_id>/data',
-            view_func=self.htpasswd.required( UploadFile.as_view('upload_file', self.notifications) )
+            view_func=self.htpasswd.required( UploadFile.as_view('upload_file',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/data',
@@ -243,7 +256,8 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/remove',
-            view_func=self.htpasswd.required( RemoveFile.as_view('remove_file', self.notifications) )
+            view_func=self.htpasswd.required( RemoveFile.as_view('remove_file',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/data/<int:file_id>',
@@ -259,7 +273,8 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/previous_next',
-            view_func=self.htpasswd.required( GetPreviousAndNextFile.as_view('get_previous_and_next_file') )
+            view_func=self.htpasswd.required(
+                GetPreviousAndNextFile.as_view('get_previous_and_next_file') )
         )
 
         # results
@@ -273,28 +288,34 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/results',
-            view_func=self.htpasswd.required( CreateResult.as_view('create_result', self.notifications) )
+            view_func=self.htpasswd.required( CreateResult.as_view('create_result',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/reset',
-            view_func=self.htpasswd.required( ResetResults.as_view('reset_results', self.notifications) )
+            view_func=self.htpasswd.required( ResetResults.as_view('reset_results',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/results/<int:result_id>/remove',
-            view_func=self.htpasswd.required( RemoveResult.as_view('remove_result', self.notifications) )
+            view_func=self.htpasswd.required( RemoveResult.as_view('remove_result',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/results/<int:result_id>/confirm',
-            view_func=self.htpasswd.required( ConfirmResult.as_view('confirm_result', self.notifications) )
+            view_func=self.htpasswd.required( ConfirmResult.as_view('confirm_result',
+                self.notifications) )
         )
 
         self.app.add_url_rule(
             '/results/<int:result_id>/label',
-            view_func=self.htpasswd.required( EditResultLabel.as_view('edit_result_label', self.notifications) )
+            view_func=self.htpasswd.required( EditResultLabel.as_view('edit_result_label',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/results/<int:result_id>/data',
-            view_func=self.htpasswd.required( EditResultData.as_view('edit_result_data', self.notifications) )
+            view_func=self.htpasswd.required( EditResultData.as_view('edit_result_data',
+                self.notifications) )
         )
 
         # projects
@@ -304,7 +325,8 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/projects',
-            view_func=self.htpasswd.required( CreateProject.as_view('create_project', self.notifications, self.jobs) )
+            view_func=self.htpasswd.required( CreateProject.as_view('create_project',
+                self.notifications, self.jobs) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/label_provider',
@@ -313,41 +335,47 @@ class WebServer:
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/external_storage',
-            view_func=self.htpasswd.required( ExecuteExternalStorage.as_view('execute_external_storage',
-                                                     self.notifications, self.jobs) )
+            view_func=self.htpasswd.required(
+                ExecuteExternalStorage.as_view('execute_external_storage',
+                    self.notifications, self.jobs) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/remove',
-            view_func=self.htpasswd.required( RemoveProject.as_view('remove_project', self.notifications) )
+            view_func=self.htpasswd.required( RemoveProject.as_view('remove_project',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/name',
-            view_func=self.htpasswd.required( EditProjectName.as_view('edit_project_name', self.notifications) )
+            view_func=self.htpasswd.required( EditProjectName.as_view('edit_project_name',
+                self.notifications) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/description',
-            view_func=self.htpasswd.required( EditProjectDescription.as_view('edit_project_description', self.notifications) )
+            view_func=self.htpasswd.required(
+                EditProjectDescription.as_view('edit_project_description',
+                    self.notifications) )
         )
 
         # pipelines
         self.app.add_url_rule(
             '/projects/<int:project_id>/pipelines/fit',
-            view_func=self.htpasswd.required( FitModel.as_view('fit_model', self.jobs, self.pipelines) )
+            view_func=self.htpasswd.required( FitModel.as_view('fit_model', self.jobs,
+                self.pipelines) )
         )
         self.app.add_url_rule(
             '/projects/<int:project_id>/pipelines/predict',
-            view_func=self.htpasswd.required( PredictModel.as_view('predict_model', self.notifications, self.jobs,
-                                           self.pipelines) )
+            view_func=self.htpasswd.required( PredictModel.as_view('predict_model',
+                self.notifications, self.jobs, self.pipelines) )
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/predict',
-            view_func=self.htpasswd.required( PredictFile.as_view('predict_file', self.notifications,
-                                          self.jobs, self.pipelines) )
+            view_func=self.htpasswd.required( PredictFile.as_view('predict_file',
+                self.notifications, self.jobs, self.pipelines) )
         )
         self.app.add_url_rule(
             '/data/<int:file_id>/<int:bbox_id>/predict_bounding_box',
-            view_func=self.htpasswd.required( PredictBoundingBox.as_view('predict_bounding_box', self.notifications,
-                                          self.jobs, self.pipelines) )
+            view_func=self.htpasswd.required( PredictBoundingBox.as_view('predict_bounding_box',
+                self.notifications, self.jobs, self.pipelines) )
         )
 
     def run(self):

+ 15 - 0
pycs/frontend/endpoints/additional/Authenticate.py

@@ -0,0 +1,15 @@
+import os
+
+from flask import make_response
+from flask.views import View
+
+class Authenticate(View):
+    """
+    Always returns a success code.
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def dispatch_request(self, user: str):
+        # Always return a success code, since authentication is already handled.
+        return make_response()

+ 17 - 2
tests/base.py

@@ -5,6 +5,9 @@ import shutil
 import typing as T
 import unittest
 
+import base64
+from flask_htpasswd import HtPasswdAuth
+
 from pathlib import Path
 from unittest import mock
 
@@ -44,9 +47,17 @@ class BaseTestCase(unittest.TestCase):
         app.config["DEBUG"] = False
         app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{cls.DB_FILE}"
 
+        # Set dummy password protection.
+        # This allows authentication with the credentials user:password.
+        app.config['FLASK_HTPASSWD_PATH'] = '.test-htpasswd'
+        if not os.path.isfile(app.config['FLASK_HTPASSWD_PATH']):
+            with open(app.config['FLASK_HTPASSWD_PATH'], 'w') as f:
+                f.write('user:$apr1$fmi16nrq$3C4MfxW3ChrUNjSLLTB3x.')
+        htpasswd = HtPasswdAuth(app)
+
         if server is None:
             settings["pipeline_cache_time"] = 2
-            server = WebServer(app, settings, discovery)
+            server = WebServer(app, htpasswd, settings, discovery)
 
         if cls.server is None:
             cls.server = server
@@ -101,6 +112,9 @@ class BaseTestCase(unittest.TestCase):
 
         db.create_all()
 
+        credentials = base64.b64encode("user:password".encode()).decode()
+        self.headers = { 'Authorization' : 'Basic %s' %  credentials }
+
         self.client = app.test_client()
         self.context = app.test_request_context()
         self.context.push()
@@ -145,6 +159,7 @@ class BaseTestCase(unittest.TestCase):
             status_code=status_code,
             json=json,
             data=data,
+            headers=self.headers,
             **kwargs
         )
 
@@ -161,5 +176,5 @@ class BaseTestCase(unittest.TestCase):
             status_code=status_code,
             json=json,
             data=data,
+            headers=self.headers
         )
-

+ 6 - 13
webui/src/App.vue

@@ -15,7 +15,7 @@
         <p><input v-model="userName" type="username" placeholder="Username" required></p>
         <p><input v-model="passwordLogin" type="password" placeholder="Password" required></p>
         <p><button type="button" v-on:click="login()" :disabled="loginButtonDisabled">Login</button></p>
-        <p><span style="color:red">{{loginErrorText}}</span></p>
+        <p><span style="color:red">{{$root.socket.latestErrorTxt}}</span></p>
       </div>
     </div>
 
@@ -76,7 +76,6 @@ export default {
         menu: false,
         content: 'settings'
       },
-      loginErrorText: "",
       loginButtonDisabled: false,
       userName: "",
       passwordLogin: "",
@@ -103,17 +102,11 @@ export default {
   methods: {
     login() {
       this.loginButtonDisabled = true;
-      if (this.userName != "" && this.passwordLogin != "") {
-        this.$root.socket.username = this.userName;
-        this.$root.socket.password = this.passwordLogin;
-        if (this.$root.socket.authenticate()) {
-          this.loginErrorText = "";
-        } else {
-          this.loginErrorText = "Invalid username or password!";
-        }
-      } else {
-        this.loginErrorText = "Please enter a valid username and password!";
-      }
+      this.$root.socket.username = this.userName;
+      this.$root.socket.password = this.passwordLogin;
+      this.$root.socket.authenticate();
+      this.userName = "";
+      this.passwordLogin = "";
       this.loginButtonDisabled = false;
     },
     resize: function () {

+ 1 - 1
webui/src/components/window/side-navigation-bar.vue

@@ -37,7 +37,7 @@
 
       <div class="item"
            @click="$root.socket.logout()">
-        <img alt="Logout" src="@/assets/icons/file-media.svg">
+        <img alt="Logout" src="@/assets/icons/chevron-left.svg">
         <span>Logout</span>
       </div>
 

+ 38 - 1
webui/src/main.js

@@ -38,6 +38,16 @@ new Vue({
             project: null,
             connected: false,
             socket: {
+                username: null,
+                password: null,
+                authenticated: false,
+                latestErrorTxt: "",
+                headers: function () {
+                    const authHeaders = new Headers();
+                    authHeaders.set('Authorization', 'Basic ' + window.btoa(this.username + ":" + this.password));
+                    authHeaders.set('Content-Type', 'application/json');
+                    return authHeaders;
+                },
                 url: function (name) {
                     if (name.startsWith('http'))
                         return name;
@@ -56,14 +66,18 @@ new Vue({
                     name = this.url(name);
                     console.log('get', name);
                     return fetch(name, {
-                        method: 'GET'
+                        credentials: 'include',
+                        method: 'GET',
+                        headers: this.headers()
                     });
                 },
                 post: function (name, value) {
                     name = this.url(name);
                     console.log('post', name, value);
                     return fetch(name, {
+                        credentials: 'include',
                         method: 'POST',
+                        headers: this.headers(),
                         body: JSON.stringify(value)
                     });
                 },
@@ -74,10 +88,33 @@ new Vue({
 
                     console.log('upload', name, file);
                     return fetch(name, {
+                        credentials: 'include',
                         method: 'POST',
+                        headers: this.headers(),
                         body: form
                     });
                 },
+                authenticate: function () {
+                  if (this.username == "" && this.password == "") {
+                    this.latestErrorTxt = "Please enter a valid username and password!";
+                    return false;
+                  }
+
+                  this.get('/authenticate')
+                    .then((response) => {
+                      this.authenticated = (response.status === 200);
+                      if (!this.authenticated) {
+                        this.latestErrorTxt = "Invalid username or password!";
+                      }
+                    });
+                    return this.authenticated;
+                },
+                logout: function () {
+                  this.authenticated = false;
+                  this.username = null;
+                  this.password = null;
+                  this.latestErrorTxt = "";
+                },
                 media: function (file, maxWidth, maxHeight) {
                     if (maxHeight)
                         return `${self}/data/${file.identifier}/${maxWidth}x${maxHeight}?uuid=${file.uuid}`