Skip to content

Commit

Permalink
Merge pull request #7 from ral-facilities/implement-login-endpoint-#6
Browse files Browse the repository at this point in the history
Implement a login endpoint
  • Loading branch information
VKTB authored Jan 18, 2024
2 parents 0fd24d7 + 9f34150 commit 6a108a3
Show file tree
Hide file tree
Showing 24 changed files with 568 additions and 8 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

- name: Install python-ldap system dependencies
run: sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev

- name: Set up Python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
with:
Expand All @@ -29,3 +32,33 @@ jobs:
python -m pip install .[code-analysis]
- name: Run pylint
run: pylint ldap_jwt_auth

unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

- name: Install python-ldap system dependencies
run: sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev

- name: Set up Python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
with:
python-version: "3.10"
cache: "pip"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[test]
- name: Create environment file
run: cp ldap_jwt_auth/.env.example ldap_jwt_auth/.env

- name: Create logging configuration file
run: cp ldap_jwt_auth/logging.example.ini ldap_jwt_auth/logging.ini

- name: Run unit tests
run: pytest test/unit/ --cov
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ldap_jwt_auth/logging.ini
keys/
/keys/

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ max-returns=6
max-statements=50

# Minimum number of public methods for a class (see R0903).
min-public-methods=2
min-public-methods=0


[EXCEPTIONS]
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ WORKDIR /ldap-jwt-auth-run

COPY pyproject.toml ./
COPY ldap_jwt_auth/ ldap_jwt_auth/
COPY keys/ keys/

RUN --mount=type=cache,target=/root/.cache \
set -eux; \
\
apk add --no-cache build-base openldap-dev; \
python3 -m pip install .;

CMD ["uvicorn", "ldap_jwt_auth.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
Expand Down
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,89 @@
# LDAP-JWT Authentication Service
This is a Python microservice 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
- Private and public key pair (must be OpenSSH encoded) for encrypting and decrypting the JWTs
- 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.

#### Using Docker Compose File
1. Create a `.env` file alongside the `.env.example` file. Use the example file as a reference and modify the values
accordingly.

2. Create a `logging.ini` file alongside the `logging.example.ini` file. Use the example file as a reference and modify
it accordingly.

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 ""
```

4. Build and start the Docker container:
```bash
docker-compose up
```
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
1. Build an image using the `Dockerfile` from the root of the project directory:
```bash
docker build -f Dockerfile -t ldap_jwt_auth_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
```
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.

1. Create a Python virtual environment and activate it in the root of the project directory:
```bash
python -m venv venv
source venv/bin/activate
```

2. Install the software packages required to build `python-ldap` on your local system, more
info [here](https://www.python-ldap.org/en/python-ldap-3.3.0/installing.html).

3. Install the required dependencies using pip:
```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.

5. Create a `logging.ini` file alongside the `logging.example.ini` file. Use the example file as a reference and modify
it accordingly.

6. Start the microservice using Uvicorn:
```bash
uvicorn ldap_jwt_auth.main:app --log-config ldap_jwt_auth/logging.ini --reload
```
The microservice should now be running locally at http://localhost:8000. The Swagger UI could be accessed
at http://localhost:8000/docs.

7. To run the unit tests, run :
```bash
pytest test/unit/
```
4 changes: 2 additions & 2 deletions ldap_jwt_auth/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ API__TITLE=LDAP-JWT Authentication Service API
API__DESCRIPTION=This is the API for the LDAP-JWT Authentication Service
# (If using a proxy) The path prefix handled by a proxy that is not seen by the app.
API__ROOT_PATH=
AUTHENTICATION__PRIVATE_KEY_PATH=/ldap-jwt-auth/keys/jwt-key
AUTHENTICATION__PUBLIC_KEY_PATH=/ldap-jwt-auth/keys/jwt-key.pub
AUTHENTICATION__PRIVATE_KEY_PATH=./keys/jwt-key
AUTHENTICATION__PUBLIC_KEY_PATH=./keys/jwt-key.pub
AUTHENTICATION__JWT_ALGORITHM=RS256
AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES=5
AUTHENTICATION__REFRESH_TOKEN_VALIDITY_DAYS=7
Expand Down
Empty file added ldap_jwt_auth/auth/__init__.py
Empty file.
54 changes: 54 additions & 0 deletions ldap_jwt_auth/auth/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# pylint: disable=no-member
"""
Module for providing a class for managing authentication.
"""
import logging

import ldap

from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.exceptions import InvalidCredentialsError, LDAPServerError
from ldap_jwt_auth.core.models import UserCredentials

logger = logging.getLogger()


class Authentication:
"""
Class for managing authentication against an LDAP server.
"""

def authenticate(self, user_credentials: UserCredentials) -> None:
"""
Authenticate a user against an LDAP server based on the provided user credentials.
:param user_credentials: The credentials of the user.
:raises InvalidCredentialsError: If the user credentials are empty or invalid.
:raises LDAPServerError: If there is a problem with the LDAP server.
"""
username = user_credentials.username
password = user_credentials.password
logger.info("Authenticating a user")
logger.debug("Username provided is '%s'", username)

if not username or not password:
raise InvalidCredentialsError("Empty username or password")

try:
connection = ldap.initialize(config.ldap_server.url)
ldap.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
ldap.set_option(ldap.OPT_X_TLS_DEMAND, True)
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 0)
connection.start_tls_s()
connection.simple_bind_s(f"{username}@{config.ldap_server.realm}", user_credentials.password)
logger.info("Authentication successful")
connection.unbind()
except ldap.INVALID_CREDENTIALS as exc:
message = "Invalid username or password"
logger.exception(message)
connection.unbind()
raise InvalidCredentialsError(message) from exc
except Exception as exc:
message = "Problem with LDAP server"
logger.exception(message)
raise LDAPServerError(message) from exc
55 changes: 55 additions & 0 deletions ldap_jwt_auth/auth/jwt_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Module for providing a class for handling JWTs.
"""
import logging
from datetime import datetime, timezone, timedelta

import jwt
from cryptography.hazmat.primitives import serialization

from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.constants import PRIVATE_KEY

logger = logging.getLogger()


class JWTHandler:
"""
Class for handling JWTs.
"""

def get_access_token(self, username: str) -> str:
"""
Generates a payload and returns a signed JWT access token.
:param username: The username of the user.
:return: The signed JWT access token
"""
logger.info("Getting an access token")
payload = {
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(minutes=config.authentication.access_token_validity_minutes),
}
return self._pack_jwt(payload)

def get_refresh_token(self) -> str:
"""
Generates a payload and returns a signed JWT refresh token.
:return: The signed JWT refresh token.
"""
logger.info("Getting a refresh token")
payload = {
"exp": datetime.now(timezone.utc) + timedelta(days=config.authentication.refresh_token_validity_days)
}
return self._pack_jwt(payload)

def _pack_jwt(self, payload: dict) -> str:
"""
Packs the provided payload into a JWT token and signs it.
:param payload: The payload to be packed.
:return: The encoded and signed JWT.
"""
logger.debug("Packing payload into a JWT")
bytes_key = bytes(PRIVATE_KEY, encoding="utf8")
loaded_private_key = serialization.load_ssh_private_key(bytes_key, password=None)
token = jwt.encode(payload, loaded_private_key, algorithm=config.authentication.jwt_algorithm)
return token
15 changes: 15 additions & 0 deletions ldap_jwt_auth/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Module for custom exception classes.
"""


