diff --git a/docker/.env b/docker/.env index 484dada73..8e3bd452e 100644 --- a/docker/.env +++ b/docker/.env @@ -13,6 +13,7 @@ TZ=Europe/Bratislava # Default passwords. CHANGE THESE FOR PRODUCTION! POSTGRES_PASSWORD=supersecret +POSTGRES_KEYCLOAK_PASSWORD=supersecret JWT_SECRET_KEY=supersecret COLLECTOR_PRESENTER_PUBLISHER_API_KEY=supersecret @@ -33,3 +34,8 @@ DB_MAX_CONNECTIONS=1000 # Ports PRESENTER_PORT=5002 + +# Standalone Keycloak +KEYCLOAK_VERSION=16.1.1 +KEYCLOAK_USER=admin +KEYCLOAK_PASSWORD=supersecret diff --git a/docker/Dockerfile.keycloak b/docker/Dockerfile.keycloak new file mode 100644 index 000000000..a9fdabbda --- /dev/null +++ b/docker/Dockerfile.keycloak @@ -0,0 +1,6 @@ +FROM jboss/keycloak:15.0.2 + +COPY ./src/keycloak/realm-export.json /opt/jboss/keycloak/realm-export.json + +COPY ./src/keycloak/disable-theme-cache.cli /opt/jboss/startup-scripts/disable-theme-cache.cli +COPY ./src/keycloak/theme /opt/jboss/keycloak/themes/taranis-ng diff --git a/docker/docker-compose-keycloak-serv.yml b/docker/docker-compose-keycloak-serv.yml new file mode 100644 index 000000000..aba75ba94 --- /dev/null +++ b/docker/docker-compose-keycloak-serv.yml @@ -0,0 +1,64 @@ +version: "3.9" + +services: + keycloak_db: + image: "postgres:${POSTGRES_TAG}" + restart: unless-stopped + environment: + POSTGRES_DB: "taranis-ng-keycloak" + POSTGRES_USER: "taranis-ng-keycloak" + POSTGRES_PASSWORD: "${POSTGRES_KEYCLOAK_PASSWORD}" + command: ["postgres", "-c", "shared_buffers=${DB_SHARED_BUFFERS}", "-c", "max_connections=${DB_MAX_CONNECTIONS}"] + volumes: + - "keycloak_db_data:/var/lib/postgresql/data" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + keycloak: + image: "skcert/taranis-ng-keycloak:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.keycloak + restart: unless-stopped + depends_on: + - keycloak_db + environment: + DB_VENDOR: postgres + DB_ADDR: keycloak_db + DB_PORT: 5432 + DB_DATABASE: taranis-ng-keycloak + DB_USER: taranis-ng-keycloak + DB_PASSWORD: "${POSTGRES_KEYCLOAK_PASSWORD}" + KEYCLOAK_IMPORT: "/opt/jboss/keycloak/realm-export.json" + KEYCLOAK_FRONTEND_URL: "${TARANIS_NG_HTTPS_URI}/api/v1/keycloak/auth" + KEYCLOAK_USER: "${KEYCLOAK_USER}" + KEYCLOAK_PASSWORD: "${KEYCLOAK_PASSWORD}" + KEYCLOAK_DEFAULT_THEME: "taranis-ng" + PROXY_ADDRESS_FORWARDING: "false" + JAVA_OPTS: "-Dkeycloak.profile.feature.upload_scripts=enabled" + volumes: + - "keycloak_data:/opt/jboss/keycloak/standalone/data" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + labels: + traefik.enable: "true" + traefik.http.services.taranis-keycloak.loadbalancer.server.port: "8080" + traefik.http.middlewares.taranis-keycloak-stripprefix.stripprefix.prefixes: "/api/v1/keycloak" + + traefik.http.routers.taranis-keycloak-443.entrypoints: "websecure" + traefik.http.routers.taranis-keycloak-443.rule: "PathPrefix(`/api/v1/keycloak/auth`)" + traefik.http.routers.taranis-keycloak-443.tls: "true" + traefik.http.routers.taranis-keycloak-443.tls.domains[0].main: "${TARANIS_NG_HOSTNAME}" + traefik.http.routers.taranis-keycloak-443.middlewares: "taranis-keycloak-stripprefix" + traefik.http.routers.taranis-keycloak-443.service: "taranis-keycloak" + +volumes: + keycloak_db_data: + keycloak_data: + diff --git a/docker/docker-compose-keycloak.yml b/docker/docker-compose-keycloak.yml new file mode 100644 index 000000000..d8d702964 --- /dev/null +++ b/docker/docker-compose-keycloak.yml @@ -0,0 +1,289 @@ +version: "3.9" + +services: + redis: + image: "redis:${REDIS_TAG}" + restart: unless-stopped + environment: + TZ: "${TZ}" + volumes: + - "redis_conf:/usr/local/etc/redis" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + database: + image: "postgres:${POSTGRES_TAG}" + restart: unless-stopped + environment: + POSTGRES_DB: "taranis-ng" + POSTGRES_USER: "taranis-ng" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TZ: "${TZ}" + PGTZ: "${TZ}" + command: ["postgres", "-c", "shared_buffers=${DB_SHARED_BUFFERS}", "-c", "max_connections=${DB_MAX_CONNECTIONS}"] + volumes: + - "database_data:/var/lib/postgresql/data" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + core: + depends_on: + - "redis" + - "database" + restart: unless-stopped + image: "skcert/taranis-ng-core:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.core + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" + environment: + REDIS_URL: "redis://redis" + DB_URL: "database" + DB_DATABASE: "taranis-ng" + DB_USER: "taranis-ng" + DB_PASSWORD: "${POSTGRES_PASSWORD}" + DB_POOL_SIZE: 100 + DB_POOL_RECYCLE: 300 + DB_POOL_TIMEOUT: 30 + + JWT_SECRET_KEY: "${JWT_SECRET_KEY}" + OPENID_LOGOUT_URL: "${TARANIS_NG_HTTPS_URI}/api/v1/keycloak/auth/realms/taranis-ng/protocol/openid-connect/logout?redirect_uri=GOTO_URL" + TARANIS_NG_KEYCLOAK_INTERNAL_URL: "http://keycloak:8080" + TARANIS_NG_KEYCLOAK_REALM: "taranis-ng" + TARANIS_NG_KEYCLOAK_CLIENT_ID: "taranis-ng" + TARANIS_NG_KEYCLOAK_CLIENT_SECRET: "supersecret" + TARANIS_NG_AUTHENTICATOR: "keycloak" + KEYCLOAK_USER_MANAGEMENT: "true" + KEYCLOAK_SERVER_URL: "http://keycloak:8080" + KEYCLOAK_ADMIN_USERNAME: "admin" + KEYCLOAK_ADMIN_PASSWORD: "supersecret" + KEYCLOAK_REALM_NAME: "taranis-ng" + KEYCLOAK_CLIENT_SECRET_KEY: "supersecret" + KEYCLOAK_VERIFY: "true" + WORKERS_PER_CORE: "1" + + CVE_UPDATE_FILE: "${CVE_UPDATE_FILE}" + CPE_UPDATE_FILE: "${CPE_UPDATE_FILE}" + + TZ: "${TZ}" + DEBUG: "true" + DEBUG_SQL: "false" + # to allow automatic initialisation of collectors/presenters/publishers + COLLECTOR_PRESENTER_PUBLISHER_API_KEY: "${COLLECTOR_PRESENTER_PUBLISHER_API_KEY}" + labels: + traefik.enable: "true" + traefik.http.services.taranis-api.loadbalancer.server.port: "80" + + traefik.http.routers.taranis-api-443.entrypoints: "websecure" + traefik.http.routers.taranis-api-443.rule: "PathPrefix(`/api/`)" + traefik.http.routers.taranis-api-443.tls: "true" + traefik.http.routers.taranis-api-443.tls.domains[0].main: "${TARANIS_NG_HOSTNAME}" + traefik.http.routers.taranis-api-443.service: "taranis-api" + + traefik.http.routers.taranis-sse-443.entrypoints: "websecure" + traefik.http.routers.taranis-sse-443.rule: "PathPrefix(`/sse`)" + traefik.http.routers.taranis-sse-443.tls: "true" + traefik.http.routers.taranis-sse-443.tls.domains[0].main: "${TARANIS_NG_HOSTNAME}" + traefik.http.routers.taranis-sse-443.service: "taranis-api" + + volumes: + - "core_data:/data" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + bots: + depends_on: + core: + condition: service_healthy + image: "skcert/taranis-ng-bots:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.bots + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" + environment: + API_KEY: "${COLLECTOR_PRESENTER_PUBLISHER_API_KEY}" + TARANIS_NG_CORE_URL: "http://core" + TARANIS_NG_CORE_SSE: "http://core/sse" + WORKERS_PER_CORE: "1" + TZ: "${TZ}" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + collectors: + depends_on: + core: + condition: service_healthy + restart: unless-stopped + image: "skcert/taranis-ng-collectors:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.collectors + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" + environment: + TARANIS_NG_CORE_URL: "http://core" + API_KEY: "${COLLECTOR_PRESENTER_PUBLISHER_API_KEY}" + WORKERS_PER_CORE: "1" + DEBUG: "true" + TZ: "${TZ}" + volumes: + - "collector_storage:/app/storage" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + presenters: + depends_on: + core: + condition: service_healthy + restart: unless-stopped + image: "skcert/taranis-ng-presenters:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.presenters + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" + environment: + TARANIS_NG_CORE_URL: "http://core" + API_KEY: "${COLLECTOR_PRESENTER_PUBLISHER_API_KEY}" + WORKERS_PER_CORE: "1" + TZ: "${TZ}" + ports: + - "${PRESENTER_PORT}:80" + volumes: + - "presenters_templates:/app/templates" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + publishers: + depends_on: + core: + condition: service_healthy + restart: unless-stopped + image: "skcert/taranis-ng-publishers:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.publishers + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" + environment: + TARANIS_NG_CORE_URL: "http://core" + API_KEY: "${COLLECTOR_PRESENTER_PUBLISHER_API_KEY}" + WORKERS_PER_CORE: "1" + TZ: "${TZ}" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + gui: + depends_on: + - "core" + restart: unless-stopped + image: "skcert/taranis-ng-gui:${TARANIS_NG_TAG}" + build: + context: .. + dockerfile: ./docker/Dockerfile.gui + args: + HTTP_PROXY: "${HTTP_PROXY}" + HTTPS_PROXY: "${HTTPS_PROXY}" + http_proxy: "${HTTP_PROXY}" + https_proxy: "${HTTPS_PROXY}" +# ports: +# - "8080:80" + environment: + NGINX_WORKERS: "4" + NGINX_CONNECTIONS: "16" + VUE_APP_TARANIS_NG_URL: "${TARANIS_NG_HTTPS_URI}" + VUE_APP_TARANIS_NG_CORE_API: "${TARANIS_NG_HTTPS_URI}/api/v1" + VUE_APP_TARANIS_NG_CORE_SSE: "${TARANIS_NG_HTTPS_URI}/sse" + VUE_APP_TARANIS_NG_LOCALE: en + VUE_APP_TARANIS_NG_LOGOUT_URL: "${TARANIS_NG_HTTPS_URI}/api/v1/auth/logout?gotoUrl=TARANIS_GUI_URI" + VUE_APP_TARANIS_NG_LOGIN_URL: "${TARANIS_NG_HTTPS_URI}/api/v1/keycloak/auth/realms/taranis-ng/protocol/openid-connect/auth?response_type=code&client_id=taranis-ng&redirect_uri=TARANIS_GUI_URI" + TZ: "${TZ}" + labels: + traefik.enable: "true" + traefik.http.services.taranis-gui.loadbalancer.server.port: "80" + + traefik.http.middlewares.redirect-to-443.redirectscheme.scheme: "https" + traefik.http.middlewares.redirect-to-443.redirectscheme.port: "${TARANIS_NG_HTTPS_PORT}" + + traefik.http.routers.taranis-gui-80.entrypoints: "web" + traefik.http.routers.taranis-gui-80.rule: "PathPrefix(`/`)" + traefik.http.routers.taranis-gui-80.middlewares: "redirect-to-443" + + traefik.http.routers.taranis-gui-443.entrypoints: "websecure" + traefik.http.routers.taranis-gui-443.rule: "PathPrefix(`/`)" + traefik.http.routers.taranis-gui-443.tls: "true" + traefik.http.routers.taranis-gui-443.tls.domains[0].main: "${TARANIS_NG_HOSTNAME}" + traefik.http.routers.taranis-gui-443.service: "taranis-gui" + + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + + traefik: + depends_on: + - "gui" + - "core" + restart: unless-stopped + image: "traefik:latest" + environment: + TZ: "${TZ}" + ports: + - "${TARANIS_NG_HTTP_PORT}:80" + - "${TARANIS_NG_HTTPS_PORT}:443" + - "${TRAEFIK_MANAGEMENT_PORT}:9090" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik:/etc/traefik:ro" + - "./tls:/opt/certs" + logging: + driver: "json-file" + options: + max-size: "200k" + max-file: "10" + +volumes: + redis_conf: + database_data: + core_data: + presenters_templates: + collector_storage: diff --git a/src/core/README.md b/src/core/README.md index 0a83bf2df..e0185404b 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -54,10 +54,13 @@ TARANIS_NG_AUTHENTICATOR: "keycloak" KEYCLOAK_REALM_NAME: "taranis-ng" KEYCLOAK_USER_MANAGEMENT: "false" ``` + +You can use and modify the existing `docker-compose-keycloak.yml` example in the repository. + # **LDAP authentication** If you prefer to authenticate users with LDAP, you need to set environment variables similarly to this: ``` TARANIS_NG_AUTHENTICATOR: "ldap" LDAP_SERVER: "ldaps://ldap.example.com" LDAP_BASE_DN: "ou=people,dc=example,dc=com" -``` \ No newline at end of file +``` diff --git a/src/core/api/keycloak.py b/src/core/api/keycloak.py index 6f8623518..2ff49c6f2 100644 --- a/src/core/api/keycloak.py +++ b/src/core/api/keycloak.py @@ -12,19 +12,19 @@ class Keycloak(Resource): matchers = [ re.compile(r"^" + re.escape(str(environ[ - 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/taranis_ng\/protocol\/openid-connect\/auth\?(response_type\=code)\&(client_id\=taranis_ng)\&(redirect_uri\=(https?%3[aA]\/\/[a-z0-9A-Z%\/\.\-_]*|https?%3[aA]%2[fF]%2[fF][a-z0-9A-Z%\/\.\-_]*))$"), + 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/" + str(environ['KEYCLOAK_REALM_NAME']) + "\/protocol\/openid-connect\/auth\?(response_type\=code)\&(client_id\=taranis_ng)\&(redirect_uri\=(https?%3[aA]\/\/[a-z0-9A-Z%\/\.\-_]*|https?%3[aA]%2[fF]%2[fF][a-z0-9A-Z%\/\.\-_]*))$"), # login url re.compile(r"^" + re.escape(str(environ[ - 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/taranis_ng\/login-actions\/authenticate(\??session_code\=[a-zA-Z0-9\-_]+)?(\&?\??execution\=[\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})?(\&?\??client_id\=taranis_ng)?(\&?\??tab_id=[a-zA-Z0-9\-_]+)?$"), + 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/" + str(environ['KEYCLOAK_REALM_NAME']) + "\/login-actions\/authenticate(\??session_code\=[a-zA-Z0-9\-_]+)?(\&?\??execution\=[\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})?(\&?\??client_id\=taranis_ng)?(\&?\??tab_id=[a-zA-Z0-9\-_]+)?$"), # login submit url re.compile(r"^" + re.escape(str(environ[ - 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/taranis_ng\/protocol\/openid-connect\/logout\?(redirect_uri\=(https?%3[aA]\/\/[a-z0-9A-Z%\/\.\-_]*|https?%3[aA]%2[fF]%2[fF][a-z0-9A-Z%\/\.\-_]*))$"), + 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/" + str(environ['KEYCLOAK_REALM_NAME']) + "\/protocol\/openid-connect\/logout\?(redirect_uri\=(https?%3[aA]\/\/[a-z0-9A-Z%\/\.\-_]*|https?%3[aA]%2[fF]%2[fF][a-z0-9A-Z%\/\.\-_]*))$"), # logout url re.compile(r"^" + re.escape(str( environ['TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/resources\/([^\.]*|[^\.]*\.[^\.]*|[^\.]*\.[^\.]*\.[^\.]*)$"), # resources url re.compile(r"^" + re.escape(str(environ[ - 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/taranis_ng\/login-actions\/required-action(\??session_code\=[a-zA-Z0-9\-_]+)?(\??\&?execution\=(UPDATE_PASSWORD))(\&?\??client_id\=taranis_ng)?(\&?\??tab_id=[a-zA-Z0-9\-_]+)?$"), + 'TARANIS_NG_KEYCLOAK_URL'])) + r"\/auth\/realms\/" + str(environ['KEYCLOAK_REALM_NAME']) + "\/login-actions\/required-action(\??session_code\=[a-zA-Z0-9\-_]+)?(\??\&?execution\=(UPDATE_PASSWORD))(\&?\??client_id\=taranis_ng)?(\&?\??tab_id=[a-zA-Z0-9\-_]+)?$"), # reset password url ] @@ -44,7 +44,7 @@ def proxy(self): resp = requests.request( method=request.method, url=request.url.replace(str(environ['TARANIS_NG_KEYCLOAK_URL']) + '/', - os.getenv('TARANIS_NG_KEYCLOAK_INTERNAL_URL'), 1), + os.getenv('TARANIS_NG_KEYCLOAK_INTERNAL_URL') + '/', 1), headers={key: value for (key, value) in request.headers if key != 'Host'}, data=request.get_data(), cookies=request.cookies, diff --git a/src/core/auth/keycloak_authenticator.py b/src/core/auth/keycloak_authenticator.py index d1dc38454..0e5218543 100644 --- a/src/core/auth/keycloak_authenticator.py +++ b/src/core/auth/keycloak_authenticator.py @@ -19,7 +19,7 @@ def authenticate(self, credentials): # verify code and get JWT token from keycloak response = post( url=environ.get( - 'TARANIS_NG_KEYCLOAK_INTERNAL_URL') + 'auth/realms/taranis_ng/protocol/openid-connect/token', + 'TARANIS_NG_KEYCLOAK_INTERNAL_URL') + '/auth/realms/' + environ.get('KEYCLOAK_REALM_NAME') + '/protocol/openid-connect/token', data={ 'grant_type': 'authorization_code', 'code': request.args['code'], # code from url @@ -30,13 +30,15 @@ def authenticate(self, credentials): environ.get('TARANIS_NG_KEYCLOAK_CLIENT_SECRET')), # do not forget credentials proxies={'http': None, 'https': None}, - allow_redirects=False) + allow_redirects=False, verify=False) data = None try: # get json data from response data = response.json() + log_manager.log_debug('Keycloak authentication response:') + log_manager.log_debug(data) except Exception: log_manager.store_auth_error_activity("Keycloak returned an unexpected response.") return {'error': 'Internal server error'}, 500 diff --git a/src/core/config.py b/src/core/config.py index e824d5d06..2323c95a5 100755 --- a/src/core/config.py +++ b/src/core/config.py @@ -38,7 +38,7 @@ class Config(object): OIDC_ID_TOKEN_COOKIE_SECURE = False OIDC_REQUIRE_VERIFIED_EMAIL = False OIDC_USER_INFO_ENABLED = True - OIDC_OPENID_REALM = 'jiskb' + OIDC_OPENID_REALM = 'taranis-ng' OIDC_SCOPES = ['openid'] OIDC_INTROSPECTION_AUTH_METHOD = 'client_secret_post' OIDC_TOKEN_TYPE_HINT = 'access_token' diff --git a/src/gui/src/api/auth.js b/src/gui/src/api/auth.js index e2df043d1..157719c3f 100644 --- a/src/gui/src/api/auth.js +++ b/src/gui/src/api/auth.js @@ -1,7 +1,10 @@ import ApiService from "@/services/api_service"; -export function authenticate(userData) { - return ApiService.post(`/auth/login`, userData) +export function authenticate(userData, method = 'post') { + if (method === 'post') + return ApiService.post(`/auth/login`, userData); + else + return ApiService.get(`/auth/login`, userData); } export function refresh() { diff --git a/src/gui/src/services/api_service.js b/src/gui/src/services/api_service.js index 9537148ea..34fb5ba9a 100644 --- a/src/gui/src/services/api_service.js +++ b/src/gui/src/services/api_service.js @@ -15,8 +15,8 @@ const ApiService = { } }, - get(resource) { - return axios.get(resource) + get(resource, data = {}) { + return axios.get(resource, data) }, post(resource, data) { diff --git a/src/gui/src/store/auth/taranis_authenticator.js b/src/gui/src/store/auth/taranis_authenticator.js index a960f5ba1..7755fa881 100644 --- a/src/gui/src/store/auth/taranis_authenticator.js +++ b/src/gui/src/store/auth/taranis_authenticator.js @@ -9,7 +9,7 @@ const actions = { login(context, userData) { - return authenticate(userData) + return authenticate(userData, userData.method) .then(response => { context.commit('setJwtToken', response.data.access_token); context.dispatch('setUser', context.getters.getUserData); diff --git a/src/gui/src/views/Login.vue b/src/gui/src/views/Login.vue index 494d37800..6d991992d 100644 --- a/src/gui/src/views/Login.vue +++ b/src/gui/src/views/Login.vue @@ -1,5 +1,5 @@