diff --git a/.env.example b/.env.example index 2ef7a29..142d97b 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ DJANGO_DB_LOG_HANDLER=console # Should the database be migrated before start (entrypoint.sh - docker setup). Will be migrated anyway if $SITE_ROOT=api. Comment out for False DJANGO_MIGRATE=True # Should the modules be searched for scheduled tasks. Comment out for false -# SCHEDULER_AUTOSTART=True +SCHEDULER_AUTOSTART=True PROJECT_NAME=dev NEW_OPENIMIS_HOST=dev-openimis.org HTTP_PORT=80 @@ -41,5 +41,23 @@ GW_BRANCH=develop BE_BRANCH=develop FE_BRANCH=develop +# Lockout mechanism +LOGIN_LOCKOUT_FAILURE_LIMIT=5 # Allowed login failures before lockout +LOGIN_LOCKOUT_COOLOFF_TIME=5 # Lockout duration in minutes +PASSWORD_MIN_LENGTH=8 +PASSWORD_UPPERCASE=1 # Minimum number of uppercase letters +PASSWORD_LOWERCASE=1 # Minimum number of lowercase letters +PASSWORD_DIGITS=1 # Minimum number of digits +PASSWORD_SYMBOLS=1 # Minimum number of symbols +PASSWORD_SPACES=1 # Maximum number of spaces allowed +CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8000 # Define the trusted origins for CSRF protection, separated by commas + +# Rate limiting settings +RATELIMIT_CACHE=default # The cache alias to use for rate limiting +RATELIMIT_KEY=ip # Key to identify the client; 'ip' means it will use the client's IP address +RATELIMIT_RATE=150/m # Rate limit (150 requests per minute) +RATELIMIT_METHOD=ALL # HTTP methods to rate limit; 'ALL' means all methods +RATELIMIT_GROUP=graphql # Group name for the rate limit +RATELIMIT_SKIP_TIMEOUT=False # Whether to skip rate limiting during c diff --git a/.github/workflows/ci_assembly.yml b/.github/workflows/ci_assembly.yml index a91bbfa..1e60d52 100755 --- a/.github/workflows/ci_assembly.yml +++ b/.github/workflows/ci_assembly.yml @@ -63,13 +63,13 @@ jobs: ${{ runner.os }}- - name: Install Python dependencies + working-directory: ./script run: | sudo apt-get update sudo apt-get install jq - python -m pip install --upgrade pip - pip install -r requirements.txt - python modules-requirements.py openimis.json > modules-requirements.txt + pip install -r ../requirements.txt + python modules-requirements.py ../openimis.json > modules-requirements.txt pip install --no-cache-dir -r modules-requirements.txt export MODULES=$(jq -r '(.modules[].name)' openimis.json | xargs) echo $modules diff --git a/.github/workflows/ci_module.yml b/.github/workflows/ci_module.yml index 498963b..b4fce97 100755 --- a/.github/workflows/ci_module.yml +++ b/.github/workflows/ci_module.yml @@ -81,15 +81,15 @@ jobs: echo "MODULE_NAME=$MODULE_NAME" >> $GITHUB_ENV # Add or replace MODULE_NAME module in openimis.json (local version) - echo $(jq --arg name "$MODULE_NAME" 'if [.modules[].name == ($name)]| max then (.modules[] | select(.name == ($name)) | .pip)|="-e ../current-module" else .modules |= .+ [{name:($name), pip:"../current-module"}] end' openimis.json) > openimis.json + echo $(jq --arg name "$MODULE_NAME" 'if [.modules[].name == ($name)]| max then (.modules[] | select(.name == ($name)) | .pip)|="-e ../../current-module" else .modules |= .+ [{name:($name), pip:"../../current-module"}] end' openimis.json) > openimis.json cat openimis.json - name: Install Python dependencies - working-directory: ./openimis + working-directory: ./openimis/script run: | python -m pip install --upgrade pip - pip install -r requirements.txt - python modules-requirements.py openimis.json > modules-requirements.txt + pip install -r ../requirements.txt + python modules-requirements.py ../openimis.json > modules-requirements.txt cat modules-requirements.txt pip install --no-cache-dir -r modules-requirements.txt diff --git a/.gitignore b/.gitignore index d2d6a77..da2798b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,9 @@ extracted_translations_fe script/config.py **/src/* **/images/insurees +openimis-dev.json +# Except for the runConfigurations folder +!.idea/runConfigurations +# Ensure all files in runConfigurations are included +!.idea/runConfigurations/* diff --git a/.idea/runConfigurations/RunTestsWithSetup.xml b/.idea/runConfigurations/RunTestsWithSetup.xml new file mode 100644 index 0000000..5b50783 --- /dev/null +++ b/.idea/runConfigurations/RunTestsWithSetup.xml @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Migrations_.xml b/.idea/runConfigurations/Run_Migrations_.xml new file mode 100644 index 0000000..81ab375 --- /dev/null +++ b/.idea/runConfigurations/Run_Migrations_.xml @@ -0,0 +1,44 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Runserver_.xml b/.idea/runConfigurations/Runserver_.xml new file mode 100644 index 0000000..d61befc --- /dev/null +++ b/.idea/runConfigurations/Runserver_.xml @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/_CreateTestDB.xml b/.idea/runConfigurations/_CreateTestDB.xml new file mode 100644 index 0000000..0b25bd0 --- /dev/null +++ b/.idea/runConfigurations/_CreateTestDB.xml @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/_TeardownTestDB.xml b/.idea/runConfigurations/_TeardownTestDB.xml new file mode 100644 index 0000000..8d64f2f --- /dev/null +++ b/.idea/runConfigurations/_TeardownTestDB.xml @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 2bd02f5..b26dc3f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -139,6 +139,19 @@ }, "justMyCode": true }, + { + "name": "make migration", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/openIMIS/manage.py", + "args": ["makemigrationsy"], + "django": true, + "cwd": "${workspaceRoot}/openIMIS", + "env": { + "DB_DEFAULT": "${input:dbEngine}" + }, + "justMyCode": true + }, { "name": "Start", "type": "python", diff --git a/Dockerfile b/Dockerfile index 68c80e2..4f842cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,8 @@ WORKDIR /openimis-be ARG OPENIMIS_CONF_JSON ENV OPENIMIS_CONF_JSON=${OPENIMIS_CONF_JSON} -RUN python modules-requirements.py openimis.json > modules-requirements.txt && pip install -r modules-requirements.txt - +WORKDIR /openimis-be/script +RUN python modules-requirements.py ../openimis.json > modules-requirements.txt && pip install -r modules-requirements.txt WORKDIR /openimis-be/openIMIS # Compile messages (Exclude zh_Hans) diff --git a/Dockerfile_win b/Dockerfile_win deleted file mode 100644 index ec9ccc3..0000000 --- a/Dockerfile_win +++ /dev/null @@ -1,36 +0,0 @@ -ARG PYTHON_VERSION="3.7" -ARG WINDOWS_VERSION="ltsc2016" -FROM "python:${PYTHON_VERSION}-windowsservercore-${WINDOWS_VERSION}" -ARG GETTEXT_VERSION="0.19.8.1" -ARG OPENIMIS_VERSION="1.4.1" -ENV GETTEXT_URL="https://github.com/vslavik/gettext-tools-windows/releases/download/v${GETTEXT_VERSION}/gettext-tools-windows-${GETTEXT_VERSION}.zip" -LABEL vendor="openIMIS"\ - maintainer="Patrick Delcroix "\ - org.openimis.fe.is-beta= \ - org.openimis.fe.is-production="" \ - org.openimis.fe.version="${OPENIMIS_VERSION}" -SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] -RUN [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;\ - Invoke-WebRequest -Uri "https://chocolatey.org/install.ps1" -UseBasicParsing | iex ; \ - choco install curl -y --no-progress ; \ - choco install sqlserver-odbcdriver -y --no-progress;\ - Write-Output $Env:GETTEXT_URL;\ - Invoke-WebRequest -Uri $Env:GETTEXT_URL -OutFile gettext.zip ;\ - Expand-Archive -Path gettext.zip -DestinationPath C:\gettext; -ENV PYTHONUNBUFFERED 1 -RUN pip install --upgrade pip; -COPY . /openimis-be -WORKDIR /openimis-be -RUN pip install -r requirements.txt -RUN python modules-requirements.py openimis.json > modules-requirements.txt -RUN pip install -r modules-requirements.txt -WORKDIR /openimis-be/openIMIS -RUN $env:PATH = 'C:\gettext\bin;' + $env:PATH; \ - [Environment]::SetEnvironmentVariable('PATH', $env:PATH, [EnvironmentVariableTarget]::Machine); -RUN refreshenv;Set-Item -Path Env:NO_DATABASE -value 'True'; python manage.py compilemessages | Write-Output -RUN refreshenv;Set-Item -Path Env:NO_DATABASE -value 'True';python manage.py collectstatic --clear --noinput | Write-Output -ENTRYPOINT ["powershell ", "-Command","/openimis-be/script/entrypoint.ps1"] -# CMD ["powershell ", "-Command","/openimis-be/script/entrypoint.ps1"] -CMD ["start","database","1433","5"] -ENV REMOTE_USER_AUTHENTICATION = False -#HEALTHCHECK ["powershell ", "-Command","/openimis-be/script/healthcheck.ps1"] diff --git a/README.md b/README.md index e0a0272..3fc617d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ | CACHE_BACKEND | String | Specifies the [caching backend](https://docs.djangoproject.com/en/5.0/topics/cache/#setting-up-the-cache) to be used. Default is set to PyMemcached. | | CACHE_URL | String | Defines the location of the cache backend. Default is `unix:/tmp/memcached.sock` for a Unix socket connection. | | CACHE_OPTIONS | String | A JSON string representing a dictionary of additional options passed to the cache backend. Empty by default | +| RATELIMIT_CACHE | String | The cache alias to use for rate limiting. Defaults to `default`. | +| RATELIMIT_KEY | String | Key to identify the client for rate limiting; `ip` means it will use the client's IP address. Defaults to `ip`. | +| RATELIMIT_RATE | String | Rate limit value (e.g., `150/m` for 150 requests per minute). Defaults to `150/m`. | +| RATELIMIT_METHOD | String | HTTP methods to rate limit; `ALL` means all methods. Defaults to `ALL`. | +| RATELIMIT_GROUP | String | Group name for the rate limit. Defaults to `graphql`. | +| RATELIMIT_SKIP_TIMEOUT | Boolean | Whether to skip rate limiting during cache timeout. Defaults to `False`. | +| CSRF_TRUSTED_ORIGINS | String | Define the trusted origins for CSRF protection, separated by commas. Defaults to `http://localhost:3000,http://localhost:8000`. | ## Developers setup @@ -120,7 +127,7 @@ At this stage, you may (depends on the database you connect to) need to: ### To manage translations of your module -- from your module root dir, execute '../openimis-be_py/gettext.sh' +- from your module root dir, execute '../openimis-be_py/script/gettext.sh' ... this extract all your translations keys from your code into your module root dir/locale/en/LC_MESSAGES/django.po - you may want to provide translation in generated django.po file... or manage them via lokalize (need to upload the keys,...) @@ -186,9 +193,7 @@ When release candidate is accepted: - from tarball: `https://github.com/openimis/openimis-be_py/archive/v1.1.0.tar.gz` - (required only once)`python -m venv ./venv`: create the python venv - `./venv/Script/activate[.sh/.ps1]`: Activate the venv -- `python modules-list.py openimis.json > module-list.txt`: list the module to install -- `python -m pip uninstall -r module-list.txt`: uninstall the previously installed module -- `python modules-requirements.py openimis.json > modules-requirements.txt`: list the source of the module to install +- `python script/modules-requirements.py openimis.json > modules-requirements.txt`: list the source of the module to install - `python -m pip install -r modules-requirements.txt`: Install the modules - `cp .env.example .env`: Copy the example environment setup and adjust the variables (refer to .env.example for more info) - `python manage.py migrate`: execute the migrations @@ -322,6 +327,73 @@ module skeleton in single command` section to extract frontend translations of all modules present in `openimis.json`. - those translations will be copied into 'extracted_translations_fe' folder in assembly backend module +### JWT Security Configuration + +To enhance JWT token security, you can configure the system to use RSA keys for signing and verifying tokens. + +1. **Generate RSA Keys**: + ```bash + # Generate a private key + openssl genpkey -algorithm RSA -out jwt_private_key.pem -aes256 + + # Generate a public key + openssl rsa -pubout -in jwt_private_key.pem -out jwt_public_key.pem + +2. **Store RSA Keys**: + Place jwt_private_key.pem and jwt_public_key.pem in a secure directory within your project, e.g., keys/. + +3. **Django Configuration**: + Ensure that the settings.py file is configured to read these keys. If RSA keys are found, the system will use RS256. Otherwise, it will fallback to HS256 using DJANGO_SECRET_KEY. + +Note: If RSA keys are not provided, the system defaults to HS256. Using RS256 with RSA keys is recommended for enhanced security. + + +## CSRF Setup Guide + +CSRF (Cross-Site Request Forgery) protection ensures that unauthorized commands are not performed on behalf of authenticated users without their consent. It achieves this by including a unique token in each form submission or AJAX request, which is then validated by the server. +When using JWT (JSON Web Token) for authentication, CSRF protection is not executed because the server does not rely on cookies for authentication. Instead, the JWT is included in the request headers, making CSRF attacks less likely. + +### Development Environment + +In the development environment, CSRF protection is configured to allow requests from `localhost:3000` and `localhost:8000` by default in .env.example file. + +### Production Environment + +In the production environment, you need to specify the trusted origins in your `.env` file. + +1. **Trusted Origins Setup**: + - Define the trusted origins in your `.env` file to allow cross-origin requests from specific domains. + - Use a comma-separated list to specify multiple origins. + - Example of setting trusted origins in `.env`: + ```env + CSRF_TRUSTED_ORIGINS=https://example.com,https://api.example.com + ``` + + +## Security Headers + +This section describes the security headers used in the application, based on OWASP recommendations, to enhance the security of your Django application. + +### Security Headers in Production + +In the production environment, several security headers are set to protect the application from common vulnerabilities: + +- **Strict-Transport-Security**: `max-age=63072000; includeSubDomains` - Enforces secure (HTTP over SSL/TLS) connections to the server and ensures all subdomains also follow this rule. +- **Content-Security-Policy**: `default-src 'self';` - Prevents a wide range of attacks, including Cross-Site Scripting (XSS), by restricting sources of content to the same origin. +- **X-Frame-Options**: `DENY` - Protects against clickjacking attacks by preventing the page from being framed. +- **X-Content-Type-Options**: `nosniff` - Prevents the browser from MIME-sniffing the content type, ensuring that the browser uses the declared content type. +- **Referrer-Policy**: `no-referrer` - Controls how much referrer information is included with requests by not sending any referrer information with requests. +- **Permissions-Policy**: `geolocation=(), microphone=()` - Controls access to browser features by disabling access to geolocation and microphone features. + +In production, additional security settings are applied to cookies used for CSRF and JWT: + +- **CSRF_COOKIE_SECURE**: Ensures the CSRF cookie is only sent over HTTPS. +- **CSRF_COOKIE_HTTPONLY**: Prevents JavaScript from accessing the CSRF cookie. +- **CSRF_COOKIE_SAMESITE**: Sets the `SameSite` attribute to 'Lax', which allows the cookie to be sent with top-level navigations and gets rid of the risk of CSRF attacks. +- **JWT_COOKIE_SECURE**: Ensures the JWT cookie is only sent over HTTPS. +- **JWT_COOKIE_SAMESITE**: Sets the `SameSite` attribute to 'Lax' for the JWT cookie. + + ## Custom exception handler for new modules REST-based modules If the module you want to add to the openIMIS uses its own REST exception handler you have to register diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index cdd61a7..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# This file includes requirements that are not necessary for production systems -# faker, for example is used to generate test data --r requirements.txt -faker==8.1.3 diff --git a/dev_build_launch.ps1 b/dev_build_launch.ps1 deleted file mode 100644 index 4d27dd7..0000000 --- a/dev_build_launch.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -# Sync file -# Requires -# - Python -# - GIT - -$base = "c:\dev\fs" -$branch = "develop" - -$reposBranch ="openimis-be-contribution_py"` -,"openimis-be-contribution_plan_py"` -,"openimis-be-core_py"` -,"openimis-be-calculation_py"` -,"openimis-be-policyholder_py"` -,"openimis-be-contract_py" - -# create base if not existing -if ( -Not (Test-Path -Path $base) ){ - mkdir $base -} - - -cd $base - -if ( -Not (Test-Path -Path openimis-be_py) ){ - Invoke-Expression "git clone https://github.com/openimis/openimis-be_py.git --quiet" -} -cd openimis-be_py -# get the other file from git -#git checkout $branch --quiet -f - - - $reposBranch | ForEach-Object -Process { - $array = $_.split('@') - $repo = $array[0]; - - $curBranch = if ($array.Count -eq 2) {$array[1]} else {$branch} - Write-output "Pulling repository $repo, branch $curBranch" - # FIXMEfetch the repository if not existing - if ( -Not (Test-Path -Path $repo )){ - Invoke-Expression "git clone https://github.com/openimis/$repo.git --quiet" - } - # get the other file from git - cd $repo - - git fetch - git pull - git checkout $curBranch -f - cd $base -} - -# build the front end - -cd openimis-be_py - - - -# assuming venv and openimis-be_py folders are on the same level -if ( -Not (Test-Path -Path ..\venv) ){ - Invoke-Expression "python -m venv ..\venv" -} -Invoke-Expression ..\venv\Scripts\Activate.ps1 -$OPENIMIS_CONF='openimis.json ' -pip install -r requirements.txt -python modules-requirements.py $OPENIMIS_CONF > modules-requirements.txt -pip install -r modules-requirements.txt -cd openIMIS -$SITE_ROOT='iapi' -$DB_NAME='IMISfs' -$DB_USER='IMISuser' -$DB_PASSWORD='IMISuser@1234' -$DB_HOST='127.0.0.1' -$DB_PORT='1433' -$DJANGO_PORT='8000' - - -[Environment]::SetEnvironmentVariable("SITE_ROOT", $SITE_ROOT) -[Environment]::SetEnvironmentVariable("REMOTE_USER_AUTHENTICATION", "True") -[Environment]::SetEnvironmentVariable("ROW_SECURITY", "False") -[Environment]::SetEnvironmentVariable("DEBUG", "True") -[Environment]::SetEnvironmentVariable("DB_NAME", $DB_NAME) -[Environment]::SetEnvironmentVariable("DB_USER", $DB_USER) -[Environment]::SetEnvironmentVariable("DB_PASSWORD", $DB_PASSWORD) -[Environment]::SetEnvironmentVariable("DB_HOST", $DB_HOST) -[Environment]::SetEnvironmentVariable("DB_PORT", $DB_PORT) -[Environment]::SetEnvironmentVariable("DJANGO_PORT", $DJANGO_PORT) -[Environment]::SetEnvironmentVariable("OPENIMIS_CONF", "../"$OPENIMIS_CONF) - -python manage.py migrate -python manage.py runserver 0.0.0.0:$DJANGO_PORT - - - diff --git a/dev_launch.ps1 b/dev_launch.ps1 deleted file mode 100644 index 5031c5a..0000000 --- a/dev_launch.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# assuming venv and openimis-be_py folders are on the same level -..\venv\Scripts\Activate.ps1 -cd openIMIS -$SITE_ROOT='iapi' -$DB_NAME='IMISfs' -$DB_USER='IMISuser' -$DB_PASSWORD='IMISuser@1234' -$DB_HOST='127.0.0.1' -$DB_PORT='1433' -$DJANGO_PORT='8000' -[Environment]::SetEnvironmentVariable("SITE_ROOT", $SITE_ROOT) -[Environment]::SetEnvironmentVariable("REMOTE_USER_AUTHENTICATION", "False") -[Environment]::SetEnvironmentVariable("ROW_SECURITY", "False") -[Environment]::SetEnvironmentVariable("DEBUG", "True") -[Environment]::SetEnvironmentVariable("DB_NAME", $DB_NAME) -[Environment]::SetEnvironmentVariable("DB_USER", $DB_USER) -[Environment]::SetEnvironmentVariable("DB_PASSWORD", $DB_PASSWORD) -[Environment]::SetEnvironmentVariable("DB_HOST", $DB_HOST) -[Environment]::SetEnvironmentVariable("DB_PORT", $DB_PORT) -[Environment]::SetEnvironmentVariable("DJANGO_PORT", $DJANGO_PORT) -[Environment]::SetEnvironmentVariable("OPENIMIS_CONF", "../"$OPENIMIS_CONF) -python manage.py runserver \ No newline at end of file diff --git a/dev_launch.sh b/dev_launch.sh deleted file mode 100755 index 34f123b..0000000 --- a/dev_launch.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -source venv/bin/activate -cd openIMIS -SITE_ROOT=api REMOTE_USER_AUTHENTICATION=False ROW_SECURITY=False DEBUG=True python manage.py runserver diff --git a/modules-links.py b/modules-links.py deleted file mode 100644 index ca375aa..0000000 --- a/modules-links.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import json -import sys - -def load_openimis_conf(): - conf_file_path = sys.argv[1] - if not conf_file_path: - sys.exit("Missing config file path argument") - if not os.path.isfile(conf_file_path): - sys.exit("Config file parameter refers to missing file %s" % conf_file_path) - - with open(conf_file_path) as conf_file: - return json.load(conf_file) - -def extract_requirement(module): - return "ln -s ../../openimis-be-%s_py %s" % (module["name"], module["name"]) - -OPENIMIS_CONF = load_openimis_conf() -MODULES = list(map(extract_requirement, OPENIMIS_CONF["modules"])) -print("\n".join(MODULES)) \ No newline at end of file diff --git a/modules-list.py b/modules-list.py deleted file mode 100644 index 59c29bc..0000000 --- a/modules-list.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import json -import sys - -def load_openimis_conf(): - conf_file_path = sys.argv[1] - if not conf_file_path: - sys.exit("Missing config file path argument") - if not os.path.isfile(conf_file_path): - sys.exit("Config file parameter refers to missing file %s" % conf_file_path) - - with open(conf_file_path) as conf_file: - return json.load(conf_file) - -def extract_requirement(module): - return "%s" % module["name"] - -OPENIMIS_CONF = load_openimis_conf() -MODULES = list(map(extract_requirement, OPENIMIS_CONF["modules"])) -print("\n".join(MODULES)) \ No newline at end of file diff --git a/modules-unlinks.py b/modules-unlinks.py deleted file mode 100644 index a736388..0000000 --- a/modules-unlinks.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import json -import sys - -def load_openimis_conf(): - conf_file_path = sys.argv[1] - if not conf_file_path: - sys.exit("Missing config file path argument") - if not os.path.isfile(conf_file_path): - sys.exit("Config file parameter refers to missing file %s" % conf_file_path) - - with open(conf_file_path) as conf_file: - return json.load(conf_file) - -def extract_requirement(module): - return "rm %s" % module["name"] - -OPENIMIS_CONF = load_openimis_conf() -MODULES = list(map(extract_requirement, OPENIMIS_CONF["modules"])) -print("\n".join(MODULES)) \ No newline at end of file diff --git a/openIMIS/apscheduler_runner/apps.py b/openIMIS/apscheduler_runner/apps.py index 125974e..d3c493b 100644 --- a/openIMIS/apscheduler_runner/apps.py +++ b/openIMIS/apscheduler_runner/apps.py @@ -5,6 +5,8 @@ from django.conf import settings from django.apps import AppConfig from copy import deepcopy +import importlib.util + logger = logging.getLogger(__name__) @@ -27,14 +29,14 @@ def _setup_scheduler_background_task(self): self.scheduler.start() def __add_module_tasks_to_scheduler(self, app_): - try: - module = __import__(f"{app_}.scheduled_tasks") - if hasattr(module.scheduled_tasks, "schedule_tasks"): - module.scheduled_tasks.schedule_tasks(self.scheduler) + spec = importlib.util.find_spec(f"{app_}.scheduled_tasks") + if spec: + try: + app = __import__(f"{app_}.scheduled_tasks") + app.scheduled_tasks.schedule_tasks(self.scheduler) logger.debug(f"{app_} tasks scheduled") - else: - logger.debug(f"{app_} has a scheduled_tasks package but no schedule_tasks callable") - except ModuleNotFoundError as exc: + except Exception as exc: + logger.debug(f"{app_}: unknown exception occurred during registering scheduled tasks: {exc}") + else: logger.debug(f"{app_} has no scheduled_tasks module, skipping") - except Exception as exc: - logger.debug(f"{app_}: unknown exception occurred during registering scheduled tasks: {exc}") + diff --git a/openIMIS/openIMIS/asgi.py b/openIMIS/openIMIS/asgi.py index e84aac7..563c093 100644 --- a/openIMIS/openIMIS/asgi.py +++ b/openIMIS/openIMIS/asgi.py @@ -1,5 +1,4 @@ from channels.auth import AuthMiddlewareStack -import dashboard_etl.routing import json import os import logging diff --git a/openIMIS/openIMIS/openimisapps.py b/openIMIS/openIMIS/openimisapps.py index 202bb6f..0200373 100644 --- a/openIMIS/openIMIS/openimisapps.py +++ b/openIMIS/openIMIS/openimisapps.py @@ -21,8 +21,11 @@ def get_locale_folders(): basedirs = [] for mod in load_openimis_conf()["modules"]: mod_name = mod["name"] - with resources.path(mod_name, "__init__.py") as path: - apps.append(path.parent.parent) # This might need to be more restrictive + try: + with resources.path(mod_name, "__init__.py") as path: + apps.append(path.parent.parent) + except ModuleNotFoundError: + raise Exception(f"Module \"{mod_name}\" not found.") for topdir in ["."] + apps: for dirpath, dirnames, filenames in os.walk(topdir, topdown=True): diff --git a/openIMIS/openIMIS/settings.py b/openIMIS/openIMIS/settings.py index 85432ef..f3df59f 100644 --- a/openIMIS/openIMIS/settings.py +++ b/openIMIS/openIMIS/settings.py @@ -8,6 +8,7 @@ from dotenv import load_dotenv from .openimisapps import openimis_apps, get_locale_folders from datetime import timedelta +from cryptography.hazmat.primitives import serialization load_dotenv() @@ -169,7 +170,8 @@ def SITE_URL(): "django_apscheduler", "channels", # Websocket support "developer_tools", - "drf_spectacular" # Swagger UI for FHIR API + "drf_spectacular", # Swagger UI for FHIR API + "axes", ] INSTALLED_APPS += OPENIMIS_APPS INSTALLED_APPS += ["apscheduler_runner", "signal_binding"] # Signal binding should be last installed module @@ -180,6 +182,7 @@ def SITE_URL(): AUTHENTICATION_BACKENDS += ["django.contrib.auth.backends.RemoteUserBackend"] AUTHENTICATION_BACKENDS += [ + "axes.backends.AxesStandaloneBackend", "rules.permissions.ObjectPermissionBackend", "graphql_jwt.backends.JSONWebTokenBackend", "django.contrib.auth.backends.ModelBackend", @@ -216,13 +219,31 @@ def SITE_URL(): MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", + 'core.middleware.GraphQLRateLimitMiddleware', + "axes.middleware.AxesMiddleware", + "core.middleware.DefaultAxesAttributesMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.middleware.SecurityHeadersMiddleware", ] +MODE = os.environ.get("MODE") + +# Lockout mechanism configuration +AXES_ENABLED = True if os.environ.get("MODE", "DEV") == "PROD" else False +AXES_FAILURE_LIMIT = int(os.getenv("LOGIN_LOCKOUT_FAILURE_LIMIT", 5)) +AXES_COOLOFF_TIME = timedelta(minutes=int(os.getenv("LOGIN_LOCKOUT_COOLOFF_TIME", 5))) + +RATELIMIT_CACHE = os.getenv('RATELIMIT_CACHE', 'default') +RATELIMIT_KEY = os.getenv('RATELIMIT_KEY', 'ip') +RATELIMIT_RATE = os.getenv('RATELIMIT_RATE', '150/m') +RATELIMIT_METHOD = os.getenv('RATELIMIT_METHOD', 'ALL') +RATELIMIT_GROUP = os.getenv('RATELIMIT_GROUP', 'graphql') +RATELIMIT_SKIP_TIMEOUT = os.getenv('RATELIMIT_SKIP_TIMEOUT', 'False') + if DEBUG: # Attach profiler middleware MIDDLEWARE.append( @@ -271,7 +292,6 @@ def SITE_URL(): GRAPHQL_JWT = { "JWT_VERIFY_EXPIRATION": True, - "JWT_LONG_RUNNING_REFRESH_TOKEN": True, "JWT_EXPIRATION_DELTA": timedelta(days=1), "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=30), "JWT_AUTH_HEADER_PREFIX": "Bearer", @@ -288,6 +308,50 @@ def SITE_URL(): ], } +# Load RSA keys +private_key_path = os.path.join(BASE_DIR, 'keys', 'jwt_private_key.pem') +public_key_path = os.path.join(BASE_DIR, 'keys', 'jwt_public_key.pem') + +if os.path.exists(private_key_path) and os.path.exists(public_key_path): + with open(private_key_path, 'rb') as f: + private_key = serialization.load_pem_private_key( + f.read(), + password=None, + ) + + with open(public_key_path, 'rb') as f: + public_key = serialization.load_pem_public_key( + f.read(), + ) + + # If RSA keys exist, update the algorithm and add keys to GRAPHQL_JWT settings + GRAPHQL_JWT.update({ + "JWT_ALGORITHM": "RS256", + "JWT_PRIVATE_KEY": private_key, + "JWT_PUBLIC_KEY": public_key, + }) + +if MODE == "PROD": + # Enhance security in production + GRAPHQL_JWT.update({ + "JWT_COOKIE_SECURE": True, + "JWT_COOKIE_SAMESITE": "Lax", + }) + + CSRF_COOKIE_SECURE = True + CSRF_COOKIE_HTTPONLY = True + CSRF_COOKIE_SAMESITE = 'Lax' + + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_HSTS_SECONDS = 63072000 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SECURE_SSL_REDIRECT = True + +csrf_trusted_origins = os.environ.get('CSRF_TRUSTED_ORIGINS', default='') +CSRF_TRUSTED_ORIGINS = csrf_trusted_origins.split(',') if csrf_trusted_origins else [] + # no db DATABASES = {} DB_DEFAULT = os.environ.get("DB_DEFAULT", 'postgresql') @@ -321,14 +385,14 @@ def SITE_URL(): "unicode_results": True, } PSQL_DATABASE_OPTIONS = {'options': '-c search_path=django,public'} - + DEFAULT_ENGINE = os.environ.get("DB_ENGINE", "mssql" if DB_DEFAULT == 'mssql' else "django.db.backends.postgresql") DEFAULT_NAME = os.environ.get("DB_NAME", "imis") DEFAULT_USER = os.environ.get("DB_USER", "IMISuser") DEFAULT_PASSWORD = os.environ.get("DB_PASSWORD") DEFAULT_HOST = os.environ.get("DB_HOST", 'db') DEFAULT_PORT = os.environ.get("DB_PORT", "1433" if DB_DEFAULT == 'mssql' else "5432") - + if DB_DEFAULT == 'mssql': @@ -455,20 +519,15 @@ def SITE_URL(): # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] +if not DEBUG: + AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "core.utils.CustomPasswordValidator", + } + ] # Internationalization @@ -550,3 +609,9 @@ def SITE_URL(): USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +PASSWORD_MIN_LENGTH = int(os.getenv('PASSWORD_MIN_LENGTH', 8)) +PASSWORD_UPPERCASE = int(os.getenv('PASSWORD_UPPERCASE', 1)) +PASSWORD_LOWERCASE = int(os.getenv('PASSWORD_LOWERCASE', 1)) +PASSWORD_DIGITS = int(os.getenv('PASSWORD_DIGITS', 1)) +PASSWORD_SYMBOLS = int(os.getenv('PASSWORD_SYMBOLS', 1)) diff --git a/openIMIS/signal_binding/apps.py b/openIMIS/signal_binding/apps.py index e0f3be6..55a9d6f 100644 --- a/openIMIS/signal_binding/apps.py +++ b/openIMIS/signal_binding/apps.py @@ -1,6 +1,7 @@ import logging from django.apps import AppConfig from django.conf import settings +import importlib.util logger = logging.getLogger(__name__) @@ -17,13 +18,22 @@ def bind_service_signals(self): def _bind_app_signals(self, app_): try: - signals_module = __import__(f"{app_}.signals") - if hasattr(signals_module.signals, "bind_service_signals"): - signals_module.signals.bind_service_signals() - logger.debug(f"{app_} service signals connected") + spec = importlib.util.find_spec(f"{app_}.signals") + if spec: + app = __import__(f"{app_}.signals") + if ( + hasattr(app, "signals") and + hasattr(app.signals, "bind_service_signals") + ): + app.signals.bind_service_signals() + logger.debug(f"{app_} service signals connected") + else: + logger.debug( + f"{app_} has signals but no bind_service_signals function" + ) else: - logger.debug(f"{app_} has a signals module but no bind_service_signals function") - except ModuleNotFoundError as exc: - logger.debug(f"{app_} has no signals module, skipping") + logger.debug( + f"{app_} has no signals submodule" + ) except Exception as exc: logger.debug(f"{app_}: unknown exception occurred during bind_service_signals: {exc}") diff --git a/openimis.json b/openimis.json index a305321..4fa9b6c 100644 --- a/openimis.json +++ b/openimis.json @@ -155,6 +155,10 @@ { "name": "grievance", "pip": "git+https://github.com/openimis/openimis-be-grievance_py.git@develop#egg=openimis-be-grievance" + }, + { + "name": "claim_sampling", + "pip": "git+https://github.com/openimis/openimis-be-claim_sampling_py.git@develop#egg=openimis-be-claim_sampling" } ] } diff --git a/requirements.txt b/requirements.txt index 73a109a..5bc8b0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ waitress wheel whitenoise django-health-check -requests~=2.31.0 +requests~=2.32.0 apscheduler==3.10.1 # As from v0.4, Django-apscheduler has a migration that is incompatible with SQL Server # (autoincrement int => bigint) so we are using our own fork with a squashed migration @@ -52,4 +52,8 @@ twisted>=23.10.0rc1 # not directly required, pinned by Snyk to avoid a vulnerabi pillow>=10.2.0 # not directly required, pinned by Snyk to avoid a vulnerability django-redis==5.4.0 -django-opensearch-dsl==0.5.1 \ No newline at end of file +django-opensearch-dsl==0.5.1 + +zxcvbn~=4.4.28 +password-validator==1.0 +django-axes==6.4.0 diff --git a/gettext.sh b/script/gettext.sh old mode 100755 new mode 100644 similarity index 100% rename from gettext.sh rename to script/gettext.sh diff --git a/lokalise-upload.py b/script/lokalise-upload.py old mode 100755 new mode 100644 similarity index 100% rename from lokalise-upload.py rename to script/lokalise-upload.py diff --git a/modules-requirements.py b/script/modules-requirements.py similarity index 81% rename from modules-requirements.py rename to script/modules-requirements.py index 4d4474b..56520c9 100644 --- a/modules-requirements.py +++ b/script/modules-requirements.py @@ -3,7 +3,8 @@ import sys -sys.path.insert(0, './openIMIS/openIMIS') +app_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', "openIMIS", "openIMIS") +sys.path.insert(0, app_path) from openimisconf import load_openimis_conf conf_file_path = 'openimis.json' diff --git a/modules-tests.py b/script/modules-tests.py similarity index 76% rename from modules-tests.py rename to script/modules-tests.py index 7e5f405..6e35fd7 100644 --- a/modules-tests.py +++ b/script/modules-tests.py @@ -3,16 +3,9 @@ import sys import itertools from distutils.sysconfig import get_python_lib - -def load_openimis_conf(): - conf_file_path = sys.argv[1] - if not conf_file_path: - sys.exit("Missing config file path argument") - if not os.path.isfile(conf_file_path): - sys.exit("Config file parameter refers to missing file %s" % conf_file_path) - - with open(conf_file_path) as conf_file: - return json.load(conf_file) +app_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', "openIMIS", "openIMIS") +sys.path.insert(0, app_path) +from openimisconf import load_openimis_conf def extract_test(module): cmds = [ diff --git a/script/setup-local-dev.py b/script/setup-local-dev.py index ed05c0b..08acea1 100644 --- a/script/setup-local-dev.py +++ b/script/setup-local-dev.py @@ -2,50 +2,71 @@ from utils import parse_pip, walk_config_be import os import json -import git # pip install GitPython -from github import Github # pip install pyGithub +import git # pip install GitPython +from github import Github # pip install pyGithub +import subprocess, sys -ref = 'develop' -ref_assembly = 'develop' +ref = BRANCH#"develop" +ref_assembly = BRANCH#"develop" def main(): - g=Github(GITHUB_TOKEN) - #assembly_fe='openimis/openimis-fe_js' - assembly_be='openimis/openimis-be_py' - #refresh openimis.json from git - + g = Github(GITHUB_TOKEN) + # assembly_fe='openimis/openimis-fe_js' + assembly_be = "openimis/openimis-be_py" + # refresh openimis.json from git + be_config = [] repo = g.get_repo(assembly_be) - be = json.loads(repo.get_contents("openimis.json", ref =ref_assembly ).decoded_content) - be['modules'] = walk_config_be(g,be,clone_repo) + be = json.loads( + repo.get_contents("openimis.json", ref=ref_assembly).decoded_content + ) + be["modules"] = walk_config_be(g, be, clone_repo) # Writing to sample.json - with open("../openimis.json", "w") as outfile: - outfile.write(json.dumps(be, indent = 4, default=set_default) ) - -def clone_repo(repo, module_name): - src_path = os.path.abspath('../src/') + with open("../openimis-dev.json", "w") as outfile: + outfile.write(json.dumps(be, indent=4, default=set_default)) + install_modules() + +def install_modules(): + print("installing dependencies and modules") + root_path = os.path.abspath("../") + command = f'pip install {root_path}/requirements.txt & python modules-requirements.py openimis-dev.json > modules-requirements.txt & pip install -r modules-requirements.txt' + + try: + result = subprocess.check_output(command, shell = True, executable = "/bin/bash", stderr = subprocess.STDOUT) + + except subprocess.CalledProcessError as cpe: + result = cpe.output + return result + + +def clone_repo(repo, module_name): + src_path = os.path.abspath("../src/") path = os.path.join(src_path, module_name) remote = f"https://{USER_NAME}:{GITHUB_TOKEN}@{repo.git_url[6:]}" if os.path.exists(path): - + repo_git = git.Repo(path) try: repo_git.git.checkout(ref) repo_git.remotes.origin.pull() print(f"{module_name} pulled and checked out") except: - print(f'error while checking out {module_name} to {ref}, please ensure the local changes are commited') + print( + f"error while checking out {module_name} to {ref}, please ensure the local changes are commited" + ) else: print(f"cloning {module_name}") repo_git = git.Repo.clone_from(remote, path) repo_git.git.checkout(ref) - return {"name":f"{module_name}", "pip":f"-e {path}"} + return {"name": f"{module_name}", "pip": f"-e {path}"} + def set_default(obj): if isinstance(obj, set): return list(obj) raise TypeError -if __name__ == '__main__': - main() \ No newline at end of file + +if __name__ == "__main__": + main()