diff --git a/.github/workflows/.ci.yml b/.github/workflows/.ci.yml index 63ec332..4cc8323 100644 --- a/.github/workflows/.ci.yml +++ b/.github/workflows/.ci.yml @@ -63,3 +63,35 @@ jobs: - name: Run unit tests run: pytest -c test/pytest.ini test/unit/ --cov + + docker: + # This job triggers only if all the other jobs succeed. It builds the Docker image and if successful, + # it pushes it to Harbor. + needs: [linting, unit-tests] + name: Docker + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Login to Harbor + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: harbor.stfc.ac.uk/ldap-jwt-authentication + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: harbor.stfc.ac.uk/ldap-jwt-authentication/auth-api + + - name: Build and push Docker image to Harbor + uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0 + with: + context: . + file: ./Dockerfile.prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 7230ad4..657396f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ ldap_jwt_auth/logging.ini -ldap_jwt_auth/active_usernames.txt +/active_usernames.txt /keys/ # Byte-compiled / optimized / DLL files diff --git a/Dockerfile b/Dockerfile index 169b8d7..9e0bff8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM python:3.10-alpine3.17 +FROM python:3.10-alpine3.19 WORKDIR /ldap-jwt-auth-run COPY pyproject.toml ./ COPY ldap_jwt_auth/ ldap_jwt_auth/ +COPY logs/ logs/ RUN --mount=type=cache,target=/root/.cache \ set -eux; \ diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..cc15637 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,31 @@ +FROM python:3.10-alpine3.19 + +WORKDIR /ldap-jwt-auth-run + +COPY README.md pyproject.toml ./ +# Copy ldap_jwt_auth source files +COPY ldap_jwt_auth/ ldap_jwt_auth/ +COPY logs/ logs/ + +RUN set -eux; \ + \ + # Install python-ldap system dependencies \ + apk add --no-cache build-base openldap-dev python3-dev; \ + \ + # Install pip dependencies \ + python -m pip install --no-cache-dir .; \ + \ + # Create loging.ini from its .example file \ + cp ldap_jwt_auth/logging.example.ini ldap_jwt_auth/logging.ini; \ + \ + # Create a non-root user to run as \ + addgroup -g 500 -S ldap-jwt-auth; \ + adduser -S -D -G ldap-jwt-auth -H -u 500 -h /ldap-jwt-auth-run ldap-jwt-auth; \ + \ + # Change ownership of logs/ - app will need to write log files to it \ + chown -R ldap-jwt-auth:ldap-jwt-auth logs/; + +USER ldap-jwt-auth + +CMD ["uvicorn", "ldap_jwt_auth.main:app", "--host", "0.0.0.0", "--port", "8000"] +EXPOSE 8000 diff --git a/README.md b/README.md index 434b346..8c7f77f 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,51 @@ # LDAP-JWT Authentication Service -This is a Python microservice that provides user authentication against an LDAP server and returns a JSON Web Token -(JWT). + +This is a Python microservice created using FastAPI that provides user authentication against an LDAP server and returns +a JSON Web Token (JWT). ## How to Run This microservice requires an LDAP server to run against. ### Prerequisites -- Docker installed (if you want to run the microservice inside Docker) -- Python 3.10 (or above) and an LDAP server to connect to +- Docker and Docker Compose installed (if you want to run the microservice inside Docker) +- LDAP server to connect to +- Python 3.10 (or above) (if you are not using Docker) - Private and public key pair (must be OpenSSH encoded) for encrypting and decrypting the JWTs +- A list of active usernames, defining who can use this service - This repository cloned ### Docker Setup -The easiest way to run the application with Docker for local development is using the `docker-compose.yml` file. It is -configured to start the application in a reload mode using the `Dockerfile`. You can also use the `Dockerfile` directly -to run the application in a container. Please do not use the `Dockerfile` in production. - Ensure that Docker is installed and running on your machine before proceeding. -1. Create a `.env` file alongside the `.env.example` file. Use the example file as a reference and modify the values accordingly. +1. Create a `.env` file alongside the `.env.example` file. Use the example file as a reference and modify the values + accordingly. + ```bash + cp ldap_jwt_auth/.env.example ldap_jwt_auth/.env + ``` -2. Create a `logging.ini` file alongside the `logging.example.ini` file. Use the example file as a reference and modify it accordingly. +2. Create a `logging.ini` file alongside the `logging.example.ini` file. Use the example file as a reference and modify + it accordingly: + ```bash + cp ldap_jwt_auth/logging.example.ini ldap_jwt_auth/logging.ini + ``` -3. Create a `keys` directory in the root of the project directory, navigate to it, and generate OpenSSH encoded private and public key pair: +3. Create a `keys` directory in the root of the project directory, navigate to it, and generate OpenSSH encoded private + and public key pair: ```bash mkdir keys - cd keys/ - ssh-keygen -b 2048 -t rsa -f jwt-key -q -N "" + ssh-keygen -b 2048 -t rsa -f keys/jwt-key -q -N "" -C "" + ``` + +4. Create a `active_usernames.txt` file alongside the `active_usernames.example.txt` file and add all the usernames that + can use this system. The usernames are the Federal IDs and each one should be stored on a separate line. + ```bash + cp active_usernames.example.txt active_usernames.txt ``` -4. Create a `active_usernames.txt` file alongside the `active_usernames.example.txt` file and add all the usernames (each one on a seperate line) that are active/can access the system. +#### Using `docker-compose.yml` +The easiest way to run the application with Docker for local development is using the `docker-compose.yml` file. It is +configured to start the application in a reload mode using the `Dockerfile`. -#### Using Docker Compose File 1. Build and start the Docker container: ```bash docker-compose up @@ -39,21 +53,62 @@ Ensure that Docker is installed and running on your machine before proceeding. The microservice should now be running inside Docker at http://localhost:8000 and its Swagger UI could be accessed at http://localhost:8000/docs. -#### Using Dockerfile +#### Using `Dockerfile` +Use the `Dockerfile` to run just the application itself in a container. Use this only for local development (not +production)! + 1. Build an image using the `Dockerfile` from the root of the project directory: ```bash - docker build -f Dockerfile -t ldap_jwt_auth_image . + docker build -f Dockerfile -t ldap_jwt_auth_api_image . ``` 2. Start the container using the image built and map it to port `8000` locally: ```bash - docker run -p 8000:8000 --name ldap_jwt_auth_container ldap_jwt_auth_image + docker run -p 8000:8000 --name ldap_jwt_auth_api_container -v ./keys/jwt-key:/ldap-jwt-auth-run/keys/jwt-key -v ./keys/jwt-key.pub:/ldap-jwt-auth-run/keys/jwt-key.pub -v ./active_usernames.txt:/ldap-jwt-auth-run/active_usernames.txt -v ./logs:/ldap-jwt-auth-run/logs ldap_jwt_auth_api_image + ``` + or with values for the environment variables: + ```bash + docker run -p 8000:8000 --name ldap_jwt_auth_api_container --env AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES=10 -v ./keys/jwt-key:/ldap-jwt-auth-run/keys/jwt-key -v ./keys/jwt-key.pub:/ldap-jwt-auth-run/keys/jwt-key.pub -v ./active_usernames.txt:/ldap-jwt-auth-run/active_usernames.txt -v ./logs:/ldap-jwt-auth-run/logs ldap_jwt_auth_api_image + ``` + The microservice should now be running inside Docker at http://localhost:8000 and its Swagger UI could be accessed + at http://localhost:8000/docs. + +#### Using `Dockerfile.prod` +Use the `Dockerfile.prod` to run just the application itself in a container. This can be used for production. + +1. While in root of the project directory, change the permissions of the `logs` directory so that it is writable by + other users. This allows the container to save the application logs to it. + ```bash + sudo chmod -R 0777 logs/ + ``` + +2. Private keys are only readable by the owner. Given that the private key is generated on the host machine and the + container runs with a different user, it means that the key is not readable by the user in the container because the + ownership belongs to the user on the host. This can be solved by transferring the ownership to the user in the + container and setting the permissions. + ```bash + sudo chown 500:500 keys/jwt-key + sudo chmod 0400 keys/jwt-key + ``` + +3. Build an image using the `Dockerfile.prod` from the root of the project directory: + ```bash + docker build -f Dockerfile.prod -t ldap_jwt_auth_api_image . + ``` + +4. Start the container using the image built and map it to port `8000` locally: + ```bash + docker run -p 8000:8000 --name ldap_jwt_auth_api_container -v ./keys/jwt-key:/ldap-jwt-auth-run/keys/jwt-key -v ./keys/jwt-key.pub:/ldap-jwt-auth-run/keys/jwt-key.pub -v ./active_usernames.txt:/ldap-jwt-auth-run/active_usernames.txt -v ./logs:/ldap-jwt-auth-run/logs ldap_jwt_auth_api_image + ``` + or with values for the environment variables: + ```bash + docker run -p 8000:8000 --name ldap_jwt_auth_api_container --env AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES=10 -v ./keys/jwt-key:/ldap-jwt-auth-run/keys/jwt-key -v ./keys/jwt-key.pub:/ldap-jwt-auth-run/keys/jwt-key.pub -v ./active_usernames.txt:/ldap-jwt-auth-run/active_usernames.txt -v ./logs:/ldap-jwt-auth-run/logs ldap_jwt_auth_api_image ``` The microservice should now be running inside Docker at http://localhost:8000 and its Swagger UI could be accessed at http://localhost:8000/docs. ### Local Setup -Ensure that you have an LDAP server to connect to. +Ensure that Python is installed on your machine before proceeding. 1. Create a Python virtual environment and activate it in the root of the project directory: ```bash @@ -68,21 +123,30 @@ Ensure that you have an LDAP server to connect to. ```bash pip install .[dev] ``` - 4. Create a `.env` file alongside the `.env.example` file. Use the example file as a reference and modify the values accordingly. + ```bash + cp ldap_jwt_auth/.env.example ldap_jwt_auth/.env + ``` 5. Create a `logging.ini` file alongside the `logging.example.ini` file. Use the example file as a reference and modify - it accordingly. + it accordingly: + ```bash + cp ldap_jwt_auth/logging.example.ini ldap_jwt_auth/logging.ini + ``` -6. Create a `keys` directory in the root of the project directory, navigate to it, and generate OpenSSH encoded private and public key pair: +6. Create a `keys` directory in the root of the project directory, navigate to it, and generate OpenSSH encoded private + and public key pair: ```bash mkdir keys - cd keys/ - ssh-keygen -b 2048 -t rsa -f jwt-key -q -N "" + ssh-keygen -b 2048 -t rsa -f keys/jwt-key -q -N "" -C "" ``` -7. Create a `active_usernames.txt` file alongside the `active_usernames.example.txt` file and add all the usernames (each one on a seperate line) that are active/can access the system. +7. Create a `active_usernames.txt` file alongside the `active_usernames.example.txt` file and add all the usernames that + can use this system. The usernames are the Federal IDs and each one should be stored on a separate line. + ```bash + cp active_usernames.example.txt active_usernames.txt + ``` 8. Start the microservice using Uvicorn: ```bash @@ -95,3 +159,28 @@ Ensure that you have an LDAP server to connect to. ```bash pytest -c test/pytest.ini test/unit/ ``` + +## Notes + +### Application Configuration +The configuration for the application is handled +using [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). It allows for loading config +values from environment variables or the `.env` file. Please note that even when using the `.env` file, Pydantic will +still read environment variables as well as the `.env` file, environment variables will always take priority over +values loaded from the `.env` file. + +Listed below are the environment variables supported by the application. + +| Environment Variable | Description | Mandatory | Default Value | +|-------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------------------------| +| `API__TITLE` | The title of the API which is added to the generated OpenAPI. | No | `LDAP-JWT Authentication Service API` | +| `API__DESCRIPTION` | The description of the API which is added to the generated OpenAPI. | No | `This is the API for the LDAP-JWT Authentication Service` | +| `API__ROOT_PATH` | (If using a proxy) The path prefix handled by a proxy that is not seen by the app. | No | ` ` | +| `AUTHENTICATION__PRIVATE_KEY_PATH` | The path to the private key to be used for encoding JWT access and refresh tokens. | Yes | | +| `AUTHENTICATION__PUBLIC_KEY_PATH` | The path to the public key to be used for decoding JWT access and refresh tokens signed by the corresponding private key. | Yes | | +| `AUTHENTICATION__JWT_ALGORITHM` | The algorithm to use to decode the JWT access and refresh tokens. | Yes | | +| `AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES` | Minutes after which the JWT access token expires. | Yes | | +| `AUTHENTICATION__REFRESH_TOKEN_VALIDITY_DAYS` | Days after which the JWT refresh token expires. | Yes | | +| `AUTHENTICATION__ACTIVE_USERNAMES_PATH` | The path to the `txt` file containing the active usernames and defining who can use this service. | Yes | | +| `LDAP_SERVER__URL` | The URL to the LDAP server to connect to. | Yes | | +| `LDAP_SERVER__REALM` | The realm for the LDAP server. | Yes | | diff --git a/ldap_jwt_auth/active_usernames.example.txt b/active_usernames.example.txt similarity index 100% rename from ldap_jwt_auth/active_usernames.example.txt rename to active_usernames.example.txt diff --git a/docker-compose.yml b/docker-compose.yml index 4c3bbc9..e872276 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: volumes: - ./ldap_jwt_auth:/ldap-jwt-auth-run/ldap_jwt_auth - ./keys:/ldap-jwt-auth-run/keys + - ./logs:/ldap-jwt-auth-run/logs + - ./active_usernames.txt:/ldap-jwt-auth-run/active_usernames.txt ports: - 8000:8000 restart: on-failure diff --git a/ldap_jwt_auth/.env.example b/ldap_jwt_auth/.env.example index 9239f3f..71d5293 100644 --- a/ldap_jwt_auth/.env.example +++ b/ldap_jwt_auth/.env.example @@ -7,6 +7,6 @@ AUTHENTICATION__PUBLIC_KEY_PATH=./keys/jwt-key.pub AUTHENTICATION__JWT_ALGORITHM=RS256 AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES=5 AUTHENTICATION__REFRESH_TOKEN_VALIDITY_DAYS=7 -AUTHENTICATION__ACTIVE_USERNAMES_PATH=./ldap_jwt_auth/active_usernames.txt +AUTHENTICATION__ACTIVE_USERNAMES_PATH=./active_usernames.txt LDAP_SERVER__URL=ldap://ldap.example.com:389 LDAP_SERVER__REALM=LDAP.EXAMPLE.COM diff --git a/ldap_jwt_auth/logging.example.ini b/ldap_jwt_auth/logging.example.ini index 28dc765..bf5f3e3 100644 --- a/ldap_jwt_auth/logging.example.ini +++ b/ldap_jwt_auth/logging.example.ini @@ -22,7 +22,7 @@ propagate=0 [handler_fileHandler] class=logging.handlers.TimedRotatingFileHandler formatter=fileFormatter -args=('ldap-jwt-auth.log', 'D', 1, 20,) +args=('./logs/ldap-jwt-auth.log', 'D', 1, 20,) [handler_consoleHandler] class=StreamHandler diff --git a/ldap_jwt_auth/main.py b/ldap_jwt_auth/main.py index 3731375..40debc9 100644 --- a/ldap_jwt_auth/main.py +++ b/ldap_jwt_auth/main.py @@ -14,7 +14,7 @@ from ldap_jwt_auth.core.logger_setup import setup_logger from ldap_jwt_auth.routers import login, refresh, verify -app = FastAPI(title=config.api.title, description=config.api.description) +app = FastAPI(title=config.api.title, description=config.api.description, root_path=config.api.root_path) setup_logger() logger = logging.getLogger() diff --git a/ldap_jwt_auth/routers/login.py b/ldap_jwt_auth/routers/login.py index 61d87f6..a152e67 100644 --- a/ldap_jwt_auth/routers/login.py +++ b/ldap_jwt_auth/routers/login.py @@ -47,8 +47,8 @@ def login( max_age=config.authentication.refresh_token_validity_days * 24 * 60 * 60, secure=True, httponly=True, - samesite="Lax", - path="/refresh", + samesite="lax", + path=f"{config.api.root_path}/refresh", ) return response except (InvalidCredentialsError, UserNotActiveError) as exc: diff --git a/logs/.keep b/logs/.keep new file mode 100644 index 0000000..e69de29