diff --git a/README.md b/README.md index 6c84746b..bb593504 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,13 @@ actions. ## Overview of scos-sensor Repo Structure - configs: This folder is used to store the sensor_definition.json file. + - certs: CA, server, and client certificates. - docker: Contains the docker files used by scos-sensor. - docs: Documentation including the [documentation hosted on GitHub pages]( ) generated from the OpenAPI specification. +- drivers: Driver files for signal anaylzers. - entrypoints: Docker entrypoint scripts which are executed when starting a container. +- files: Folder where task results are stored. - gunicorn: Gunicorn configuration file. - nginx: Nginx configuration template and SSL certificates. - scripts: Various utility scripts. @@ -208,6 +211,7 @@ actions. - actions: Code to discover actions in plugins and to perform a simple logger action. - authentication: Code related to user authentication. - capabilities: Code used to generate capabilities endpoint. + - constants: Constants shared by the other source code folders. - handlers: Code to handle signals received from actions. - schedule: Schedule API endpoint for scheduling actions. - scheduler: Scheduler responsible for executing actions. @@ -217,9 +221,13 @@ actions. - status: Status endpoint. - tasks: Tasks endpoint used to display upcoming and completed tasks. - templates: HTML templates used by the browsable API. + - test_utils: Utility code used in tests. + - utils: Utility code shared by the other source code folders. - conftest.py: Used to configure pytest fixtures. - manage.py: Django’s command line tool for administrative tasks. - - requirements.txt and requirements-dev.txt: Python dependencies. + - requirements.in and requirements-dev.in: Direct Python dependencies. + - requirements.txt and requirements-dev.txt: Python dependencies including transitive + dependencies. - tox.ini: Used to configure tox. - docker-compose.yml: Used by Docker Compose to create services from containers. This is needed to run scos-sensor. @@ -299,11 +307,19 @@ environment (env) file is created from the env.template file. These settings can be set in the environment file or set directly in docker-compose.yml. Here are the settings in the environment file: +- ADDITIONAL_USER_NAMES: Comma separated list of additional admin usernames. +- ADDITIONAL_USER_PASSWORD: Password for additional admin users. +- ADMIN_NAME: Username for the admin user. - ADMIN_EMAIL: Email used to generate admin user. Change in production. - ADMIN_PASSWORD: Password used to generate admin user. Change in production. +- AUTHENTICATION: Authentication method used for scos-sensor. Supports `TOKEN` or + `CERT`. - BASE_IMAGE: Base docker image used to build the API container. +- CALLBACK_AUTHENTICATION: Sets how to authenticate to the callback URL. Supports + `TOKEN` or `CERT`. - CALLBACK_SSL_VERIFICATION: Set to “true” in production environment. If false, the SSL certificate validation will be ignored when posting results to the callback URL. +- CALLBACK_TIMEOUT: The timeout for the requests sent to the callback URL. - DEBUG: Django debug mode. Set to False in production. - DOCKER_TAG: Always set to “latest” to install newest version of docker containers. - DOMAINS: A space separated list of domain names. Used to generate [ALLOWED_HOSTS]( @@ -321,9 +337,14 @@ settings in the environment file: results. Defaults to 85%. This disk usage detected by scos-sensor (using the Python `shutil.disk_usage` function) may not match the usage reported by the Linux `df` command. +- PATH_TO_CLIENT_CERT: Path to file containing certificate and private key used as + client certificate when CALLBACK_AUTHENTICATION is `CERT`. +- PATH_TO_VERIFY_CERT: Trusted CA certificate to verify callback URL server + certificate. - POSTGRES_PASSWORD: Sets password for the Postgres database for the “postgres” user. Change in production. The env.template file sets to a randomly generated value. - REPO_ROOT: Root folder of the repository. Should be correctly set by default. +- SCOS_SENSOR_GIT_TAG: The scos-sensor branch name. - SECRET_KEY: Used by Django to provide cryptographic signing. Change to a unique, unpredictable value. See . The env.template @@ -332,6 +353,8 @@ settings in the environment file: scos-sensor repository with a valid certificate in production. - SSL_KEY_PATH: Path to server SSL private key. Use the private key for your valid certificate in production. +- SSL_CA_PATH: Path to a CA certificate used to verify scos-sensor client + certificate(s) when authentication is set to CERT. ### Sensor Definition File @@ -377,38 +400,31 @@ authenticating when using a callback URL. ### Sensor Authentication And Permissions -The sensor can be configured to authenticate using OAuth JWT access tokens from an -external authorization server or using Django Rest Framework Token Authentication. +The sensor can be configured to authenticate using mutual TLS with client certificates +or using Django Rest Framework Token Authentication. #### Django Rest Framework Token Authentication -This is the default authentication method. To enable Django Rest Framework -Authentication, make sure `AUTHENTICATION` is set to `TOKEN` in the environment file -(this will be enabled if `AUTHENTICATION` set to anything other -than `JWT`). +This is the default authentication method. To enable Django Rest Framework token and +session authentication, make sure `AUTHENTICATION` is set to `TOKEN` in the environment +file (this will be enabled if `AUTHENTICATION` set to anything other than `CERT`). A token is automatically created for each user. Django Rest Framework Token Authentication will check that the token in the Authorization header ("Token " + -token) matches a user's token. +token) matches a user's token. Login session authentication with username and password +is used for the browsable API. -#### OAuth2 JWT Authentication +#### Certificate Authentication -To enable OAuth 2 JWT Authentication, set `AUTHENTICATION` to `JWT` in the environment -file. To authenticate, the client will need to send a JWT access token in the -authorization header (using "Bearer " + access token). The token signature will be -verified using the public key from the `PATH_TO_JWT_PUBLIC_KEY` setting. The expiration -time will be checked. Only users who have an authority matching the `REQUIRED_ROLE` -setting will be authorized. - -The token is expected to come from an OAuth2 authorization server. For more -information, see . +To enable Certificate Authentication, make sure `AUTHENTICATION` is set to `CERT` in +the environment file. To authenticate, the client will need to send a trusted client +certificate. The Common Name must match the username of a user in the database. #### Certificates Use this section to create self-signed certificates with customized organizational and host information. This section includes instructions for creating a self-signed -root CA, SSL server certificates for the sensor, optional client certificates, and test -JWT public/private key pair. +root CA, SSL server certificates for the sensor, and optional client certificates. As described below, a self-signed CA can be created for testing. **For production, make sure to use certificates from a trusted CA.** @@ -420,16 +436,16 @@ Below instructions adapted from This is the SSL certificate used for the scos-sensor web server and is always required. -To be able to sign server-side and client-side certificates, we need to create our own -self-signed root CA certificate first. The command will prompt you to enter a -password and the values for the CA subject. +To be able to sign server-side and client-side certificates in this example, we need to +create our own self-signed root CA certificate first. The command will prompt you to +enter a password and the values for the CA subject. ```bash openssl req -x509 -sha512 -days 365 -newkey rsa:4096 -keyout scostestca.key -out scostestca.pem ``` -Generate a host certificate signing request. Replace the values in square brackets in the -subject for the server certificate. +Generate a host certificate signing request. Replace the values in square brackets in +the subject for the server certificate. ```bash openssl req -new -newkey rsa:4096 -keyout sensor01.key -out sensor01.csr -subj "/C=[2 letter country code]/ST=[state or province]/L=[locality]/O=[organization]/OU=[organizational unit]/CN=[common name]" @@ -437,20 +453,20 @@ openssl req -new -newkey rsa:4096 -keyout sensor01.key -out sensor01.csr -subj " Before we proceed with openssl, we need to create a configuration file -- sensor01.ext. It'll store some additional parameters needed when signing the certificate. Adjust the -settings, especially DNS names and IP addresses, in the below example for your sensor: +settings, especially DNS names, in the below example for your sensor. For more +information and to customize your certificate, see the X.509 standard +[here](https://www.rfc-editor.org/rfc/rfc5280). ```text -authorityKeyIdentifier=keyid,issuer:always +authorityKeyIdentifier=keyid basicConstraints=CA:FALSE subjectAltName = @alt_names subjectKeyIdentifier = hash keyUsage = critical, digitalSignature, keyEncipherment -extendedKeyUsage = serverAuth, clientAuth +extendedKeyUsage = serverAuth, # add , clientAuth to use as client SSL cert (2-way SSL) [alt_names] -DNS.1 = sensor01.domain -DNS.2 = localhost -IP.1 = xxx.xxx.xxx.xxx -IP.2 = 127.0.0.1 +DNS.1 = localhost +# Add additional DNS names as needed, e.g. DNS.2, DNS.3, etc ``` Sign the host certificate. @@ -473,8 +489,9 @@ cat sensor01_decrypted.key sensor01.pem > sensor01_combined.pem ##### Client Certificate -This certificate is required for using the sensor with mutual TLS which is required if -OAuth authentication is enabled. +This certificate is required for using the sensor with mutual TLS certificate +authentication (2 way SSL, AUTHENTICATION=CERT). This example uses the same self-signed +CA used for creating the example scos-sensor server certificate. Replace the brackets with the information specific to your user and organization. @@ -487,8 +504,8 @@ Create client.ext with the following: ```text basicConstraints = CA:FALSE subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer -keyUsage = digitalSignature +authorityKeyIdentifier = keyid +keyUsage = critical, digitalSignature extendedKeyUsage = clientAuth ``` @@ -507,39 +524,34 @@ openssl pkcs12 -export -out client.pfx -inkey client.key -in client.pem -certfil Import client.pfx into web browser for use with the browsable API or use the client.pem or client.pfx when communicating with the API programmatically. -##### Generating JWT Public/Private Key - -The JWT public key must correspond to the private key of the JWT issuer (OAuth -authorization server). For manual testing, the instructions below could be used to -create a public/private key pair for creating JWTs without an authorization -server. +###### Configure scos-sensor -###### Step 1: Create public/private key pair +The Nginx web server is not configured by default to require client certificates +(mutual TLS). To require client certificates, uncomment out the following in +[nginx/conf.template](nginx/conf.template): -```bash -openssl genrsa -out jwt.pem 4096 +```text +ssl_client_certificate /etc/ssl/certs/ca.crt; +ssl_verify_client on; ``` -###### Step 2: Extract Public Key +Note that additional configuration may be needed for Nginx to +use OCSP validation and/or check certificate revocation lists (CRL). Adjust the other +Nginx parameters, such as `ssl_verify_depth`, as desired. See the +[Nginx documentation](https://nginx.org/en/docs/http/ngx_http_ssl_module.html) for more +information about configuring Nginx SSL settings. The `ssl_verify_client` setting can +also be set to `optional` or `optional_no_ca`, but if a client certificate is not +provided, scos-sensor `AUTHENTICATION` setting must be set to `TOKEN` which requires a +token for the API or a username and password for the browsable API. -```bash -openssl rsa -in jwt.pem -outform PEM -pubout -out jwt_public_key.pem -``` +To disable client certificate authentication, comment out the following in +[nginx/conf.template](nginx/conf.template): -###### Step 3: Extract Private Key - -```bash -openssl pkey -inform PEM -outform PEM -in jwt.pem -out jwt_private_key.pem +```text +# ssl_client_certificate /etc/ssl/certs/ca.crt; +# ssl_verify_client on; ``` -###### Configure scos-sensor - -The Nginx web server can be set to require client certificates (mutual TLS). This can -optionally be enabled. To require client certificates, uncomment -`ssl_verify_client on;` in the [Nginx configuration file](nginx/conf.template). If you -use OCSP, also uncomment `ssl_ocsp on;`. Additional configuration may be needed for -Nginx to check certificate revocation lists (CRL). - Copy the server certificate and server private key (sensor01_combined.pem) to `scos-sensor/configs/certs`. Then set `SSL_CERT_PATH` and `SSL_KEY_PATH` (in the environment file) to the path of the sensor01_combined.pem relative to configs/certs @@ -548,38 +560,30 @@ environment file) to the path of the sensor01_combined.pem relative to configs/c mutual TLS, also copy the CA certificate to the same directory. Then, set `SSL_CA_PATH` to the path of the CA certificate relative to `configs/certs`. -If you are using JWT authentication, set `PATH_TO_JWT_PUBLIC_KEY` to the path of the -JWT public key relative to configs/certs. This public key file should correspond to the -private key used to sign the JWT. Alternatively, the JWT private key -created above could be used to manually sign a JWT token for testing if -`PATH_TO_JWT_PUBLIC_KEY` is set to the JWT public key created above. - If you are using client certificates, use client.pfx to connect to the browsable API by importing this certificate into your browser. -For callback functionality with an OAuth authorized callback URL, set -`PATH_TO_CLIENT_CERT` and `PATH_TO_VERIFY_CERT`, both relative to configs/certs. -Depending on the configuration of the callback URL server and the authorization server, -the sensor server certificate could be used as a client certificate by setting -`PATH_TO_CLIENT_CERT` to the path of sensor01_combined.pem relative to configs/certs. -Also the CA used to verify the client certificate could potentially be used to verify -the callback URL server certificate by setting `PATH_TO_VERIFY_CERT` to the same file -as used for `SSL_CA_PATH` (scostestca.pem). - #### Permissions and Users -The API requires the user to either have an authority in the JWT token matching the the -`REQUIRED_ROLE` setting or that the user be a superuser. New users created using the +The API requires the user to be a superuser. New users created using the API initially do not have superuser access. However, an admin can mark a user as a -superuser in the Sensor Configuration Portal. When using JWT tokens, the user does not -have to be pre-created using the sensor's API. The API will accept any user using a -JWT token if they have an authority matching the required role setting. +superuser in the Sensor Configuration Portal. + +When scos-sensor starts, an admin user is created using the ADMIN_NAME, ADMIN_EMAIL and +ADMIN_PASSWORD environment variables. The ADMIN_NAME is the username for the admin +user. Additional admin users can be created using the ADDITIONAL_USER_NAMES and +ADDITIONAL_USER_PASSWORD environment variables. ADDITIONAL_USER_NAMES is a comma +separated list. ADDITIONAL_USER_PASSWORD is a single password used for each additional +admin user. If ADDITIONAL_USER_PASSWORD is not specified, the additional users will +be created with an unusable password, which is sufficient if only using certificates +or tokens to authenticate. However, a password is required to access the Sensor +Configuration Portal. ### Callback URL Authentication -OAuth and Token authentication are supported for authenticating against the server -pointed to by the callback URL. Callback SSL verification can be enabled -or disabled using `CALLBACK_SSL_VERIFICATION` in the environment file. +Certificate and token authentication are supported for authenticating against the +server pointed to by the callback URL. Callback SSL verification can be enabled or +disabled using `CALLBACK_SSL_VERIFICATION` in the environment file. #### Token @@ -589,36 +593,40 @@ will send the user's (user who created the schedule) token in the authorization the token against what it originally sent to the sensor when creating the schedule. This method of authentication for the callback URL is enabled by default. To verify it is enabled, set `CALLBACK_AUTHENTICATION` to `TOKEN` in the environment file (this will -be enabled if `CALLBACK_AUTHENTICATION` set to anything other than `OAUTH`). +be enabled if `CALLBACK_AUTHENTICATION` set to anything other than `CERT`). `PATH_TO_VERIFY_CERT`, in the environment file, can used to set a CA certificate to verify the callback URL server SSL certificate. If this is unset and `CALLBACK_SSL_VERIFICATION` is set to true, [standard trusted CAs]( ) will be used. -#### OAuth +#### Certificate -The OAuth 2 password flow is supported for callback URL authentication. The following -settings in the environment file are used to configure the OAuth 2 password flow -authentication. +Certificate authentication (mutual TLS) is supported for callback URL authentication. +The following settings in the environment file are used to configure certificate +authentication for the callback URL. -- `CALLBACK_AUTHENTICATION` - set to `OAUTH`. -- `CLIENT_ID` - client ID used to authorize the client (the sensor) against the - authorization server. -- `CLIENT_SECRET` - client secret used to authorize the client (the sensor) against the - authorization server. -- `OAUTH_TOKEN_URL` - URL to get the access token. +- `CALLBACK_AUTHENTICATION` - set to `CERT`. - `PATH_TO_CLIENT_CERT` - client certificate used to authenticate against the - authorization server. -- `PATH_TO_VERIFY_CERT` - CA certificate to verify the authorization server and - callback URL server SSL certificate. If this is unset and `CALLBACK_SSL_VERIFICATION` - is set to true, [standard trusted CAs]( - ) will be - used. - -In src/sensor/settings.py, the OAuth `USER_NAME` and `PASSWORD` are set to be the same -as `CLIENT_ID` and `CLIENT_SECRET`. This may need to change depending on your -authorization server. + callback URL server. +- `PATH_TO_VERIFY_CERT` - CA certificate to verify the callback URL server SSL + certificate. If this is unset and `CALLBACK_SSL_VERIFICATION` + is set to true, [standard trusted CAs]( + https://requests.readthedocs.io/en/master/user/advanced/#ca-certificates) will be + used. + +Set `PATH_TO_CLIENT_CERT` and `PATH_TO_VERIFY_CERT` relative to configs/certs. +Depending on the configuration of the callback URL server, the scos-sensor server +certificate could be used as a client certificate (if created with clientAuth extended +key usage) by setting `PATH_TO_CLIENT_CERT` to the same value as `SSL_CERT_PATH` +if the private key is bundled with the certificate. Also +the CA used to verify the scos-sensor client certificate(s) could potentially be used +to verify the callback URL server certificate by setting `PATH_TO_VERIFY_CERT` to the +same file as used for `SSL_CA_PATH`. This would require the callback URL server +certificate to be issued by the same CA as the scos-sensor client certficate(s) or have +the callback URL server's CA cert bundled with the scos-sensor client CA cert. Make +sure to consider the security implications of these configurations and settings, +especially using the same files for multiple settings. ### Data File Encryption diff --git a/configs/certs/README.md b/configs/certs/README.md index 2b9c6951..85b06b8f 100644 --- a/configs/certs/README.md +++ b/configs/certs/README.md @@ -1,3 +1,3 @@ # Certs -Add SSL certs and JWT public key here. +Add SSL certs here. diff --git a/docker-compose.yml b/docker-compose.yml index 19792cd0..4abfd274 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,14 +32,15 @@ services: - DEBUG - DOCKER_GIT_CREDENTIALS environment: + - ADMIN_NAME - ADMIN_EMAIL - ADMIN_PASSWORD + - ADDITIONAL_USER_NAMES + - ADDITIONAL_USER_PASSWORD - AUTHENTICATION - CALLBACK_AUTHENTICATION - CALLBACK_SSL_VERIFICATION - CALLBACK_TIMEOUT - - CLIENT_ID - - CLIENT_SECRET - DEBUG - DOCKER_TAG - DOMAINS @@ -53,9 +54,7 @@ services: - MAX_DISK_USAGE - MOCK_SIGAN - MOCK_SIGAN_RANDOM - - OAUTH_TOKEN_URL - PATH_TO_CLIENT_CERT - - PATH_TO_JWT_PUBLIC_KEY - PATH_TO_VERIFY_CERT - POSTGRES_PASSWORD - SCOS_SENSOR_GIT_TAG diff --git a/docs/openapi.json b/docs/openapi.json index 270b63cc..feee0bb5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -14,7 +14,7 @@ }, "host": "testserver", "schemes": [ - "http" + "https" ], "basePath": "/api", "consumes": [ @@ -24,14 +24,21 @@ "application/json" ], "securityDefinitions": { + "cert": { + "type": "cert", + "description": "Certificate based mutual TLS authentication. AUTHENTICATION must be set to 'CERT'. This is done by the client verifying the server certificate and the server verifying the client certificate. The client certificate Common Name (CN) should contain the username of a user that exists in the database. Client certificate verification is handled by NGINX. For more information, see https://www.rfc-editor.org/rfc/rfc5246." + }, "token": { "type": "apiKey", - "description": "Tokens are automatically generated for all users. You can view yours by going to your User Details view in the browsable API at `/api/v1/users/me` and looking for the `auth_token` key. New user accounts do not initially have a password and so can not log in to the browsable API. To set a password for a user (for testing purposes), an admin can do that in the Sensor Configuration Portal, but only the account's token should be stored and used for general purpose API access. Example cURL call: `curl -kLsS -H \"Authorization: Token 529c30e6e04b3b546f2e073e879b75fdfa147c15\" https://localhost/api/v1`", + "description": "Tokens are automatically generated for all users. You can view yours by going to your User Details view in the browsable API at `/api/v1/users/me` and looking for the `auth_token` key. New user accounts do not initially have a password and so can not log in to the browsable API. To set a password for a user (for testing purposes), an admin can do that in the Sensor Configuration Portal, but only the account's token should be stored and used for general purpose API access. Example cURL call: `curl -kLsS -H \"Authorization: Token 529c30e6e04b3b546f2e073e879b75fdfa147c15\" https://localhost/api/v1`. AUTHENTICATION should be set to 'TOKEN'", "name": "Token", "in": "header" } }, "security": [ + { + "cert": [] + }, { "token": [] } @@ -642,13 +649,13 @@ }, "parameters": [ { - "name": "format", + "name": "schedule_entry_name", "in": "path", "required": true, "type": "string" }, { - "name": "schedule_entry_name", + "name": "format", "in": "path", "required": true, "type": "string" @@ -752,12 +759,6 @@ ] }, "parameters": [ - { - "name": "format", - "in": "path", - "required": true, - "type": "string" - }, { "name": "schedule_entry_name", "in": "path", @@ -770,6 +771,12 @@ "description": "The id of the task relative to the result", "required": true, "type": "integer" + }, + { + "name": "format", + "in": "path", + "required": true, + "type": "string" } ] }, @@ -804,12 +811,6 @@ ] }, "parameters": [ - { - "name": "format", - "in": "path", - "required": true, - "type": "string" - }, { "name": "schedule_entry_name", "in": "path", @@ -822,6 +823,12 @@ "description": "The id of the task relative to the result", "required": true, "type": "integer" + }, + { + "name": "format", + "in": "path", + "required": true, + "type": "string" } ] }, @@ -911,13 +918,13 @@ }, "parameters": [ { - "name": "format", + "name": "schedule_entry_name", "in": "path", "required": true, "type": "string" }, { - "name": "schedule_entry_name", + "name": "format", "in": "path", "required": true, "type": "string" @@ -1570,17 +1577,17 @@ }, "parameters": [ { - "name": "format", + "name": "id", "in": "path", + "description": "A unique integer value identifying this user.", "required": true, - "type": "string" + "type": "integer" }, { - "name": "id", + "name": "format", "in": "path", - "description": "A unique integer value identifying this user.", "required": true, - "type": "integer" + "type": "string" } ] }, diff --git a/env.template b/env.template index 6ba31d84..2f6429c4 100644 --- a/env.template +++ b/env.template @@ -39,15 +39,15 @@ GIT_BRANCH="git:$(git rev-parse --abbrev-ref HEAD)@$(git rev-parse --short HEAD) # If admin user email and password set, admin user will be generated. ADMIN_EMAIL="admin@example.com" ADMIN_PASSWORD=password +ADMIN_NAME=Admin +ADDITIONAL_USER_NAMES="" # comma separated +ADDITIONAL_USER_PASSWORD="" # Session password for Postgres. Username is "postgres". # SECURITY WARNING: generate unique key with something like # `openssl rand -base64 12` POSTGRES_PASSWORD="$(python3 -c 'import secrets; import base64; print(base64.b64encode(secrets.token_bytes(32)).decode("utf-8"))')" -# Set to enable monitoring sensors with your sentry.io account -SENTRY_DSN= - if $DEBUG; then GUNICORN_LOG_LEVEL=debug RAY_record_ref_creation_sites=1 @@ -64,18 +64,15 @@ MANAGER_IP="$(hostname -I | cut -d' ' -f1)" BASE_IMAGE=ghcr.io/ntia/scos-tekrsa/tekrsa_usb:0.2.3 # Default callback api/results -# Set to OAUTH if using OAuth Password Flow Authentication, callback url needs to be api/v2/results +# Set to CERT for certificate authentication CALLBACK_AUTHENTICATION=TOKEN CALLBACK_TIMEOUT=2 -CLIENT_ID=sensor01.sms.internal -CLIENT_SECRET=sensor-secret - -# Sensor certificate with private key used as client cert +# Sensor certificate with private key used as client cert for callback URL +# Paths relative to configs/certs PATH_TO_CLIENT_CERT=sensor01.pem -# Trusted Certificate Authority certificate to verify authserver and callback URL server certificate +# Trusted Certificate Authority certificate to verify callback URL server certificate PATH_TO_VERIFY_CERT=scos_test_ca.crt -# Path relative to configs/certs -PATH_TO_JWT_PUBLIC_KEY=jwt_pubkey.pem -# set to JWT to enable JWT authentication + +# set to CERT to enable scos-sensor certificate authentication AUTHENTICATION=TOKEN diff --git a/nginx/conf.template b/nginx/conf.template index ba1b0266..e5e0d46b 100644 --- a/nginx/conf.template +++ b/nginx/conf.template @@ -32,8 +32,8 @@ server { ssl_session_tickets off; ssl_certificate /etc/ssl/certs/ssl-cert.pem; ssl_certificate_key /etc/ssl/private/ssl-cert.key; - ssl_protocols TLSv1.2; - ssl_client_certificate /etc/ssl/certs/ca.crt; + ssl_protocols TLSv1.2 TLSv1.3; + # ssl_client_certificate /etc/ssl/certs/ca.crt; # ssl_verify_client on; # ssl_ocsp on; # Enable OCSP validation ssl_verify_depth 4; @@ -49,11 +49,14 @@ server { # Pass off requests to Gunicorn location @proxy_to_wsgi_server { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header Host $http_host; + proxy_set_header X-SSL-CLIENT-DN $ssl_client_s_dn; proxy_redirect off; proxy_pass http://wsgi-server; + } } diff --git a/scripts/create_superuser.py b/scripts/create_superuser.py index 00a4a6a1..522825b0 100755 --- a/scripts/create_superuser.py +++ b/scripts/create_superuser.py @@ -13,24 +13,61 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sensor.settings") django.setup() +UserModel = get_user_model() + + +def add_user(username, password, email=None): + try: + admin_user = UserModel._default_manager.get(username=username) + if email: + admin_user.email = email + admin_user.set_password(password) + admin_user.save() + print("Reset admin account password and email from environment") + except UserModel.DoesNotExist: + UserModel._default_manager.create_superuser(username, email, password) + print("Created admin account with password and email from environment") + try: password = os.environ["ADMIN_PASSWORD"] print("Retreived admin password from environment variable ADMIN_PASSWORD") email = os.environ["ADMIN_EMAIL"] print("Retreived admin email from environment variable ADMIN_EMAIL") + username = os.environ["ADMIN_NAME"] + print("Retreived admin name from environment variable ADMIN_NAME") + add_user(username.strip(), password.strip(), email.strip()) except KeyError: print("Not on a managed sensor, so not auto-generating admin account.") print("You can add an admin later with `./manage.py createsuperuser`") sys.exit(0) -UserModel = get_user_model() - +additional_user_names = "" +additional_user_password = "" try: - admin_user = UserModel._default_manager.get(username="admin") - admin_user.email = email - admin_user.set_password(password) - print("Reset admin account password and email from environment") -except UserModel.DoesNotExist: - UserModel._default_manager.create_superuser("admin", email, password) - print("Created admin account with password and email from environment") + additional_user_names = os.environ["ADDITIONAL_USER_NAMES"] + print( + "Retreived additional user names from environment variable ADDITIONAL_USER_NAMES" + ) + if ( + "ADDITIONAL_USER_PASSWORD" in os.environ + and os.environ["ADDITIONAL_USER_PASSWORD"] + ): + additional_user_password = os.environ["ADDITIONAL_USER_PASSWORD"].strip() + else: + # user will have unusable password + # https://docs.djangoproject.com/en/3.2/ref/contrib/auth/#django.contrib.auth.models.UserManager.create_user + additional_user_password = None + print( + "Retreived additional user password from environment variable ADDITIONAL_USER_PASSWORD" + ) +except KeyError: + print("Not creating any additonal users.") + + +if additional_user_names != "" and additional_user_password != "": + if "," in additional_user_names: + for additional_user_name in additional_user_names.split(","): + add_user(additional_user_name.strip(), additional_user_password) + else: + add_user(additional_user_names.strip(), additional_user_password) diff --git a/scripts/restore_fixture.sh b/scripts/restore_fixture.sh deleted file mode 100755 index 5224e3e6..00000000 --- a/scripts/restore_fixture.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash - -# Exit on error -set -e - -REPO_ROOT=${REPO_ROOT:=$(git rev-parse --show-toplevel)} -PROGRAM_NAME=${0##*/} -INPUT="$1" - - -echo_usage() { - cat << EOF -Restore a fixture file. - -Usage: $PROGRAM_NAME filename - -Example: - $PROGRAM_NAME ./src/capabilities/fixtures/greyhound-2018-02-22.json - -EOF - - exit 0 -} - - -if [[ ! "$INPUT" || "$INPUT" == "-h" || "$INPUT" == "--help" ]]; then - echo_usage - exit 0 -fi - -HOST_PATH=$(readlink -e "$INPUT") -FILENAME=$(basename "$INPUT") -CONTAINER_PATH="/tmp/$FILENAME" - -if [[ ! -e "$HOST_PATH" ]]; then - echo "Fixture file \"$HOST_PATH\" doesn't exist." - exit 1 -fi - -set +e # this command may "fail" -DB_RUNNING=$(docker-compose -f ${REPO_ROOT}/docker-compose.yml ps db |grep Up) -API_RUNNING=$(docker-compose -f ${REPO_ROOT}/docker-compose.yml ps api |grep Up) -set -e - -# Ensure database container is running -docker-compose -f ${REPO_ROOT}/docker-compose.yml up -d db - -# Load given fixture file into database -if [[ "$API_RUNNING" ]]; then - API_CONTAINER=$(docker-compose -f ${REPO_ROOT}/docker-compose.yml ps -q api) - docker cp "$HOST_PATH" ${API_CONTAINER}:/tmp - docker-compose -f ${REPO_ROOT}/docker-compose.yml exec api \ - /src/manage.py loaddata "$CONTAINER_PATH" -else - docker-compose -f ${REPO_ROOT}/docker-compose.yml run \ - -v "$HOST_PATH":"$CONTAINER_PATH" \ - --rm api /src/manage.py loaddata "$CONTAINER_PATH" -fi - -# If the DB was already running, leave it up -if [[ ! "$DB_RUNNING" ]]; then - # Stop database container - docker-compose -f ${REPO_ROOT}/docker-compose.yml stop db -fi - -echo "Restored fixture from $INPUT." diff --git a/src/.isort.cfg b/src/.isort.cfg index d4c96609..29e00ce5 100644 --- a/src/.isort.cfg +++ b/src/.isort.cfg @@ -4,4 +4,4 @@ include_trailing_comma=True force_grid_wrap=0 use_parentheses=True line_length=88 -known_third_party=cryptography,django,drf_yasg,environs,its_preselector,jsonfield,jwt,numpy,oauthlib,pytest,requests,requests_mock,requests_oauthlib,rest_framework,scos_actions,sigmf +known_third_party=cryptography,django,drf_yasg,environs,its_preselector,jsonfield,numpy,pytest,requests,requests_mock,rest_framework,scos_actions,sigmf diff --git a/src/authentication/auth.py b/src/authentication/auth.py index a8a94069..07605e77 100644 --- a/src/authentication/auth.py +++ b/src/authentication/auth.py @@ -1,11 +1,10 @@ +import datetime import logging +import re -import jwt from django.conf import settings from django.contrib.auth import get_user_model -from jwt import ExpiredSignatureError, InvalidSignatureError from rest_framework import authentication, exceptions -from rest_framework.authentication import get_authorization_header logger = logging.getLogger(__name__) @@ -13,74 +12,41 @@ "rest_framework.authentication.TokenAuthentication" in settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] ) -oauth_jwt_authentication_enabled = ( - "authentication.auth.OAuthJWTAuthentication" +certificate_authentication_enabled = ( + "authentication.auth.CertificateAuthentication" in settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] ) -def jwt_request_has_required_role(request): - if request.auth: - if "authorities" in request.auth: - if request.auth["authorities"]: - authorities = request.auth["authorities"] - return settings.REQUIRED_ROLE.upper() in authorities - return False +class CertificateAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + logger.debug("Authenticating certificate.") + cert_dn = request.headers.get("X-Ssl-Client-Dn") + if cert_dn: + user_model = get_user_model() + try: + cn = get_cn_from_dn(cert_dn) + user = user_model.objects.get(username=cn) + user.last_login = datetime.datetime.now() + user.save() + except user_model.DoesNotExist: + logger.error(f"No username matching {cn} found in database!") + raise exceptions.AuthenticationFailed("No matching username found!") + except Exception as ex: + logger.error("Error occurred during certificate authentication!") + logger.error(ex) + raise exceptions.AuthenticationFailed( + "Error occurred during certificate authentication!" + ) + return user, None + return None, None -class OAuthJWTAuthentication(authentication.BaseAuthentication): - def authenticate(self, request): - auth_header = get_authorization_header(request) - if not auth_header: - logger.debug("no auth header") - return None - auth_header = auth_header.split() - if len(auth_header) != 2: - return None - if auth_header[0].decode().lower() != "bearer": - logger.debug("no JWT bearer token") - return None # attempt other configured authentication methods - token = auth_header[1] - # get JWT public key - public_key = "" - try: - with open(settings.PATH_TO_JWT_PUBLIC_KEY) as public_key_file: - public_key = public_key_file.read() - except Exception as e: - logger.error(e) - if not public_key: - error = exceptions.AuthenticationFailed( - "Unable to get public key to decode jwt" - ) - logger.error(error) - raise error - try: - # decode JWT token - # verifies jwt signature using RS256 algorithm and public key - # requires exp claim to verify token is not expired - # decodes and returns base64 encoded payload - decoded_key = jwt.decode( - token, - public_key, - verify=True, - algorithms="RS256", - options={"require": ["exp"], "verify_exp": True}, - ) - except ExpiredSignatureError as e: - logger.error(e) - raise exceptions.AuthenticationFailed("Token is expired!") - except InvalidSignatureError as e: - logger.error(e) - raise exceptions.AuthenticationFailed("Unable to verify token!") - except Exception as e: - logger.error(e) - raise exceptions.AuthenticationFailed(f"Unable to decode token! {e}") - jwt_username = decoded_key["user_name"] - user_model = get_user_model() - user = None - try: - user = user_model.objects.get(username=jwt_username) - except user_model.DoesNotExist: - user = user_model.objects.create_user(username=jwt_username) - user.save() - return (user, decoded_key) +def get_cn_from_dn(cert_dn): + p = re.compile(r"CN=[a-zA-Z0-9\s.]*") + match = p.search(cert_dn) + if not match: + raise Exception("No CN found in certificate!") + cn = match.group() + cn = cn[3:] + return cn diff --git a/src/authentication/oauth.py b/src/authentication/oauth.py deleted file mode 100644 index 23f47f8f..00000000 --- a/src/authentication/oauth.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging - -from django.conf import settings -from oauthlib.oauth2 import LegacyApplicationClient -from requests_oauthlib import OAuth2Session - -logger = logging.getLogger(__name__) - - -def get_oauth_token(): - """Returns OAuth access token.""" - try: - logger.debug(settings.CLIENT_ID) - logger.debug(settings.CLIENT_SECRET) - logger.debug(settings.USER_NAME) - logger.debug(settings.PASSWORD) - - logger.debug(settings.OAUTH_TOKEN_URL) - logger.debug(settings.PATH_TO_CLIENT_CERT) - logger.debug(settings.PATH_TO_VERIFY_CERT) - verify_ssl = settings.CALLBACK_SSL_VERIFICATION - if settings.CALLBACK_SSL_VERIFICATION: - if settings.PATH_TO_VERIFY_CERT != "": - verify_ssl = settings.PATH_TO_VERIFY_CERT - - logger.debug(verify_ssl) - oauth = OAuth2Session( - client=LegacyApplicationClient(client_id=settings.CLIENT_ID) - ) - oauth.cert = settings.PATH_TO_CLIENT_CERT - token = oauth.fetch_token( - token_url=settings.OAUTH_TOKEN_URL, - username=settings.USER_NAME, - password=settings.PASSWORD, - client_id=settings.CLIENT_ID, - client_secret=settings.CLIENT_SECRET, - verify=verify_ssl, - ) - oauth.close() - logger.debug("Response from oauth.fetch_token: " + str(token)) - return token - except Exception: - raise - - -def get_oauth_client(): - """Returns Authorized OAuth Client (with authentication header token).""" - try: - token = get_oauth_token() - client = OAuth2Session(settings.CLIENT_ID, token=token) - client.cert = settings.PATH_TO_CLIENT_CERT - return client - except Exception: - raise diff --git a/src/authentication/permissions.py b/src/authentication/permissions.py index 40c55ec6..42a3f14c 100644 --- a/src/authentication/permissions.py +++ b/src/authentication/permissions.py @@ -1,14 +1,10 @@ from rest_framework import permissions -from .auth import jwt_request_has_required_role, oauth_jwt_authentication_enabled - -class RequiredJWTRolePermissionOrIsSuperuser(permissions.BasePermission): - message = "User missing required role" +class IsSuperuser(permissions.BasePermission): + message = "User is not superuser" def has_permission(self, request, view): - if oauth_jwt_authentication_enabled and jwt_request_has_required_role(request): - return True if request.user.is_superuser: return True return False diff --git a/src/authentication/tests/jwt_content_example.json b/src/authentication/tests/jwt_content_example.json deleted file mode 100644 index 5e5f2b4f..00000000 --- a/src/authentication/tests/jwt_content_example.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "user_name": "sensor01", - "scope": [ - "read", - "write" - ], - "exp": 1601494344, - "userDetails": { - "id": null, - "uid": "", - "altSecurityIdenties": null, - "email": "sensor01", - "firstname": "sensor01", - "lastname": "", - "cn": "sensor01", - "lastlogin": 1583360759668, - "enabled": true, - "authorities": [ - { - "authority": "ROLE_MANAGER" - } - ], - "password": null, - "username": "sensor01", - "dn": "cn=sensor01,ou=OU1,ou=OU2,ou=OU3,dc=DC1,dc=DC2", - "accountNonLocked": true, - "accountnullxpired": true, - "credentialsnullxpired": true - }, - "authorities": [ - "ROLE_MANAGER" - ], - "jti": "e4271916-bfe0-4028-b372-dc05c4882c88", - "client_id": "sensor01" -} diff --git a/src/authentication/tests/test_cert_auth.py b/src/authentication/tests/test_cert_auth.py new file mode 100644 index 00000000..9d98cb07 --- /dev/null +++ b/src/authentication/tests/test_cert_auth.py @@ -0,0 +1,201 @@ +import pytest +from rest_framework.reverse import reverse +from rest_framework.test import RequestsClient + +from authentication.auth import certificate_authentication_enabled +from authentication.models import User +from sensor import V1 +from sensor.tests.utils import get_requests_ssl_dn_header + +pytestmark = pytest.mark.skipif( + not certificate_authentication_enabled, + reason="Certificate authentication is not enabled!", +) + + +@pytest.mark.django_db +def test_no_client_cert_unauthorized_no_dn(live_server): + client = RequestsClient() + response = client.get(f"{live_server.url}") + assert response.status_code == 403 + + capabilities = reverse("capabilities", kwargs=V1) + response = client.get(f"{live_server.url}{capabilities}") + assert response.status_code == 403 + + schedule_list = reverse("schedule-list", kwargs=V1) + response = client.get(f"{live_server.url}{schedule_list}") + assert response.status_code == 403 + + status = reverse("status", kwargs=V1) + response = client.get(f"{live_server.url}{status}") + assert response.status_code == 403 + + task_root = reverse("task-root", kwargs=V1) + response = client.get(f"{live_server.url}{task_root}") + assert response.status_code == 403 + + task_results_overview = reverse("task-results-overview", kwargs=V1) + response = client.get(f"{live_server.url}{task_results_overview}") + assert response.status_code == 403 + + upcoming_tasks = reverse("upcoming-tasks", kwargs=V1) + response = client.get(f"{live_server.url}{upcoming_tasks}") + assert response.status_code == 403 + + user_list = reverse("user-list", kwargs=V1) + response = client.get(f"{live_server.url}{user_list}") + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_client_cert_accepted(live_server, admin_user): + client = RequestsClient() + response = client.get( + f"{live_server.url}", headers=get_requests_ssl_dn_header("admin") + ) + assert response.status_code == 200 + + +def test_mismatching_user_forbidden(live_server, admin_user): + client = RequestsClient() + response = client.get( + f"{live_server.url}", headers=get_requests_ssl_dn_header("user") + ) + assert response.status_code == 403 + assert "No matching username found!" in response.json()["detail"] + + +@pytest.mark.django_db +def test_urls_unauthorized_not_superuser(live_server, user): + client = RequestsClient() + headers = get_requests_ssl_dn_header("user") + + capabilities = reverse("capabilities", kwargs=V1) + response = client.get(f"{live_server.url}{capabilities}", headers=headers) + assert response.status_code == 403 + + schedule_list = reverse("schedule-list", kwargs=V1) + response = client.get(f"{live_server.url}{schedule_list}", headers=headers) + assert response.status_code == 403 + + status = reverse("status", kwargs=V1) + response = client.get(f"{live_server.url}{status}", headers=headers) + assert response.status_code == 403 + + task_root = reverse("task-root", kwargs=V1) + response = client.get(f"{live_server.url}{task_root}", headers=headers) + assert response.status_code == 403 + + task_results_overview = reverse("task-results-overview", kwargs=V1) + response = client.get(f"{live_server.url}{task_results_overview}", headers=headers) + assert response.status_code == 403 + + upcoming_tasks = reverse("upcoming-tasks", kwargs=V1) + response = client.get(f"{live_server.url}{upcoming_tasks}", headers=headers) + assert response.status_code == 403 + + user_list = reverse("user-list", kwargs=V1) + response = client.get(f"{live_server.url}{user_list}", headers=headers) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_urls_authorized(live_server, admin_user): + client = RequestsClient() + headers = get_requests_ssl_dn_header("admin") + + capabilities = reverse("capabilities", kwargs=V1) + response = client.get(f"{live_server.url}{capabilities}", headers=headers) + assert response.status_code == 200 + + schedule_list = reverse("schedule-list", kwargs=V1) + response = client.get(f"{live_server.url}{schedule_list}", headers=headers) + assert response.status_code == 200 + + status = reverse("status", kwargs=V1) + response = client.get(f"{live_server.url}{status}", headers=headers) + assert response.status_code == 200 + + task_root = reverse("task-root", kwargs=V1) + response = client.get(f"{live_server.url}{task_root}", headers=headers) + assert response.status_code == 200 + + task_results_overview = reverse("task-results-overview", kwargs=V1) + response = client.get(f"{live_server.url}{task_results_overview}", headers=headers) + assert response.status_code == 200 + + upcoming_tasks = reverse("upcoming-tasks", kwargs=V1) + response = client.get(f"{live_server.url}{upcoming_tasks}", headers=headers) + assert response.status_code == 200 + + user_list = reverse("user-list", kwargs=V1) + response = client.get(f"{live_server.url}{user_list}", headers=headers) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_token_hidden(live_server, admin_user): + client = RequestsClient() + headers = get_requests_ssl_dn_header("admin") + response = client.get(f"{live_server.url}", headers=headers) + assert response.status_code == 200 + + sensor01_user = User.objects.get(username=admin_user.username) + kws = {"pk": sensor01_user.pk} + kws.update(V1) + user_detail = reverse("user-detail", kwargs=kws) + client = RequestsClient() + response = client.get(f"{live_server.url}{user_detail}", headers=headers) + assert response.status_code == 200 + assert ( + response.json()["auth_token"] + == "rest_framework.authentication.TokenAuthentication is not enabled" + ) + + +@pytest.mark.django_db +def test_empty_common_name_unauthorized(live_server, admin_user): + client = RequestsClient() + response = client.get(f"{live_server.url}", headers=get_requests_ssl_dn_header("")) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_invalid_dn_unauthorized(live_server, admin_user): + client = RequestsClient() + headers = { + "X-Ssl-Client-Dn": f"C=TC,ST=test_state,L=test_locality,O=test_org,OU=test_ou", + } + response = client.get(f"{live_server.url}", headers=headers) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_empty_dn_unauthorized(live_server, admin_user): + client = RequestsClient() + headers = { + "X-Ssl-Client-Dn": "", + } + response = client.get(f"{live_server.url}", headers=headers) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_dn_with_uid(live_server, admin_user): + client = RequestsClient() + headers = { + "X-Ssl-Client-Dn": f"C=TC,ST=test_state,L=test_locality,O=test_org,OU=test_ou,CN={admin_user.username}+UID=11111", + } + response = client.get(f"{live_server.url}", headers=headers) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_dn_cn_space_reverse_order(live_server, alt_admin_user): + client = RequestsClient() + headers = { + "X-Ssl-Client-Dn": f"CN={alt_admin_user.username}+UID=111111,OU=test_ou,O=test_org,L=test_locality,ST=test_state,C=TC", + } + response = client.get(f"{live_server.url}", headers=headers) + assert response.status_code == 200 diff --git a/src/authentication/tests/test_jwt_auth.py b/src/authentication/tests/test_jwt_auth.py deleted file mode 100644 index 313e6f1d..00000000 --- a/src/authentication/tests/test_jwt_auth.py +++ /dev/null @@ -1,432 +0,0 @@ -import base64 -import json -import os -import secrets -from datetime import datetime, timedelta -from tempfile import NamedTemporaryFile - -import jwt -import pytest -from rest_framework.reverse import reverse -from rest_framework.test import RequestsClient - -from authentication.auth import oauth_jwt_authentication_enabled -from authentication.models import User -from authentication.tests.utils import get_test_public_private_key -from sensor import V1 - -pytestmark = pytest.mark.skipif( - not oauth_jwt_authentication_enabled, - reason="OAuth JWT authentication is not enabled!", -) - - -jwt_content_file = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "jwt_content_example.json" -) -with open(jwt_content_file) as token_file: - TOKEN_CONTENT = json.load(token_file) - -BAD_PRIVATE_KEY, BAD_PUBLIC_KEY = get_test_public_private_key() - -one_min = timedelta(minutes=1) -one_day = timedelta(days=1) - - -def get_token_payload(authorities=["ROLE_MANAGER"], exp=None, client_id=None): - token_payload = TOKEN_CONTENT.copy() - current_datetime = datetime.now() - if not exp: - token_payload["exp"] = (current_datetime + one_day).timestamp() - else: - token_payload["exp"] = exp - token_payload["userDetails"]["lastlogin"] = (current_datetime - one_day).timestamp() - token_payload["userDetails"]["authorities"] = [] - for authority in authorities: - token_payload["userDetails"]["authorities"].append({"authority": authority}) - token_payload["userDetails"]["enabled"] = True - token_payload["authorities"] = authorities - if client_id: - token_payload["client_id"] = client_id - return token_payload - - -def get_headers(token): - return { - "Authorization": f"Bearer {token}", - "X-Ssl-Client-Dn": f"CN=Test,OU=Test,O=Test,L=Test,ST=Test,C=Test", - } - - -@pytest.mark.django_db -def test_no_token_unauthorized(live_server): - client = RequestsClient() - response = client.get(f"{live_server.url}") - assert response.status_code == 403 - - -@pytest.mark.django_db -def test_token_no_roles_unauthorized(live_server, jwt_keys): - token_payload = get_token_payload(authorities=[]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "User missing required role" - - -@pytest.mark.django_db -def test_token_role_manager_accepted(live_server, jwt_keys): - token_payload = get_token_payload() - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - -def test_bad_token_forbidden(live_server): - client = RequestsClient() - token = ( - secrets.token_urlsafe(28) - + "." - + secrets.token_urlsafe(679) - + "." - + secrets.token_urlsafe(525) - ) - response = client.get(f"{live_server.url}", headers=get_headers(token)) - print(f"headers: {response.request.headers}") - assert response.status_code == 403 - assert "Unable to decode token!" in response.json()["detail"] - - -@pytest.mark.django_db -def test_token_expired_1_day_forbidden(live_server, jwt_keys): - current_datetime = datetime.now() - token_payload = get_token_payload(exp=(current_datetime - one_day).timestamp()) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "Token is expired!" - - -@pytest.mark.django_db -def test_bad_private_key_forbidden(live_server): - token_payload = get_token_payload() - encoded = jwt.encode( - token_payload, str(BAD_PRIVATE_KEY.decode("utf-8")), algorithm="RS256" - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "Unable to verify token!" - - -@pytest.mark.django_db -def test_bad_public_key_forbidden(settings, live_server, jwt_keys): - with NamedTemporaryFile() as jwt_public_key_file: - jwt_public_key_file.write(BAD_PUBLIC_KEY) - jwt_public_key_file.flush() - settings.PATH_TO_JWT_PUBLIC_KEY = jwt_public_key_file.name - token_payload = get_token_payload() - encoded = jwt.encode( - token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "Unable to verify token!" - - -@pytest.mark.django_db -def test_token_expired_1_min_forbidden(live_server, jwt_keys): - current_datetime = datetime.now() - token_payload = get_token_payload(exp=(current_datetime - one_min).timestamp()) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "Token is expired!" - - -@pytest.mark.django_db -def test_token_expires_in_1_min_accepted(live_server, jwt_keys): - current_datetime = datetime.now() - token_payload = get_token_payload(exp=(current_datetime + one_min).timestamp()) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_token_role_user_forbidden(live_server, jwt_keys): - token_payload = get_token_payload(authorities=["ROLE_USER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - assert response.json()["detail"] == "User missing required role" - - -@pytest.mark.django_db -def test_token_role_user_required_role_accepted(settings, live_server, jwt_keys): - settings.REQUIRED_ROLE = "ROLE_USER" - token_payload = get_token_payload(authorities=["ROLE_USER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_token_multiple_roles_accepted(live_server, jwt_keys): - token_payload = get_token_payload( - authorities=["ROLE_MANAGER", "ROLE_USER", "ROLE_ITS"] - ) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_token_mulitple_roles_forbidden(live_server, jwt_keys): - token_payload = get_token_payload( - authorities=["ROLE_SENSOR", "ROLE_USER", "ROLE_ITS"] - ) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 403 - - -@pytest.mark.django_db -def test_urls_unauthorized(live_server, jwt_keys): - token_payload = get_token_payload(authorities=["ROLE_USER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - headers = get_headers(encoded) - - capabilities = reverse("capabilities", kwargs=V1) - response = client.get(f"{live_server.url}{capabilities}", headers=headers) - assert response.status_code == 403 - - schedule_list = reverse("schedule-list", kwargs=V1) - response = client.get(f"{live_server.url}{schedule_list}", headers=headers) - assert response.status_code == 403 - - status = reverse("status", kwargs=V1) - response = client.get(f"{live_server.url}{status}", headers=headers) - assert response.status_code == 403 - - task_root = reverse("task-root", kwargs=V1) - response = client.get(f"{live_server.url}{task_root}", headers=headers) - assert response.status_code == 403 - - task_results_overview = reverse("task-results-overview", kwargs=V1) - response = client.get(f"{live_server.url}{task_results_overview}", headers=headers) - assert response.status_code == 403 - - upcoming_tasks = reverse("upcoming-tasks", kwargs=V1) - response = client.get(f"{live_server.url}{upcoming_tasks}", headers=headers) - assert response.status_code == 403 - - user_list = reverse("user-list", kwargs=V1) - response = client.get(f"{live_server.url}{user_list}", headers=headers) - assert response.status_code == 403 - - -@pytest.mark.django_db -def test_urls_authorized(live_server, jwt_keys): - token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - headers = get_headers(encoded) - - capabilities = reverse("capabilities", kwargs=V1) - response = client.get(f"{live_server.url}{capabilities}", headers=headers) - assert response.status_code == 200 - - schedule_list = reverse("schedule-list", kwargs=V1) - response = client.get(f"{live_server.url}{schedule_list}", headers=headers) - assert response.status_code == 200 - - status = reverse("status", kwargs=V1) - response = client.get(f"{live_server.url}{status}", headers=headers) - assert response.status_code == 200 - - task_root = reverse("task-root", kwargs=V1) - response = client.get(f"{live_server.url}{task_root}", headers=headers) - assert response.status_code == 200 - - task_results_overview = reverse("task-results-overview", kwargs=V1) - response = client.get(f"{live_server.url}{task_results_overview}", headers=headers) - assert response.status_code == 200 - - upcoming_tasks = reverse("upcoming-tasks", kwargs=V1) - response = client.get(f"{live_server.url}{upcoming_tasks}", headers=headers) - assert response.status_code == 200 - - user_list = reverse("user-list", kwargs=V1) - response = client.get(f"{live_server.url}{user_list}", headers=headers) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_user_cannot_view_user_detail(live_server, jwt_keys): - sensor01_token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode( - sensor01_token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - sensor02_token_payload = get_token_payload(authorities=["ROLE_USER"]) - sensor02_token_payload["user_name"] = "sensor02" - encoded = jwt.encode( - sensor02_token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - - sensor01_user = User.objects.get(username=sensor01_token_payload["user_name"]) - kws = {"pk": sensor01_user.pk} - kws.update(V1) - user_detail = reverse("user-detail", kwargs=kws) - response = client.get( - f"{live_server.url}{user_detail}", headers=get_headers(encoded) - ) - assert response.status_code == 403 - - -@pytest.mark.django_db -def test_user_cannot_view_user_detail_role_change(live_server, jwt_keys): - sensor01_token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode( - sensor01_token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - token_payload = get_token_payload(authorities=["ROLE_USER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - - sensor01_user = User.objects.get(username=sensor01_token_payload["user_name"]) - kws = {"pk": sensor01_user.pk} - kws.update(V1) - user_detail = reverse("user-detail", kwargs=kws) - response = client.get( - f"{live_server.url}{user_detail}", headers=get_headers(encoded) - ) - assert response.status_code == 403 - - -@pytest.mark.django_db -def test_admin_can_view_user_detail(live_server, jwt_keys): - token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - headers = get_headers(encoded) - response = client.get(f"{live_server.url}", headers=headers) - assert response.status_code == 200 - - sensor01_user = User.objects.get(username=token_payload["user_name"]) - kws = {"pk": sensor01_user.pk} - kws.update(V1) - user_detail = reverse("user-detail", kwargs=kws) - response = client.get(f"{live_server.url}{user_detail}", headers=headers) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_admin_can_view_other_user_detail(live_server, jwt_keys): - sensor01_token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode( - sensor01_token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(encoded)) - assert response.status_code == 200 - - sensor02_token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - sensor02_token_payload["user_name"] = "sensor02" - encoded = jwt.encode( - sensor02_token_payload, str(jwt_keys.private_key), algorithm="RS256" - ) - client = RequestsClient() - - sensor01_user = User.objects.get(username=sensor01_token_payload["user_name"]) - kws = {"pk": sensor01_user.pk} - kws.update(V1) - user_detail = reverse("user-detail", kwargs=kws) - response = client.get( - f"{live_server.url}{user_detail}", headers=get_headers(encoded) - ) - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_token_hidden(live_server, jwt_keys): - token_payload = get_token_payload(authorities=["ROLE_MANAGER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - client = RequestsClient() - headers = get_headers(encoded) - response = client.get(f"{live_server.url}", headers=headers) - assert response.status_code == 200 - - sensor01_user = User.objects.get(username=token_payload["user_name"]) - kws = {"pk": sensor01_user.pk} - kws.update(V1) - user_detail = reverse("user-detail", kwargs=kws) - client = RequestsClient() - response = client.get(f"{live_server.url}{user_detail}", headers=headers) - assert response.status_code == 200 - assert ( - response.json()["auth_token"] - == "rest_framework.authentication.TokenAuthentication is not enabled" - ) - - -@pytest.mark.django_db -def test_change_token_role_bad_signature(live_server, jwt_keys): - """Make sure token modified after it was signed is rejected""" - token_payload = get_token_payload(authorities=["ROLE_USER"]) - encoded = jwt.encode(token_payload, str(jwt_keys.private_key), algorithm="RS256") - first_period = encoded.find(".") - second_period = encoded.find(".", first_period + 1) - payload = encoded[first_period + 1 : second_period] - payload_bytes = payload.encode("utf-8") - # must be multiple of 4 for b64decode - for i in range(len(payload_bytes) % 4): - payload_bytes = payload_bytes + b"=" - decoded = base64.b64decode(payload_bytes) - payload_str = decoded.decode("utf-8") - payload_data = json.loads(payload_str) - payload_data["user_name"] = "sensor013" - payload_data["authorities"] = ["ROLE_MANAGER"] - payload_data["userDetails"]["authorities"] = [{"authority": "ROLE_MANAGER"}] - payload_str = json.dumps(payload_data) - modified_payload = base64.b64encode(payload_str.encode("utf-8")) - modified_payload = modified_payload.decode("utf-8") - # remove padding - if modified_payload.endswith("="): - last_padded_index = len(modified_payload) - 1 - for i in range(len(modified_payload) - 1, -1, -1): - if modified_payload[i] != "=": - last_padded_index = i - break - modified_payload = modified_payload[: last_padded_index + 1] - modified_token = ( - encoded[:first_period] - + "." - + modified_payload - + "." - + encoded[second_period + 1 :] - ) - client = RequestsClient() - response = client.get(f"{live_server.url}", headers=get_headers(modified_token)) - assert response.status_code == 403 - assert response.json()["detail"] == "Unable to verify token!" diff --git a/src/authentication/tests/test_token_auth.py b/src/authentication/tests/test_token_auth.py index c0e7aae6..e0948f74 100644 --- a/src/authentication/tests/test_token_auth.py +++ b/src/authentication/tests/test_token_auth.py @@ -6,6 +6,7 @@ from authentication.auth import token_auth_enabled from sensor import V1 +from sensor.tests.utils import HTTPS_KWARG pytestmark = pytest.mark.skipif( not token_auth_enabled, reason="Token authentication is not enabled!" @@ -160,6 +161,7 @@ def test_user_cannot_view_user_detail(settings, live_server, user_client, user): response = user_client.get( f"{live_server.url}{user_detail}", headers={"Authorization": f"Token {user.auth_token.key}"}, + **HTTPS_KWARG, ) assert response.status_code == 403 diff --git a/src/authentication/tests/utils.py b/src/authentication/tests/utils.py deleted file mode 100644 index 3ee92b5d..00000000 --- a/src/authentication/tests/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa - - -def get_test_public_private_key(): - """Creates public/private key pair for testing - https://stackoverflow.com/a/39126754 - """ - key = rsa.generate_private_key( - backend=default_backend(), public_exponent=65537, key_size=4096 - ) - private_key = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - public_key = key.public_key().public_bytes( - serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo - ) - return private_key, public_key diff --git a/src/conftest.py b/src/conftest.py index af8f4f52..58b70608 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -8,13 +8,9 @@ import scheduler from authentication.models import User -from authentication.tests.utils import get_test_public_private_key +from sensor.tests.certificate_auth_client import CertificateAuthClient from tasks.models import TaskResult -PRIVATE_KEY, PUBLIC_KEY = get_test_public_private_key() -Keys = namedtuple("KEYS", ["private_key", "public_key"]) -keys = Keys(PRIVATE_KEY.decode("utf-8"), PUBLIC_KEY.decode("utf-8")) - @pytest.fixture(autouse=True) def cleanup_db(db): @@ -66,8 +62,17 @@ def user(db): @pytest.fixture def user_client(db, user): """A Django test client logged in as a normal user""" - client = Client() - client.login(username=user.username, password=user.password) + client = CertificateAuthClient() + assert client.login(username=user.username, password=user.password) + + return client + + +@pytest.fixture +def admin_client(db, admin_user): + """A Django test client logged in as an admin user""" + client = CertificateAuthClient() + assert client.login(username=admin_user.username, password="password") return client @@ -92,8 +97,8 @@ def alt_user(db): @pytest.fixture def alt_user_client(db, alt_user): """A Django test client logged in as a normal user""" - client = Client() - client.login(username=alt_user.username, password=alt_user.password) + client = CertificateAuthClient() + assert client.login(username=alt_user.username, password=alt_user.password) return client @@ -110,15 +115,15 @@ def alt_admin_user(db, django_user_model, django_username_field): username_field = django_username_field try: - user = UserModel._default_manager.get(**{username_field: "alt_admin"}) + user = UserModel._default_manager.get(**{username_field: "ALT ADMIN"}) except UserModel.DoesNotExist: extra_fields = {} if username_field != "username": - extra_fields[username_field] = "alt_admin" + extra_fields[username_field] = "ALT ADMIN" user = UserModel._default_manager.create_superuser( - "alt_admin", "alt_admin@example.com", "password", **extra_fields + "ALT ADMIN", "alt_admin@example.com", "password", **extra_fields ) return user @@ -129,16 +134,7 @@ def alt_admin_client(db, alt_admin_user): """A Django test client logged in as an admin user.""" from django.test.client import Client - client = Client() - client.login(username=alt_admin_user.username, password="password") + client = CertificateAuthClient() + assert client.login(username=alt_admin_user.username, password="password") return client - - -@pytest.fixture(autouse=True) -def jwt_keys(settings): - with tempfile.NamedTemporaryFile() as jwt_public_key_file: - jwt_public_key_file.write(PUBLIC_KEY) - jwt_public_key_file.flush() - settings.PATH_TO_JWT_PUBLIC_KEY = jwt_public_key_file.name - yield keys diff --git a/src/requirements-dev.txt b/src/requirements-dev.txt index 96beb804..e8ef0921 100644 --- a/src/requirements-dev.txt +++ b/src/requirements-dev.txt @@ -4,7 +4,7 @@ # # pip-compile requirements-dev.in # -aiohttp==3.9.0 +aiohttp==3.9.1 # via # -r requirements-dev.in # aiohttp-cors @@ -16,41 +16,41 @@ aiosignal==1.3.1 # -r requirements.txt # aiohttp # ray -asgiref==3.6.0 +asgiref==3.7.2 # via # -r requirements.txt # django -async-timeout==4.0.2 +async-timeout==4.0.3 # via aiohttp -attrs==22.2.0 +attrs==23.1.0 # via # -r requirements.txt # aiohttp # jsonschema - # pytest + # referencing blessed==1.20.0 # via gpustat -cachetools==5.3.0 +cachetools==5.3.2 # via # google-auth # tox -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements.txt # requests -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements.txt # cryptography -cfgv==3.3.1 +cfgv==3.4.0 # via pre-commit -chardet==5.1.0 +chardet==5.2.0 # via tox -charset-normalizer==3.0.1 +charset-normalizer==3.3.2 # via # -r requirements.txt # requests -click==8.1.3 +click==8.1.7 # via # -r requirements.txt # ray @@ -58,26 +58,15 @@ colorama==0.4.6 # via tox colorful==0.5.5 # via ray -coreapi==2.3.3 - # via - # -r requirements.txt - # drf-yasg -coreschema==0.0.4 - # via - # -r requirements.txt - # coreapi - # drf-yasg -coverage[toml]==7.2.1 - # via - # coverage - # pytest-cov -cryptography==41.0.6 +coverage[toml]==7.3.2 + # via pytest-cov +cryptography==41.0.7 # via -r requirements.txt defusedxml==0.7.1 # via # -r requirements.txt # its-preselector -distlib==0.3.6 +distlib==0.3.7 # via virtualenv django==3.2.23 # via @@ -93,48 +82,53 @@ djangorestframework==3.14.0 # via # -r requirements.txt # drf-yasg -drf-yasg==1.21.5 +drf-yasg==1.21.7 # via -r requirements.txt environs==9.5.0 # via # -r requirements.txt # scos-actions # scos-tekrsa -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via pytest -filelock==3.9.0 +filelock==3.13.1 # via # -r requirements.txt # ray # tox # virtualenv -frozenlist==1.3.3 +frozenlist==1.4.0 # via # -r requirements.txt # aiohttp # aiosignal # ray -google-api-core==2.11.0 +google-api-core==2.14.0 # via opencensus -google-auth==2.17.3 +google-auth==2.24.0 # via google-api-core -googleapis-common-protos==1.59.0 +googleapis-common-protos==1.61.0 # via google-api-core -gpustat==1.1 +gpustat==1.1.1 # via ray -grpcio==1.51.3 +grpcio==1.59.3 # via # -r requirements.txt # ray gunicorn==20.1.0 # via -r requirements.txt -identify==2.5.18 +identify==2.5.32 # via pre-commit -idna==3.4 +idna==3.6 # via # -r requirements.txt # requests # yarl +importlib-resources==6.1.1 + # via + # -r requirements.txt + # jsonschema + # jsonschema-specifications inflection==0.5.1 # via # -r requirements.txt @@ -145,33 +139,25 @@ its-preselector @ git+https://github.com/NTIA/Preselector@3.1.0 # via # -r requirements.txt # scos-actions -itypes==1.2.0 - # via - # -r requirements.txt - # coreapi -jinja2==3.1.3 - # via - # -r requirements.txt - # coreschema jsonfield==3.1.0 # via -r requirements.txt -jsonschema==3.2.0 +jsonschema==4.20.0 # via # -r requirements.txt # ray -markupsafe==2.1.2 +jsonschema-specifications==2023.11.2 # via # -r requirements.txt - # jinja2 -marshmallow==3.19.0 + # jsonschema +marshmallow==3.20.1 # via # -r requirements.txt # environs -msgpack==1.0.5 +msgpack==1.0.7 # via # -r requirements.txt # ray -msgspec==0.16.0 +msgspec==0.18.4 # via # -r requirements.txt # scos-actions @@ -179,13 +165,13 @@ multidict==6.0.4 # via # aiohttp # yarl -nodeenv==1.7.0 +nodeenv==1.8.0 # via pre-commit -numexpr==2.8.4 +numexpr==2.8.6 # via # -r requirements.txt # scos-actions -numpy==1.24.2 +numpy==1.24.4 # via # -r requirements.txt # numexpr @@ -194,17 +180,13 @@ numpy==1.24.2 # scos-actions # sigmf # tekrsa-api-wrap -nvidia-ml-py==11.525.112 +nvidia-ml-py==12.535.133 # via gpustat -oauthlib==3.2.2 - # via - # -r requirements.txt - # requests-oauthlib -opencensus==0.11.2 +opencensus==0.11.3 # via ray opencensus-context==0.1.3 # via opencensus -packaging==23.0 +packaging==23.2 # via # -r requirements.txt # drf-yasg @@ -213,34 +195,38 @@ packaging==23.0 # pytest # ray # tox -platformdirs==3.0.0 +pkgutil-resolve-name==1.3.10 + # via + # -r requirements.txt + # jsonschema +platformdirs==3.11.0 # via # tox # virtualenv -pluggy==1.0.0 +pluggy==1.3.0 # via # pytest # tox -pre-commit==3.3.3 +pre-commit==3.5.0 # via -r requirements-dev.in -prometheus-client==0.13.1 +prometheus-client==0.19.0 # via ray -protobuf==4.23.3 +protobuf==4.25.1 # via # -r requirements.txt # google-api-core # googleapis-common-protos # ray -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements.txt # gpustat # scos-actions -psycopg2-binary==2.9.5 +psycopg2-binary==2.9.9 # via -r requirements.txt py-spy==0.3.14 # via ray -pyasn1==0.5.0 +pyasn1==0.5.1 # via # pyasn1-modules # rsa @@ -250,33 +236,27 @@ pycparser==2.21 # via # -r requirements.txt # cffi -pydantic==1.10.7 +pydantic==1.10.13 # via ray -pyjwt==2.6.0 - # via -r requirements.txt -pyproject-api==1.5.0 +pyproject-api==1.5.4 # via tox -pyrsistent==0.19.3 - # via - # -r requirements.txt - # jsonschema -pytest==7.2.1 +pytest==7.4.3 # via # pytest-cov # pytest-django pytest-cov==3.0.0 # via -r requirements-dev.in -pytest-django==4.5.2 +pytest-django==4.7.0 # via -r requirements-dev.in python-dateutil==2.8.2 # via # -r requirements.txt # scos-actions -python-dotenv==0.21.1 +python-dotenv==1.0.0 # via # -r requirements.txt # environs -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements.txt # django @@ -285,6 +265,7 @@ pytz==2022.7.1 pyyaml==6.0.1 # via # -r requirements.txt + # drf-yasg # pre-commit # ray ray[default]==2.6.3 @@ -292,25 +273,30 @@ ray[default]==2.6.3 # -r requirements-dev.in # -r requirements.txt # scos-actions +referencing==0.31.1 + # via + # -r requirements.txt + # jsonschema + # jsonschema-specifications requests==2.31.0 # via # -r requirements.txt - # coreapi # google-api-core # its-preselector # ray # requests-mock - # requests-oauthlib -requests-mock==1.10.0 - # via -r requirements.txt -requests-oauthlib==1.3.1 +requests-mock==1.11.0 # via -r requirements.txt +rpds-py==0.13.2 + # via + # -r requirements.txt + # jsonschema + # referencing rsa==4.9 # via google-auth -ruamel-yaml==0.17.21 +ruamel-yaml==0.18.5 # via # -r requirements.txt - # drf-yasg # scos-actions ruamel-yaml-clib==0.2.8 # via @@ -335,12 +321,10 @@ six==1.16.0 # -r requirements.txt # blessed # django-session-timeout - # google-auth - # jsonschema # python-dateutil # requests-mock # sigmf -smart-open==6.3.0 +smart-open==6.4.0 # via ray sqlparse==0.4.4 # via @@ -356,28 +340,34 @@ tomli==2.0.1 # pyproject-api # pytest # tox -tox==4.4.6 +tox==4.5.1.1 # via -r requirements-dev.in -typing-extensions==4.5.0 - # via pydantic +typing-extensions==4.8.0 + # via + # -r requirements.txt + # asgiref + # pydantic uritemplate==4.1.1 # via # -r requirements.txt - # coreapi # drf-yasg -urllib3==1.26.18 +urllib3==2.1.0 # via # -r requirements.txt # requests -virtualenv==20.20.0 +virtualenv==20.21.0 # via # pre-commit # ray # tox -wcwidth==0.2.6 +wcwidth==0.2.12 # via blessed -yarl==1.9.2 +yarl==1.9.3 # via aiohttp +zipp==3.17.0 + # via + # -r requirements.txt + # importlib-resources # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/src/requirements.in b/src/requirements.in index b99a9f25..e55d8fba 100644 --- a/src/requirements.in +++ b/src/requirements.in @@ -7,17 +7,14 @@ environs>=9.0, <10.0 filelock>=3.9, <4.0 gunicorn>=20.0, <21.0 jsonfield>=3.0, <4.0 -oauthlib>=3.2.1, <4.0 packaging>=23.0, <24.0 psycopg2-binary>=2.0, <3.0 -pyjwt>=2.4.0, <3.0 requests-mock>=1.0, <2.0 -requests_oauthlib>=1.0, <2.0 scos_tekrsa @ git+https://github.com/NTIA/scos-tekrsa@4.0.0 # The following are sub-dependencies for which SCOS Sensor enforces a # higher minimum patch version than the dependencies which require them. # This is done to ensure the inclusion of specific security patches. -jinja2>=3.1.3 # CVE-2024-22195 pyyaml>=5.4.0 # CVE-2020-14343 +grpcio>=1.53.0 # CVE-2023-32732, CVE-2023-32731, CVE-2023-1428 urllib3>=1.26.18 # CVE-2023-45803 diff --git a/src/requirements.txt b/src/requirements.txt index fe7f191b..fa2499cd 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -6,25 +6,21 @@ # aiosignal==1.3.1 # via ray -asgiref==3.6.0 +asgiref==3.7.2 # via django -attrs==22.2.0 - # via jsonschema -certifi==2023.7.22 +attrs==23.1.0 + # via + # jsonschema + # referencing +certifi==2023.11.17 # via requests -cffi==1.15.1 +cffi==1.16.0 # via cryptography -charset-normalizer==3.0.1 +charset-normalizer==3.3.2 # via requests -click==8.1.3 +click==8.1.7 # via ray -coreapi==2.3.3 - # via drf-yasg -coreschema==0.0.4 - # via - # coreapi - # drf-yasg -cryptography==41.0.6 +cryptography==41.0.7 # via -r requirements.in defusedxml==0.7.1 # via its-preselector @@ -42,52 +38,52 @@ djangorestframework==3.14.0 # via # -r requirements.in # drf-yasg -drf-yasg==1.21.5 +drf-yasg==1.21.7 # via -r requirements.in environs==9.5.0 # via # -r requirements.in # scos-actions # scos-tekrsa -filelock==3.9.0 +filelock==3.13.1 # via # -r requirements.in # ray -frozenlist==1.3.3 +frozenlist==1.4.0 # via # aiosignal # ray -grpcio==1.51.3 - # via ray +grpcio==1.59.3 + # via + # -r requirements.in + # ray gunicorn==20.1.0 # via -r requirements.in -idna==3.4 +idna==3.6 # via requests +importlib-resources==6.1.1 + # via + # jsonschema + # jsonschema-specifications inflection==0.5.1 # via drf-yasg its-preselector @ git+https://github.com/NTIA/Preselector@3.1.0 # via scos-actions -itypes==1.2.0 - # via coreapi -jinja2==3.1.3 - # via - # -r requirements.in - # coreschema jsonfield==3.1.0 # via -r requirements.in -jsonschema==3.2.0 +jsonschema==4.20.0 # via ray -markupsafe==2.1.2 - # via jinja2 -marshmallow==3.19.0 +jsonschema-specifications==2023.11.2 + # via jsonschema +marshmallow==3.20.1 # via environs -msgpack==1.0.5 +msgpack==1.0.7 # via ray -msgspec==0.16.0 +msgspec==0.18.4 # via scos-actions -numexpr==2.8.4 +numexpr==2.8.6 # via scos-actions -numpy==1.24.2 +numpy==1.24.4 # via # numexpr # ray @@ -95,33 +91,27 @@ numpy==1.24.2 # scos-actions # sigmf # tekrsa-api-wrap -oauthlib==3.2.2 - # via - # -r requirements.in - # requests-oauthlib -packaging==23.0 +packaging==23.2 # via # -r requirements.in # drf-yasg # marshmallow # ray -protobuf==4.23.3 +pkgutil-resolve-name==1.3.10 + # via jsonschema +protobuf==4.25.1 # via ray -psutil==5.9.5 +psutil==5.9.6 # via scos-actions -psycopg2-binary==2.9.5 +psycopg2-binary==2.9.9 # via -r requirements.in pycparser==2.21 # via cffi -pyjwt==2.6.0 - # via -r requirements.in -pyrsistent==0.19.3 - # via jsonschema python-dateutil==2.8.2 # via scos-actions -python-dotenv==0.21.1 +python-dotenv==1.0.0 # via environs -pytz==2022.7.1 +pytz==2023.3.post1 # via # django # djangorestframework @@ -129,24 +119,27 @@ pytz==2022.7.1 pyyaml==6.0.1 # via # -r requirements.in + # drf-yasg # ray ray==2.6.3 # via scos-actions +referencing==0.31.1 + # via + # jsonschema + # jsonschema-specifications requests==2.31.0 # via - # coreapi # its-preselector # ray # requests-mock - # requests-oauthlib -requests-mock==1.10.0 - # via -r requirements.in -requests-oauthlib==1.3.1 +requests-mock==1.11.0 # via -r requirements.in -ruamel-yaml==0.17.21 +rpds-py==0.13.2 # via - # drf-yasg - # scos-actions + # jsonschema + # referencing +ruamel-yaml==0.18.5 + # via scos-actions ruamel-yaml-clib==0.2.8 # via ruamel-yaml scipy==1.10.1 @@ -160,7 +153,6 @@ sigmf @ git+https://github.com/NTIA/SigMF@multi-recording-archive six==1.16.0 # via # django-session-timeout - # jsonschema # python-dateutil # requests-mock # sigmf @@ -168,14 +160,16 @@ sqlparse==0.4.4 # via django tekrsa-api-wrap==1.3.2 # via scos-tekrsa +typing-extensions==4.8.0 + # via asgiref uritemplate==4.1.1 - # via - # coreapi - # drf-yasg -urllib3==1.26.18 + # via drf-yasg +urllib3==2.1.0 # via # -r requirements.in # requests +zipp==3.17.0 + # via importlib-resources # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/src/schedule/tests/test_user_views.py b/src/schedule/tests/test_user_views.py index 1d9b3a65..10b8c511 100644 --- a/src/schedule/tests/test_user_views.py +++ b/src/schedule/tests/test_user_views.py @@ -32,6 +32,6 @@ def test_user_cannot_view_schedule_entry_detail(user_client, admin_client): kws = {"pk": admin_entry_name} kws.update(V1) admin_url = reverse("schedule-detail", kwargs=kws) - response = user_client.get(admin_url) + response = user_client.get(admin_url, **HTTPS_KWARG) rjson = validate_response(response, status.HTTP_403_FORBIDDEN) assert rjson != EMPTY_SCHEDULE_RESPONSE diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index b5849ccd..2c1cc45b 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -10,7 +10,6 @@ from django.utils import timezone from scos_actions.signals import trigger_api_restart -from authentication import oauth from schedule.models import ScheduleEntry from sensor import settings from tasks.consts import MAX_DETAIL_LEN @@ -193,14 +192,15 @@ def _finalize_task_result(self, task_result, started, finished, status, detail): if settings.PATH_TO_VERIFY_CERT != "": verify_ssl = settings.PATH_TO_VERIFY_CERT logger.debug(settings.CALLBACK_AUTHENTICATION) - if settings.CALLBACK_AUTHENTICATION == "OAUTH": - client = oauth.get_oauth_client() + if settings.CALLBACK_AUTHENTICATION == "CERT": headers = {"Content-Type": "application/json"} - response = client.post( + + response = requests.post( self.entry.callback_url, data=json.dumps(result_json), headers=headers, verify=verify_ssl, + cert=settings.PATH_TO_CLIENT_CERT, timeout=settings.CALLBACK_TIMEOUT, ) self._callback_response_handler(response, task_result) diff --git a/src/scheduler/tests/test_scheduler.py b/src/scheduler/tests/test_scheduler.py index 3810a5e6..195708d1 100644 --- a/src/scheduler/tests/test_scheduler.py +++ b/src/scheduler/tests/test_scheduler.py @@ -317,24 +317,16 @@ def test_minimum_duration_non_blocking(): def verify_request(request_history, status="success", detail=None): request_json = None - if conf.settings.CALLBACK_AUTHENTICATION == "OAUTH": - oauth_history = request_history[0] - assert oauth_history.verify == conf.settings.PATH_TO_VERIFY_CERT - assert ( - oauth_history.text - == f"grant_type=password&username={conf.settings.USER_NAME}&password={conf.settings.PASSWORD}" - ) - assert oauth_history.cert == conf.settings.PATH_TO_CLIENT_CERT - auth_header = oauth_history.headers.get("Authorization") - auth_header = auth_header.replace("Basic ", "") - auth_header_decoded = base64.b64decode(auth_header).decode("utf-8") - assert ( - auth_header_decoded - == f"{conf.settings.CLIENT_ID}:{conf.settings.CLIENT_SECRET}" - ) - request_json = request_history[1].json() - else: - request_json = request_history[0].json() + history = request_history[0] + if conf.settings.CALLBACK_SSL_VERIFICATION: + if conf.settings.PATH_TO_VERIFY_CERT: + assert history.verify == conf.settings.PATH_TO_VERIFY_CERT + else: + assert history.verify == True + if conf.settings.CALLBACK_AUTHENTICATION == "CERT": + assert history.cert == conf.settings.PATH_TO_CLIENT_CERT + + request_json = request_history[0].json() assert request_json["status"] == status assert request_json["task_id"] == 1 assert request_json["self"] @@ -348,9 +340,7 @@ def verify_request(request_history, status="success", detail=None): @pytest.mark.django_db def test_failure_posted_to_callback_url(test_scheduler, settings): """If an entry has callback_url defined, scheduler should POST to it.""" - oauth_token_url = "https://auth/mock" callback_url = "https://results" - settings.OAUTH_TOKEN_URL = oauth_token_url cb_flag = threading.Event() def cb_request_handler(sess, resp): @@ -368,16 +358,14 @@ def cb_request_handler(sess, resp): request_history = None with requests_mock.Mocker() as m: # register mock url for posting - if settings.CALLBACK_AUTHENTICATION == "OAUTH": + if settings.CALLBACK_AUTHENTICATION == "CERT": m.post( callback_url, - request_headers={"Authorization": "Bearer " + "test_access_token"}, ) else: m.post( callback_url, request_headers={"Authorization": "Token " + str(token)} ) - m.post(oauth_token_url, json={"access_token": "test_access_token"}) s.run(blocking=False) time.sleep(0.1) # let requests thread run request_history = m.request_history @@ -389,9 +377,7 @@ def cb_request_handler(sess, resp): @pytest.mark.django_db def test_success_posted_to_callback_url(test_scheduler, settings): """If an entry has callback_url defined, scheduler should POST to it.""" - oauth_token_url = "https://auth/mock" callback_url = "https://results" - settings.OAUTH_TOKEN_URL = oauth_token_url cb_flag = threading.Event() def cb_request_handler(sess, resp): @@ -410,20 +396,17 @@ def cb_request_handler(sess, resp): request_history = None with requests_mock.Mocker() as m: # register mock url for posting - if settings.CALLBACK_AUTHENTICATION == "OAUTH": + if settings.CALLBACK_AUTHENTICATION == "CERT": m.post( callback_url, - request_headers={"Authorization": "Bearer " + "test_access_token"}, ) else: m.post( callback_url, request_headers={"Authorization": "Token " + str(token)} ) - m.post(oauth_token_url, json={"access_token": "test_access_token"}) s.run(blocking=False) time.sleep(0.1) # let requests thread run request_history = m.request_history - # request_json = m.request_history[0].json() assert cb_flag.is_set() assert action_flag.is_set() @@ -432,15 +415,25 @@ def cb_request_handler(sess, resp): @pytest.mark.django_db def test_notification_failed_status_unknown_host(test_scheduler): - entry = create_entry("t", 1, 1, 100, 5, "logger", "https://badmgr.its.bldrdoc.gov") - entry.save() - entry.refresh_from_db() - print("entry = " + entry.name) - s = test_scheduler - advance_testclock(s.timefn, 1) - s.run(blocking=False) # queue first 10 tasks - result = TaskResult.objects.first() - assert result.status == "notification_failed" + with requests_mock.Mocker() as m: + callback_url = "https://results" + entry = create_entry("t", 1, 1, 100, 5, "logger", callback_url) + entry.save() + token = entry.owner.auth_token + m.post( + callback_url, + request_headers={"Authorization": "Token " + str(token)}, + text="Not Found", + status_code=404, + ) + + entry.refresh_from_db() + print("entry = " + entry.name) + s = test_scheduler + advance_testclock(s.timefn, 1) + s.run(blocking=False) # queue first 10 tasks + result = TaskResult.objects.first() + assert result.status == "notification_failed" @pytest.mark.django_db diff --git a/src/sensor/migration_settings.py b/src/sensor/migration_settings.py index 0cf779a6..215dc228 100644 --- a/src/sensor/migration_settings.py +++ b/src/sensor/migration_settings.py @@ -226,7 +226,7 @@ ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", - "authentication.permissions.RequiredJWTRolePermissionOrIsSuperuser", + "authentication.permissions.IsSuperuser", ), "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", @@ -244,10 +244,9 @@ } AUTHENTICATION = env("AUTHENTICATION", default="") -if AUTHENTICATION == "JWT": +if AUTHENTICATION == "CERT": REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( - "authentication.auth.OAuthJWTAuthentication", - "rest_framework.authentication.SessionAuthentication", + "authentication.auth.CertificateAuthentication", ) else: REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( @@ -258,45 +257,44 @@ # https://drf-yasg.readthedocs.io/en/stable/settings.html SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": {}, + "SECURITY_DEFINITIONS": { + "cert": { + "type": "cert", + "description": ( + "Certificate based mutual TLS authentication. " + "AUTHENTICATION must be set to 'CERT'. " + "This is done by the client verifying the server certificate and the server verifying the client certificate. " + "The client certificate Common Name (CN) should contain the username of a user that exists in the database. " + "Client certificate verification is handled by NGINX. " + "For more information, see https://www.rfc-editor.org/rfc/rfc5246." + ), + }, + "token": { + "type": "apiKey", + "description": ( + "Tokens are automatically generated for all users. You can " + "view yours by going to your User Details view in the " + "browsable API at `/api/v1/users/me` and looking for the " + "`auth_token` key. New user accounts do not initially " + "have a password and so can not log in to the browsable API. " + "To set a password for a user (for testing purposes), an " + "admin can do that in the Sensor Configuration Portal, but " + "only the account's token should be stored and used for " + "general purpose API access. " + 'Example cURL call: `curl -kLsS -H "Authorization: Token' + ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' + "https://localhost/api/v1`. " + "AUTHENTICATION should be set to 'TOKEN'" + ), + "name": "Token", + "in": "header", + }, + }, "APIS_SORTER": "alpha", "OPERATIONS_SORTER": "method", "VALIDATOR_URL": None, } -if AUTHENTICATION == "JWT": - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["oAuth2JWT"] = { - "type": "oauth2", - "description": ( - "OAuth2 authentication using resource owner password flow." - "This is done by verifing JWT bearer tokens signed with RS256 algorithm." - "The JWT_PUBLIC_KEY_FILE setting controls the public key used for signature verification." - "Only authorizes users who have an authority matching the REQUIRED_ROLE setting." - "For more information, see https://tools.ietf.org/html/rfc6749#section-4.3." - ), - "flows": {"password": {"scopes": {}}}, # scopes are not used - } -else: - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["token"] = { - "type": "apiKey", - "description": ( - "Tokens are automatically generated for all users. You can " - "view yours by going to your User Details view in the " - "browsable API at `/api/v1/users/me` and looking for the " - "`auth_token` key. New user accounts do not initially " - "have a password and so can not log in to the browsable API. " - "To set a password for a user (for testing purposes), an " - "admin can do that in the Sensor Configuration Portal, but " - "only the account's token should be stored and used for " - "general purpose API access. " - 'Example cURL call: `curl -kLsS -H "Authorization: Token' - ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' - "https://localhost/api/v1`" - ), - "name": "Token", - "in": "header", - } - # Database # https://docs.djangoproject.com/en/1.11/ref/settings/#databases @@ -378,29 +376,18 @@ CALLBACK_SSL_VERIFICATION = env.bool("CALLBACK_SSL_VERIFICATION", default=True) -# OAuth Password Flow Authentication CALLBACK_AUTHENTICATION = env("CALLBACK_AUTHENTICATION", default="") -CLIENT_ID = env("CLIENT_ID", default="") -CLIENT_SECRET = env("CLIENT_SECRET", default="") -USER_NAME = CLIENT_ID -PASSWORD = CLIENT_SECRET -OAUTH_TOKEN_URL = env("OAUTH_TOKEN_URL", default="") CERTS_DIR = path.join(CONFIG_DIR, "certs") # Sensor certificate with private key used as client cert PATH_TO_CLIENT_CERT = env("PATH_TO_CLIENT_CERT", default="") if PATH_TO_CLIENT_CERT != "": PATH_TO_CLIENT_CERT = path.join(CERTS_DIR, PATH_TO_CLIENT_CERT) -# Trusted Certificate Authority certificate to verify authserver and callback URL server certificate +# Trusted Certificate Authority certificate to verify callback URL server certificate PATH_TO_VERIFY_CERT = env("PATH_TO_VERIFY_CERT", default="") if PATH_TO_VERIFY_CERT != "": PATH_TO_VERIFY_CERT = path.join(CERTS_DIR, PATH_TO_VERIFY_CERT) -# Public key to verify JWT token -PATH_TO_JWT_PUBLIC_KEY = env.str("PATH_TO_JWT_PUBLIC_KEY", default="") -if PATH_TO_JWT_PUBLIC_KEY != "": - PATH_TO_JWT_PUBLIC_KEY = path.join(CERTS_DIR, PATH_TO_JWT_PUBLIC_KEY) -# Required role from JWT token to access API -REQUIRED_ROLE = "ROLE_MANAGER" + PRESELECTOR_CONFIG = env.str( "PRESELECTOR_CONFIG", default=path.join(CONFIG_DIR, "preselector_config.json") diff --git a/src/sensor/runtime_settings.py b/src/sensor/runtime_settings.py index 069a5b4a..0e4a5d65 100644 --- a/src/sensor/runtime_settings.py +++ b/src/sensor/runtime_settings.py @@ -235,7 +235,7 @@ ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", - "authentication.permissions.RequiredJWTRolePermissionOrIsSuperuser", + "authentication.permissions.IsSuperuser", ), "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", @@ -253,10 +253,9 @@ } AUTHENTICATION = env("AUTHENTICATION", default="") -if AUTHENTICATION == "JWT": +if AUTHENTICATION == "CERT": REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( - "authentication.auth.OAuthJWTAuthentication", - "rest_framework.authentication.SessionAuthentication", + "authentication.auth.CertificateAuthentication", ) else: REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( @@ -267,45 +266,44 @@ # https://drf-yasg.readthedocs.io/en/stable/settings.html SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": {}, + "SECURITY_DEFINITIONS": { + "cert": { + "type": "cert", + "description": ( + "Certificate based mutual TLS authentication. " + "AUTHENTICATION must be set to 'CERT'. " + "This is done by the client verifying the server certificate and the server verifying the client certificate. " + "The client certificate Common Name (CN) should contain the username of a user that exists in the database. " + "Client certificate verification is handled by NGINX. " + "For more information, see https://www.rfc-editor.org/rfc/rfc5246." + ), + }, + "token": { + "type": "apiKey", + "description": ( + "Tokens are automatically generated for all users. You can " + "view yours by going to your User Details view in the " + "browsable API at `/api/v1/users/me` and looking for the " + "`auth_token` key. New user accounts do not initially " + "have a password and so can not log in to the browsable API. " + "To set a password for a user (for testing purposes), an " + "admin can do that in the Sensor Configuration Portal, but " + "only the account's token should be stored and used for " + "general purpose API access. " + 'Example cURL call: `curl -kLsS -H "Authorization: Token' + ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' + "https://localhost/api/v1`. " + "AUTHENTICATION should be set to 'TOKEN'" + ), + "name": "Token", + "in": "header", + }, + }, "APIS_SORTER": "alpha", "OPERATIONS_SORTER": "method", "VALIDATOR_URL": None, } -if AUTHENTICATION == "JWT": - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["oAuth2JWT"] = { - "type": "oauth2", - "description": ( - "OAuth2 authentication using resource owner password flow." - "This is done by verifing JWT bearer tokens signed with RS256 algorithm." - "The JWT_PUBLIC_KEY_FILE setting controls the public key used for signature verification." - "Only authorizes users who have an authority matching the REQUIRED_ROLE setting." - "For more information, see https://tools.ietf.org/html/rfc6749#section-4.3." - ), - "flows": {"password": {"scopes": {}}}, # scopes are not used - } -else: - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["token"] = { - "type": "apiKey", - "description": ( - "Tokens are automatically generated for all users. You can " - "view yours by going to your User Details view in the " - "browsable API at `/api/v1/users/me` and looking for the " - "`auth_token` key. New user accounts do not initially " - "have a password and so can not log in to the browsable API. " - "To set a password for a user (for testing purposes), an " - "admin can do that in the Sensor Configuration Portal, but " - "only the account's token should be stored and used for " - "general purpose API access. " - 'Example cURL call: `curl -kLsS -H "Authorization: Token' - ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' - "https://localhost/api/v1`" - ), - "name": "Token", - "in": "header", - } - # Database # https://docs.djangoproject.com/en/1.11/ref/settings/#databases @@ -387,30 +385,18 @@ CALLBACK_SSL_VERIFICATION = env.bool("CALLBACK_SSL_VERIFICATION", default=True) -# OAuth Password Flow Authentication CALLBACK_AUTHENTICATION = env("CALLBACK_AUTHENTICATION", default="") CALLBACK_TIMEOUT = env.int("CALLBACK_TIMEOUT", default=3) -CLIENT_ID = env("CLIENT_ID", default="") -CLIENT_SECRET = env("CLIENT_SECRET", default="") -USER_NAME = CLIENT_ID -PASSWORD = CLIENT_SECRET -OAUTH_TOKEN_URL = env("OAUTH_TOKEN_URL", default="") CERTS_DIR = path.join(CONFIG_DIR, "certs") # Sensor certificate with private key used as client cert PATH_TO_CLIENT_CERT = env("PATH_TO_CLIENT_CERT", default="") if PATH_TO_CLIENT_CERT != "": PATH_TO_CLIENT_CERT = path.join(CERTS_DIR, PATH_TO_CLIENT_CERT) -# Trusted Certificate Authority certificate to verify authserver and callback URL server certificate +# Trusted Certificate Authority certificate to verify callback URL server certificate PATH_TO_VERIFY_CERT = env("PATH_TO_VERIFY_CERT", default="") if PATH_TO_VERIFY_CERT != "": PATH_TO_VERIFY_CERT = path.join(CERTS_DIR, PATH_TO_VERIFY_CERT) -# Public key to verify JWT token -PATH_TO_JWT_PUBLIC_KEY = env.str("PATH_TO_JWT_PUBLIC_KEY", default="") -if PATH_TO_JWT_PUBLIC_KEY != "": - PATH_TO_JWT_PUBLIC_KEY = path.join(CERTS_DIR, PATH_TO_JWT_PUBLIC_KEY) -# Required role from JWT token to access API -REQUIRED_ROLE = "ROLE_MANAGER" PRESELECTOR_CONFIG = env.str( "PRESELECTOR_CONFIG", default=path.join(CONFIG_DIR, "preselector_config.json") diff --git a/src/sensor/settings.py b/src/sensor/settings.py index 8c512804..83628914 100644 --- a/src/sensor/settings.py +++ b/src/sensor/settings.py @@ -237,7 +237,7 @@ ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", - "authentication.permissions.RequiredJWTRolePermissionOrIsSuperuser", + "authentication.permissions.IsSuperuser", ), "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", @@ -255,10 +255,9 @@ } AUTHENTICATION = env("AUTHENTICATION", default="") -if AUTHENTICATION == "JWT": +if AUTHENTICATION == "CERT": REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( - "authentication.auth.OAuthJWTAuthentication", - "rest_framework.authentication.SessionAuthentication", + "authentication.auth.CertificateAuthentication", ) else: REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = ( @@ -269,45 +268,44 @@ # https://drf-yasg.readthedocs.io/en/stable/settings.html SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": {}, + "SECURITY_DEFINITIONS": { + "cert": { + "type": "cert", + "description": ( + "Certificate based mutual TLS authentication. " + "AUTHENTICATION must be set to 'CERT'. " + "This is done by the client verifying the server certificate and the server verifying the client certificate. " + "The client certificate Common Name (CN) should contain the username of a user that exists in the database. " + "Client certificate verification is handled by NGINX. " + "For more information, see https://www.rfc-editor.org/rfc/rfc5246." + ), + }, + "token": { + "type": "apiKey", + "description": ( + "Tokens are automatically generated for all users. You can " + "view yours by going to your User Details view in the " + "browsable API at `/api/v1/users/me` and looking for the " + "`auth_token` key. New user accounts do not initially " + "have a password and so can not log in to the browsable API. " + "To set a password for a user (for testing purposes), an " + "admin can do that in the Sensor Configuration Portal, but " + "only the account's token should be stored and used for " + "general purpose API access. " + 'Example cURL call: `curl -kLsS -H "Authorization: Token' + ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' + "https://localhost/api/v1`. " + "AUTHENTICATION should be set to 'TOKEN'" + ), + "name": "Token", + "in": "header", + }, + }, "APIS_SORTER": "alpha", "OPERATIONS_SORTER": "method", "VALIDATOR_URL": None, } -if AUTHENTICATION == "JWT": - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["oAuth2JWT"] = { - "type": "oauth2", - "description": ( - "OAuth2 authentication using resource owner password flow." - "This is done by verifing JWT bearer tokens signed with RS256 algorithm." - "The JWT_PUBLIC_KEY_FILE setting controls the public key used for signature verification." - "Only authorizes users who have an authority matching the REQUIRED_ROLE setting." - "For more information, see https://tools.ietf.org/html/rfc6749#section-4.3." - ), - "flows": {"password": {"scopes": {}}}, # scopes are not used - } -else: - SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["token"] = { - "type": "apiKey", - "description": ( - "Tokens are automatically generated for all users. You can " - "view yours by going to your User Details view in the " - "browsable API at `/api/v1/users/me` and looking for the " - "`auth_token` key. New user accounts do not initially " - "have a password and so can not log in to the browsable API. " - "To set a password for a user (for testing purposes), an " - "admin can do that in the Sensor Configuration Portal, but " - "only the account's token should be stored and used for " - "general purpose API access. " - 'Example cURL call: `curl -kLsS -H "Authorization: Token' - ' 529c30e6e04b3b546f2e073e879b75fdfa147c15" ' - "https://localhost/api/v1`" - ), - "name": "Token", - "in": "header", - } - # Database # https://docs.djangoproject.com/en/1.11/ref/settings/#databases @@ -389,30 +387,18 @@ CALLBACK_SSL_VERIFICATION = env.bool("CALLBACK_SSL_VERIFICATION", default=True) -# OAuth Password Flow Authentication CALLBACK_AUTHENTICATION = env("CALLBACK_AUTHENTICATION", default="") CALLBACK_TIMEOUT = env.int("CALLBACK_TIMEOUT", default=3) -CLIENT_ID = env("CLIENT_ID", default="") -CLIENT_SECRET = env("CLIENT_SECRET", default="") -USER_NAME = CLIENT_ID -PASSWORD = CLIENT_SECRET -OAUTH_TOKEN_URL = env("OAUTH_TOKEN_URL", default="") CERTS_DIR = path.join(CONFIG_DIR, "certs") # Sensor certificate with private key used as client cert PATH_TO_CLIENT_CERT = env("PATH_TO_CLIENT_CERT", default="") if PATH_TO_CLIENT_CERT != "": PATH_TO_CLIENT_CERT = path.join(CERTS_DIR, PATH_TO_CLIENT_CERT) -# Trusted Certificate Authority certificate to verify authserver and callback URL server certificate +# Trusted Certificate Authority certificate to verify callback URL server certificate PATH_TO_VERIFY_CERT = env("PATH_TO_VERIFY_CERT", default="") if PATH_TO_VERIFY_CERT != "": PATH_TO_VERIFY_CERT = path.join(CERTS_DIR, PATH_TO_VERIFY_CERT) -# Public key to verify JWT token -PATH_TO_JWT_PUBLIC_KEY = env.str("PATH_TO_JWT_PUBLIC_KEY", default="") -if PATH_TO_JWT_PUBLIC_KEY != "": - PATH_TO_JWT_PUBLIC_KEY = path.join(CERTS_DIR, PATH_TO_JWT_PUBLIC_KEY) -# Required role from JWT token to access API -REQUIRED_ROLE = "ROLE_MANAGER" PRESELECTOR_CONFIG = env.str( "PRESELECTOR_CONFIG", default=path.join(CONFIG_DIR, "preselector_config.json") diff --git a/src/sensor/tests/certificate_auth_client.py b/src/sensor/tests/certificate_auth_client.py new file mode 100644 index 00000000..fabc0592 --- /dev/null +++ b/src/sensor/tests/certificate_auth_client.py @@ -0,0 +1,114 @@ +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.test.client import MULTIPART_CONTENT, Client + +from sensor.tests.utils import get_http_request_ssl_dn_header + +cert_auth_enabled = settings.AUTHENTICATION == "CERT" + + +class CertificateAuthClient(Client): + """Adds SSL DN header if certificate authentication is being used""" + + def __init__( + self, enforce_csrf_checks=False, json_encoder=DjangoJSONEncoder, **defaults + ) -> None: + super().__init__(enforce_csrf_checks, json_encoder=json_encoder, **defaults) + self.username = "" + + def get_kwargs(self, extra): + kwargs = {} + kwargs.update(extra) + if cert_auth_enabled: + kwargs.update(get_http_request_ssl_dn_header(self.username)) + return kwargs + + def get(self, path, data=None, follow=False, secure=False, **extra): + assert secure + return super().get(path, data, follow, secure, **self.get_kwargs(extra)) + + def post( + self, + path, + data=None, + content_type=MULTIPART_CONTENT, + follow=False, + secure=False, + **extra + ): + assert secure + return super().post( + path, data, content_type, follow, secure, **self.get_kwargs(extra) + ) + + def head(self, path, data=None, follow=False, secure=False, **extra): + assert secure + return super().head(path, data, follow, secure, **self.get_kwargs(extra)) + + def options( + self, + path, + data="", + content_type="application/octet-stream", + follow=False, + secure=False, + **extra + ): + assert secure + return super().options( + self, path, data, content_type, follow, secure, **self.get_kwargs(extra) + ) + + def put( + self, + path, + data="", + content_type="application/octet-stream", + follow=False, + secure=False, + **extra + ): + assert secure + return super().put( + path, data, content_type, follow, secure, **self.get_kwargs(extra) + ) + + def patch( + self, + path, + data="", + content_type="application/octet-stream", + follow=False, + secure=False, + **extra + ): + assert secure + return super().patch( + path, data, content_type, follow, secure, **self.get_kwargs(extra) + ) + + def delete( + self, + path, + data="", + content_type="application/octet-stream", + follow=False, + secure=False, + **extra + ): + assert secure + return super().delete( + path, data, content_type, follow, secure, **self.get_kwargs(extra) + ) + + def trace(self, path, follow=False, secure=False, **extra): + assert secure + return super().trace(path, follow, secure, **self.get_kwargs(extra)) + + def login(self, **credentials): + if cert_auth_enabled: + assert "username" in credentials + self.username = credentials["username"] + return True + else: + return super().login(**credentials) diff --git a/src/sensor/tests/test_api_docs.py b/src/sensor/tests/test_api_docs.py index d5b4f150..66c6d423 100644 --- a/src/sensor/tests/test_api_docs.py +++ b/src/sensor/tests/test_api_docs.py @@ -4,6 +4,7 @@ from rest_framework.reverse import reverse from sensor import V1, settings +from sensor.tests.utils import HTTPS_KWARG def test_api_docs_up_to_date(admin_client): @@ -16,7 +17,7 @@ def test_api_docs_up_to_date(admin_client): return True schema_url = reverse("api_schema", kwargs=V1) + "?format=openapi" - response = admin_client.get(schema_url) + response = admin_client.get(schema_url, **HTTPS_KWARG) with open(settings.OPENAPI_FILE, "w+") as openapi_file: openapi_json = json.loads(response.content) diff --git a/src/sensor/tests/utils.py b/src/sensor/tests/utils.py index cf327955..aee48d73 100644 --- a/src/sensor/tests/utils.py +++ b/src/sensor/tests/utils.py @@ -1,6 +1,6 @@ from rest_framework import status -HTTPS_KWARG = {"wsgi.url_scheme": "https"} +HTTPS_KWARG = {"wsgi.url_scheme": "https", "secure": True} def validate_response(response, expected_code=None): @@ -14,3 +14,15 @@ def validate_response(response, expected_code=None): if actual_code not in (status.HTTP_204_NO_CONTENT,): rjson = response.json() return rjson + + +def get_requests_ssl_dn_header(common_name): + return { + "X-Ssl-Client-Dn": f"C=TC,ST=test_state,L=test_locality,O=test_org,OU=test_ou,CN={common_name}", + } + + +def get_http_request_ssl_dn_header(common_name): + return { + "HTTP_X-SSL-CLIENT-DN": f"C=TC,ST=test_state,L=test_locality,O=test_org,OU=test_ou,CN={common_name}", + } diff --git a/src/sensor/urls.py b/src/sensor/urls.py index 2f469f8b..70f6ac23 100644 --- a/src/sensor/urls.py +++ b/src/sensor/urls.py @@ -17,18 +17,19 @@ """ +from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path, re_path from django.views.generic import RedirectView from rest_framework.urlpatterns import format_suffix_patterns -from . import settings from .views import api_v1_root, schema_view # Matches api/v1, api/v2, etc... API_PREFIX = r"^api/(?Pv[0-9]+)/" DEFAULT_API_VERSION = settings.REST_FRAMEWORK["DEFAULT_VERSION"] +AUTHENTICATION = settings.AUTHENTICATION api_urlpatterns = format_suffix_patterns( ( @@ -57,5 +58,7 @@ path("admin/", admin.site.urls), path("api/", RedirectView.as_view(url=f"/api/{DEFAULT_API_VERSION}/")), re_path(API_PREFIX, include(api_urlpatterns)), - path("api/auth/", include("rest_framework.urls")), ] + +# logout/login does not do anything if AUTHENTICATION is set to "CERT" +urlpatterns.append(path("api/auth/", include("rest_framework.urls"))) diff --git a/src/status/fixtures/greyhound.json b/src/status/fixtures/greyhound.json deleted file mode 100644 index 0ba0d957..00000000 --- a/src/status/fixtures/greyhound.json +++ /dev/null @@ -1,12 +0,0 @@ -[ -{ - "model": "status.location", - "pk": 1, - "fields": { - "active": true, - "description": "DOC Boulder Labs Bldg 1 3420", - "latitude": "39.994793", - "longitude": "-105.262078" - } -} -] diff --git a/src/tasks/tests/test_overview_view.py b/src/tasks/tests/test_overview_view.py index bf15b80e..0bdc99b8 100644 --- a/src/tasks/tests/test_overview_view.py +++ b/src/tasks/tests/test_overview_view.py @@ -31,10 +31,10 @@ def test_admin_get_overview(admin_client): assert overview["schedule_entry"] # is non-empty string -def test_user_delete_overview_not_allowed(admin_client): +def test_user_delete_overview_not_allowed(user_client): url = reverse_results_overview() - response = admin_client.delete(url, **HTTPS_KWARG) - assert validate_response(response, status.HTTP_405_METHOD_NOT_ALLOWED) + response = user_client.delete(url, **HTTPS_KWARG) + assert validate_response(response, status.HTTP_403_FORBIDDEN) def test_admin_delete_overview_not_allowed(admin_client): diff --git a/src/tasks/views.py b/src/tasks/views.py index 330bba8f..56d63048 100644 --- a/src/tasks/views.py +++ b/src/tasks/views.py @@ -217,7 +217,7 @@ def build_sigmf_archive(fileobj, schedule_entry_name, acquisitions): raw_data = acq.data.read() data = fernet.decrypt(raw_data) del raw_data - tmpdata.write(data) # decrypted data will be stored on disk in tmp file + tmpdata.write(data) # decrypted data stored in file del data else: tmpdata.write(acq.data.read()) diff --git a/src/test_utils/task_test_utils.py b/src/test_utils/task_test_utils.py index 7528f722..0fe79604 100644 --- a/src/test_utils/task_test_utils.py +++ b/src/test_utils/task_test_utils.py @@ -187,6 +187,7 @@ def update_result_detail(client, schedule_entry_name, task_id, new_acquisition): "data": json.dumps(new_acquisition), "content_type": "application/json", "wsgi.url_scheme": "https", + "secure": True, } return client.put(url, **kwargs) diff --git a/src/tox.ini b/src/tox.ini index 73313df3..d44c4172 100644 --- a/src/tox.ini +++ b/src/tox.ini @@ -3,6 +3,7 @@ envlist = py38 py39 py310 + cert min_version = 4.0 skip_missing_interpreters = true no_package = true @@ -13,15 +14,11 @@ deps = -r {toxinidir}/requirements-dev.txt commands = pytest {posargs} -[testenv:oauth] +[testenv:cert] envlist = py38,py39,py310 setenv = - AUTHENTICATION=JWT - CALLBACK_AUTHENTICATION=OAUTH - CLIENT_ID=sensor01.sms.internal - CLIENT_SECRET=sensor-secret - PATH_TO_CLIENT_CERT=test/sensor01.pem - PATH_TO_VERIFY_CERT=test/scos_test_ca.crt + AUTHENTICATION=CERT + CALLBACK_AUTHENTICATION=CERT SWITCH_CONFIGS_DIR=../configs/switches [testenv:coverage]