Skip to content

Commit

Permalink
Merge pull request #22 from ral-facilities/list-of-active-users-#21
Browse files Browse the repository at this point in the history
Add a list of active users that can use the system
  • Loading branch information
VKTB authored Feb 1, 2024
2 parents affb151 + 1a85222 commit 45b281f
Show file tree
Hide file tree
Showing 15 changed files with 160 additions and 25 deletions.
8 changes: 6 additions & 2 deletions .github/workflows/.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ jobs:
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
run: |
sudo apt-get update
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
Expand All @@ -41,7 +43,9 @@ jobs:
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
run: |
sudo apt-get update
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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ldap_jwt_auth/logging.ini
ldap_jwt_auth/active_usernames.txt
/keys/

# Byte-compiled / optimized / DLL files
Expand Down
3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +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 .;
python3 -m pip install .[dev];

CMD ["uvicorn", "ldap_jwt_auth.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
EXPOSE 8000
30 changes: 20 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,21 @@ to run the application in a container. Please do not use the `Dockerfile` in pro

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.
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.
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:
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:
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 File
1. Build and start the Docker container:
```bash
docker-compose up
```
Expand Down Expand Up @@ -76,14 +75,25 @@ Ensure that you have an LDAP server to connect to.
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:

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

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.


8. 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 :
9. To run the unit tests, run:
```bash
pytest test/unit/
```
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ services:
build: .
volumes:
- ./ldap_jwt_auth:/ldap-jwt-auth-run/ldap_jwt_auth
- ./keys:/ldap-jwt-auth-run/keys
ports:
- 8000:8000
restart: on-failure
1 change: 1 addition & 0 deletions ldap_jwt_auth/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +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
LDAP_SERVER__URL=ldap://ldap.example.com:389
LDAP_SERVER__REALM=LDAP.EXAMPLE.COM
3 changes: 3 additions & 0 deletions ldap_jwt_auth/active_usernames.example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
abc123
efg456
xyz789
39 changes: 38 additions & 1 deletion ldap_jwt_auth/auth/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import ldap

from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.exceptions import InvalidCredentialsError, LDAPServerError
from ldap_jwt_auth.core.exceptions import (
InvalidCredentialsError,
LDAPServerError,
ActiveUsernamesFileNotFoundError,
UserNotActiveError,
)
from ldap_jwt_auth.core.schemas import UserCredentialsPostRequestSchema

logger = logging.getLogger()
Expand All @@ -21,9 +26,13 @@ class Authentication:
def authenticate(self, user_credentials: UserCredentialsPostRequestSchema) -> None:
"""
Authenticate a user against an LDAP server based on the provided user credentials.
Before attempting to authenticate against LDAP, it checks that the credentials are not empty and that the
username is part of the active usernames.
: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.
:raises UserNotActiveError: If the username is not part of the active usernames.
"""
username = user_credentials.username
password = user_credentials.password
Expand All @@ -33,6 +42,9 @@ def authenticate(self, user_credentials: UserCredentialsPostRequestSchema) -> No
if not username or not password:
raise InvalidCredentialsError("Empty username or password")

if not self.is_user_active(username):
raise UserNotActiveError(f"The provided username '{username}' is not part of the active usernames")

try:
connection = ldap.initialize(config.ldap_server.url)
ldap.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
Expand All @@ -52,3 +64,28 @@ def authenticate(self, user_credentials: UserCredentialsPostRequestSchema) -> No
message = "Problem with LDAP server"
logger.exception(message)
raise LDAPServerError(message) from exc

def is_user_active(self, username: str) -> bool:
"""
Check if the provided username is part of the active usernames.
:param username: The username to check.
:return: `True` if the user is active, `False` otherwise.
"""
logger.info("Checking if user is active")
active_usernames = self._get_active_usernames()
return username in active_usernames

def _get_active_usernames(self) -> list:
"""
Load the active usernames as a list from a `txt` file. It removes any leading and trailing whitespaces and does
not load empty lines/strings.
:return: The list of active usernames.
:raises ActiveUsernamesFileNotFoundError: If the file containing the active usernames cannot be found.
"""
try:
with open(config.authentication.active_usernames_path, "r", encoding="utf-8") as file:
return [line.strip() for line in file.readlines() if line.strip()]
except FileNotFoundError as exc:
raise ActiveUsernamesFileNotFoundError(
f"Cannot find file containing active usernames with path: {config.authentication.active_usernames_path}"
) from exc
1 change: 1 addition & 0 deletions ldap_jwt_auth/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class AuthenticationConfig(BaseModel):
jwt_algorithm: str
access_token_validity_minutes: int
refresh_token_validity_days: int
active_usernames_path: str


class LDAPServerConfig(BaseModel):
Expand Down
8 changes: 4 additions & 4 deletions ldap_jwt_auth/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
# Read the contents of the private and public key files into constants. These are used for encoding and decoding of JWT
# access and refresh tokens.
try:
with open(config.authentication.private_key_path, "r", encoding="utf-8") as f:
PRIVATE_KEY = f.read()
with open(config.authentication.private_key_path, "r", encoding="utf-8") as file:
PRIVATE_KEY = file.read()
except FileNotFoundError as exc:
sys.exit(f"Cannot find private key: {exc}")

try:
with open(config.authentication.public_key_path, "r", encoding="utf-8") as f:
PUBLIC_KEY = f.read()
with open(config.authentication.public_key_path, "r", encoding="utf-8") as file:
PUBLIC_KEY = file.read()
except FileNotFoundError as exc:
sys.exit(f"Cannot find public key: {exc}")
16 changes: 14 additions & 2 deletions ldap_jwt_auth/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
"""


class ActiveUsernamesFileNotFoundError(Exception):
"""
Exception raised when the file containing the active usernames cannot be found.
"""


class InvalidCredentialsError(Exception):
"""
Exception raised when invalid credentials are provided.
"""


class InvalidJWTError(Exception):
"""
Exception raised when invalid JWT token is provided.
Expand All @@ -21,7 +33,7 @@ class LDAPServerError(Exception):
"""


class InvalidCredentialsError(Exception):
class UserNotActiveError(Exception):
"""
Exception raised when invalid credentials are provided.
Exception raised when user is not active.
"""
14 changes: 12 additions & 2 deletions ldap_jwt_auth/routers/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from ldap_jwt_auth.auth.authentication import Authentication
from ldap_jwt_auth.auth.jwt_handler import JWTHandler
from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.exceptions import InvalidCredentialsError, LDAPServerError
from ldap_jwt_auth.core.exceptions import (
InvalidCredentialsError,
LDAPServerError,
UserNotActiveError,
ActiveUsernamesFileNotFoundError,
)
from ldap_jwt_auth.core.schemas import UserCredentialsPostRequestSchema

logger = logging.getLogger()
Expand Down Expand Up @@ -45,10 +50,15 @@ def login(
path="/refresh",
)
return response
except InvalidCredentialsError as exc:
except (InvalidCredentialsError, UserNotActiveError) as exc:
message = "Invalid credentials provided"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=message) from exc
except LDAPServerError as exc:
message = "Something went wrong"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=message) from exc
except ActiveUsernamesFileNotFoundError as exc:
message = "Something went wrong"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=message) from exc
1 change: 1 addition & 0 deletions test/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ AUTHENTICATION__PUBLIC_KEY_PATH=./test/keys/jwt-key.pub
AUTHENTICATION__JWT_ALGORITHM=RS256
AUTHENTICATION__ACCESS_TOKEN_VALIDITY_MINUTES=5
AUTHENTICATION__REFRESH_TOKEN_VALIDITY_DAYS=7
AUTHENTICATION__ACTIVE_USERNAMES_PATH=./test/active_usernames.txt
LDAP_SERVER__URL=ldap://ldap.example.com:389
LDAP_SERVER__REALM=LDAP.EXAMPLE.COM
1 change: 1 addition & 0 deletions test/active_usernames.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
username
58 changes: 56 additions & 2 deletions test/unit/auth/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@

from ldap_jwt_auth.auth.authentication import Authentication
from ldap_jwt_auth.core.config import config
from ldap_jwt_auth.core.exceptions import InvalidCredentialsError, LDAPServerError
from ldap_jwt_auth.core.exceptions import (
InvalidCredentialsError,
LDAPServerError,
UserNotActiveError,
ActiveUsernamesFileNotFoundError,
)
from ldap_jwt_auth.core.schemas import UserCredentialsPostRequestSchema


Expand Down Expand Up @@ -57,7 +62,7 @@ def test_authenticate_with_invalid_credentials(ldap_initialize_mock):
ldap_initialize_mock.return_value = ldap_obj_mock

authentication = Authentication()
user_credentials = UserCredentialsPostRequestSchema(username="invalid_username", password="invalid_password")
user_credentials = UserCredentialsPostRequestSchema(username="username", password="password")

with pytest.raises(InvalidCredentialsError) as exc:
authentication.authenticate(user_credentials)
Expand All @@ -70,6 +75,19 @@ def test_authenticate_with_invalid_credentials(ldap_initialize_mock):
ldap_obj_mock.unbind.assert_called_once()


def test_authenticate_with_not_active_username():
"""
Test LDAP authentication with username that is not active.
"""
authentication = Authentication()
username = "username_not_active"
user_credentials = UserCredentialsPostRequestSchema(username=username, password="password")

with pytest.raises(UserNotActiveError) as exc:
authentication.authenticate(user_credentials)
assert str(exc.value) == f"The provided username '{username}' is not part of the active usernames"


@patch("ldap_jwt_auth.auth.authentication.ldap.initialize")
def test_authenticate_ldap_server_error(ldap_initialize_mock):
"""
Expand All @@ -88,3 +106,39 @@ def test_authenticate_ldap_server_error(ldap_initialize_mock):
ldap_initialize_mock.assert_called_once_with(config.ldap_server.url)
ldap_obj_mock.start_tls_s.assert_called_once()
ldap_obj_mock.unbind.assert_not_called()


def test_is_user_active():
"""
Test `is_user_active` returns `True` when active username is passed to it.
"""
authentication = Authentication()
is_user_active = authentication.is_user_active("username")

assert is_user_active is True


def test_is_user_active_with_not_active_username():
"""
Test `is_user_active` returns `False` when username that is not active is passed to it.
"""
authentication = Authentication()
is_user_active = authentication.is_user_active("username_not_active")

assert is_user_active is False


@patch("builtins.open")
def test_is_user_active_active_usernames_file_not_found(file_open_mock):
"""
Test `is_user_active` when file containing active usernames cannot be found.
"""
file_open_mock.side_effect = FileNotFoundError()

with pytest.raises(ActiveUsernamesFileNotFoundError) as exc:
authentication = Authentication()
authentication.is_user_active("username_not_active")
assert (
str(exc.value)
== f"Cannot find file containing active usernames with path: {config.authentication.active_usernames_path}"
)

0 comments on commit 45b281f

Please sign in to comment.