class LDAPServerError(Exception):
"""
Exception raised when there is problem with the LDAP server.
"""


class InvalidCredentialsError(Exception):
"""
Exception raised when invalid credentials are provided.
"""
14 changes: 14 additions & 0 deletions ldap_jwt_auth/core/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
Model for defining the API schema models.
"""

from pydantic import BaseModel


class UserCredentials(BaseModel):
"""
Model for the user credentials.
"""

username: str
password: str
40 changes: 39 additions & 1 deletion ldap_jwt_auth/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,54 @@
"""
import logging

from fastapi import FastAPI
from fastapi import FastAPI, Request, status
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.logger_setup import setup_logger
from ldap_jwt_auth.routers import login

app = FastAPI(title=config.api.title, description=config.api.description)

setup_logger()
logger = logging.getLogger()
logger.info("Logging now setup")


@app.exception_handler(Exception)
async def custom_general_exception_handler(_: Request, exc: Exception) -> JSONResponse:
"""
Custom exception handler for FastAPI to handle uncaught exceptions. It logs the error and returns an appropriate
response.
:param _: Unused
:param exc: The exception object that triggered this handler.
:return: A JSON response indicating that something went wrong.
"""
logger.exception(exc)
return JSONResponse(content={"detail": "Something went wrong"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)


@app.exception_handler(RequestValidationError)
async def custom_validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""
Custom exception handler for FastAPI to handle `RequestValidationError`.
This method is used to handle validation errors that occur when processing incoming requests in FastAPI. When a
`RequestValidationError` is raised during request parsing or validation, this handler will be triggered to log the
error and call `request_validation_exception_handler` to return an appropriate response.
:param request: The incoming HTTP request that caused the validation error.
:param exc: The exception object representing the validation error.
:return: A JSON response with validation error details.
"""
logger.exception(exc)
return await request_validation_exception_handler(request, exc)


# Fixes CORS issues but should be updated before deploying to prod
ALLOWED_ORIGINS = ["*"]

Expand All @@ -26,6 +62,8 @@
allow_headers=["*"],
)

app.include_router(login.router)


@app.get("/")
def read_root():
Expand Down
Empty file.
Loading

0 comments on commit 6a108a3

Please sign in to comment.