Skip to content

Add basic auth #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 19 additions & 4 deletions src/routers/member.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
"""Member endpoints."""

import re
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Response, UploadFile, status
from fastapi import APIRouter, Depends, Response, UploadFile, status

from ..models.member import Member
from ..security import authorize
from ..services.member import MemberService

router = APIRouter(tags=["Member"], prefix="/api/v1/member")
service: MemberService = MemberService()


@router.post("", response_model=Member)
def create_member_folder(member: Member):
def create_member_folder(
member: Member,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Create a folder structure for a member and return the member object.
:param member: Member object
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original member object
"""
service.create_folder_structure(member)
return member


@router.put("", response_model=Member)
def update_member_folder(member: Member):
def update_member_folder(
member: Member,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Update the folder structure for a member and return the member object.
If the member does not exist, return a 404.
:param member: Member object
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original member object
"""
if not service.to_id_path(member.id).exists():
Expand All @@ -38,14 +48,19 @@ def update_member_folder(member: Member):


@router.post("/{member_id}/profilePicture", response_model=UUID)
async def upload_member_picture(member_id: UUID, file: UploadFile):
async def upload_member_picture(
member_id: UUID,
file: UploadFile,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Upload a picture for a member to convert
and store the profile picture in different formats
If the member does not exist, return a 404.
If the file is not an image, return a 500.
:param member_id: the id of the member
:param file: the image file
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original member_id
"""
# pylint: disable=duplicate-code
Expand Down
23 changes: 19 additions & 4 deletions src/routers/video.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
"""Video endpoints"""

import re
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Response, UploadFile, status
from fastapi import APIRouter, Depends, Response, UploadFile, status

from ..models.video import Video
from ..security import authorize
from ..services.video import VideoService

router = APIRouter(tags=["Video"], prefix="/api/v1/video")
service: VideoService = VideoService()


@router.post("", response_model=Video)
def create_video_folder(video: Video):
def create_video_folder(
video: Video,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Create a folder structure for a video and return the video object.
:param video: Video object
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original video object
"""
service.create_folder_structure(video)
return video


@router.put("", response_model=Video)
def update_video_folder(video: Video):
def update_video_folder(
video: Video,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Update the folder structure for a video and return the video object.
If the video does not exist, return a 404.
:param video: Video object
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original video object
"""
if not service.to_id_path(video.id).exists():
Expand All @@ -38,14 +48,19 @@ def update_video_folder(video: Video):


@router.post("/{video_id}/thumbnail", response_model=UUID)
async def upload_video_poster(video_id: UUID, file: UploadFile):
async def upload_video_poster(
video_id: UUID,
file: UploadFile,
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
):
"""
Upload a picture for a video thumbnail to convert
and store the thumbnail in different formats
If the video does not exist, return a 404.
If the file is not an image, return a 500.
:param video_id: the id of the video
:param file: the image file
:param authorized: fastapi dependency to authorize the request
:return: 200 and the original video_id
"""
# pylint: disable=duplicate-code
Expand Down
36 changes: 36 additions & 0 deletions src/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Security module for the FastAPI application."""

import secrets
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials

from .settings import settings

security = HTTPBasic()


def authorize(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
"""
Authorize the request with the correct username and password.
The correct username and password are stored in the settings.
:param credentials: the credentials from the request
:return:
"""
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = settings.username.encode("utf8")
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = settings.password.encode("utf8")
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
2 changes: 2 additions & 0 deletions src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class Settings(BaseSettings):
"""Class for reading settings from environment variables."""

server_base_path: str = "./assets/"
username: str = "admin"
password: str = "password"
Comment on lines +10 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing default credentials in the source code can pose a security risk. Consider using environment variables for sensitive information like username and password to enhance security. This approach allows for more secure and flexible configuration management.

-    username: str = "admin"
-    password: str = "password"
+    username: str = Field(default="admin", env="APP_USERNAME")
+    password: str = Field(default="password", env="APP_PASSWORD")

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
username: str = "admin"
password: str = "password"
username: str = Field(default="admin", env="APP_USERNAME")
password: str = Field(default="password", env="APP_PASSWORD")



settings = Settings()
19 changes: 14 additions & 5 deletions tests/routers/test_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ def test_create_member_folder(client, mocker):

member_data = {"id": str(member_object.id), "url": member_object.url}

response = client.post("/api/v1/member", json=member_data)
response = client.post(
"/api/v1/member", json=member_data, auth=("admin", "password")
)

assert service_mock.create_folder_structure.call_count == 1
assert service_mock.create_folder_structure.call_args[0][0] == member_object
assert response.status_code == 200
assert response.json() == member_data
assert service_mock.create_folder_structure.call_count == 1
assert service_mock.create_folder_structure.call_args[0][0] == member_object


def test_update_member_folder_no_id(client, mocker):
Expand All @@ -36,7 +38,9 @@ def test_update_member_folder_no_id(client, mocker):

member_data = {"id": str(member_object.id), "url": member_object.url}

response = client.put("/api/v1/member", json=member_data)
response = client.put(
"/api/v1/member", json=member_data, auth=("admin", "password")
)

assert service_mock.update_symlink.call_count == 0
assert response.status_code == 404
Expand All @@ -49,7 +53,9 @@ def test_update_member_folder(client, mocker):

member_data = {"id": str(member_object.id), "url": member_object.url}

response = client.put("/api/v1/member", json=member_data)
response = client.put(
"/api/v1/member", json=member_data, auth=("admin", "password")
)

assert service_mock.update_symlink.call_count == 1
assert service_mock.update_symlink.call_args[0][0] == member_object
Expand All @@ -64,6 +70,7 @@ def test_upload_member_picture_no_id(client, mocker):
response = client.post(
f"/api/v1/member/{member_object.id}/profilePicture",
files={"file": ("file.jpg", "file_content", "image/jpeg")},
auth=("admin", "password"),
)

assert service_mock.create_profile_picture.call_count == 0
Expand All @@ -77,6 +84,7 @@ def test_upload_member_picture_not_image(client, mocker):
response = client.post(
f"/api/v1/member/{member_object.id}/profilePicture",
files={"file": ("file.jpg", "file_content", "text/plain")},
auth=("admin", "password"),
)

assert service_mock.create_profile_picture.call_count == 0
Expand All @@ -92,6 +100,7 @@ def test_upload_member_picture(client, mocker):
response = client.post(
f"/api/v1/member/{member_object.id}/profilePicture",
files={"file": ("file.jpg", "file_content", "image/jpeg")},
auth=("admin", "password"),
)

assert service_mock.create_profile_picture.call_count == 1
Expand Down
9 changes: 6 additions & 3 deletions tests/routers/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_create_video_folder(client, mocker):

video_data = {"id": str(video_object.id), "urls": video_object.urls}

response = client.post("/api/v1/video", json=video_data)
response = client.post("/api/v1/video", json=video_data, auth=("admin", "password"))

assert service_mock.create_folder_structure.call_count == 1
assert service_mock.create_folder_structure.call_args[0][0] == video_object
Expand All @@ -38,7 +38,7 @@ def test_update_video_folder_no_id(client, mocker):

video_data = {"id": str(video_object.id), "urls": video_object.urls}

response = client.put("/api/v1/video", json=video_data)
response = client.put("/api/v1/video", json=video_data, auth=("admin", "password"))

assert service_mock.update_symlinks.call_count == 0
assert response.status_code == 404
Expand All @@ -51,7 +51,7 @@ def test_update_video_folder(client, mocker):

video_data = {"id": str(video_object.id), "urls": video_object.urls}

response = client.put("/api/v1/video", json=video_data)
response = client.put("/api/v1/video", json=video_data, auth=("admin", "password"))

assert service_mock.update_symlinks.call_count == 1
assert service_mock.update_symlinks.call_args[0][0] == video_object
Expand All @@ -66,6 +66,7 @@ def test_upload_video_poster_no_id(client, mocker):
response = client.post(
f"/api/v1/video/{video_object.id}/thumbnail",
files={"file": ("file.jpg", "file_content", "text/plain")},
auth=("admin", "password"),
)

assert service_mock.create_thumbnail.call_count == 0
Expand All @@ -79,6 +80,7 @@ def test_upload_video_poster_not_image(client, mocker):
response = client.post(
f"/api/v1/video/{video_object.id}/thumbnail",
files={"file": ("file.jpg", b"file_content", "text/plain")},
auth=("admin", "password"),
)

assert service_mock.create_thumbnail.call_count == 0
Expand All @@ -94,6 +96,7 @@ def test_upload_video_poster(client, mocker):
response = client.post(
f"/api/v1/video/{video_object.id}/thumbnail",
files={"file": ("file.jpg", b"file_content", "image/jpeg")},
auth=("admin", "password"),
)

assert service_mock.create_thumbnails.call_count == 1
Expand Down
37 changes: 37 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pytest
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasicCredentials
from fastapi.testclient import TestClient

from src.security import authorize


@pytest.fixture
def client():
app = FastAPI()

@app.get("/secure-endpoint")
def secure_endpoint(credentials: HTTPBasicCredentials = Depends(authorize)):
return {"message": "You are authorized"}

return TestClient(app)


def test_authorize_correct_credentials(mocker, client):
credentials = HTTPBasicCredentials(username="admin", password="password")
mocker.patch("src.security.security", return_value=credentials)

response = client.get("/secure-endpoint", auth=("admin", "password"))

assert response.status_code == 200
assert response.json() == {"message": "You are authorized"}


def test_authorize_incorrect_credentials(mocker, client):
credentials = HTTPBasicCredentials(username="admin", password="password")
mocker.patch("src.security.security", return_value=credentials)

response = client.get("/secure-endpoint", auth=("wrong", "wrong"))

assert response.status_code == 401
assert response.json() == {"detail": "Incorrect username or password"}
8 changes: 8 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from src.settings import Settings


def test_settings_default_values():
settings = Settings()
assert settings.server_base_path == "./assets/"
assert settings.username == "admin"
assert settings.password == "password"