Skip to content

Commit b3dd766

Browse files
authored
Add basic auth (#76)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced security by introducing basic authentication for member and video management actions. - **Tests** - Added tests to validate the functionality of security measures and default values in settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 880b06d commit b3dd766

File tree

9 files changed

+146
-21
lines changed

9 files changed

+146
-21
lines changed

poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/routers/member.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
11
"""Member endpoints."""
22

33
import re
4+
from typing import Annotated
45
from uuid import UUID
56

6-
from fastapi import APIRouter, Response, UploadFile, status
7+
from fastapi import APIRouter, Depends, Response, UploadFile, status
78

89
from ..models.member import Member
10+
from ..security import authorize
911
from ..services.member import MemberService
1012

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

1416

1517
@router.post("", response_model=Member)
16-
def create_member_folder(member: Member):
18+
def create_member_folder(
19+
member: Member,
20+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
21+
):
1722
"""
1823
Create a folder structure for a member and return the member object.
1924
:param member: Member object
25+
:param authorized: fastapi dependency to authorize the request
2026
:return: 200 and the original member object
2127
"""
2228
service.create_folder_structure(member)
2329
return member
2430

2531

2632
@router.put("", response_model=Member)
27-
def update_member_folder(member: Member):
33+
def update_member_folder(
34+
member: Member,
35+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
36+
):
2837
"""
2938
Update the folder structure for a member and return the member object.
3039
If the member does not exist, return a 404.
3140
:param member: Member object
41+
:param authorized: fastapi dependency to authorize the request
3242
:return: 200 and the original member object
3343
"""
3444
if not service.to_id_path(member.id).exists():
@@ -38,14 +48,19 @@ def update_member_folder(member: Member):
3848

3949

4050
@router.post("/{member_id}/profilePicture", response_model=UUID)
41-
async def upload_member_picture(member_id: UUID, file: UploadFile):
51+
async def upload_member_picture(
52+
member_id: UUID,
53+
file: UploadFile,
54+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
55+
):
4256
"""
4357
Upload a picture for a member to convert
4458
and store the profile picture in different formats
4559
If the member does not exist, return a 404.
4660
If the file is not an image, return a 500.
4761
:param member_id: the id of the member
4862
:param file: the image file
63+
:param authorized: fastapi dependency to authorize the request
4964
:return: 200 and the original member_id
5065
"""
5166
# pylint: disable=duplicate-code

src/routers/video.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
11
"""Video endpoints"""
22

33
import re
4+
from typing import Annotated
45
from uuid import UUID
56

6-
from fastapi import APIRouter, Response, UploadFile, status
7+
from fastapi import APIRouter, Depends, Response, UploadFile, status
78

89
from ..models.video import Video
10+
from ..security import authorize
911
from ..services.video import VideoService
1012

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

1416

1517
@router.post("", response_model=Video)
16-
def create_video_folder(video: Video):
18+
def create_video_folder(
19+
video: Video,
20+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
21+
):
1722
"""
1823
Create a folder structure for a video and return the video object.
1924
:param video: Video object
25+
:param authorized: fastapi dependency to authorize the request
2026
:return: 200 and the original video object
2127
"""
2228
service.create_folder_structure(video)
2329
return video
2430

2531

2632
@router.put("", response_model=Video)
27-
def update_video_folder(video: Video):
33+
def update_video_folder(
34+
video: Video,
35+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
36+
):
2837
"""
2938
Update the folder structure for a video and return the video object.
3039
If the video does not exist, return a 404.
3140
:param video: Video object
41+
:param authorized: fastapi dependency to authorize the request
3242
:return: 200 and the original video object
3343
"""
3444
if not service.to_id_path(video.id).exists():
@@ -38,14 +48,19 @@ def update_video_folder(video: Video):
3848

3949

4050
@router.post("/{video_id}/thumbnail", response_model=UUID)
41-
async def upload_video_poster(video_id: UUID, file: UploadFile):
51+
async def upload_video_poster(
52+
video_id: UUID,
53+
file: UploadFile,
54+
authorized: Annotated[None, Depends(authorize)], # pylint: disable=unused-argument
55+
):
4256
"""
4357
Upload a picture for a video thumbnail to convert
4458
and store the thumbnail in different formats
4559
If the video does not exist, return a 404.
4660
If the file is not an image, return a 500.
4761
:param video_id: the id of the video
4862
:param file: the image file
63+
:param authorized: fastapi dependency to authorize the request
4964
:return: 200 and the original video_id
5065
"""
5166
# pylint: disable=duplicate-code

src/security.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Security module for the FastAPI application."""
2+
3+
import secrets
4+
from typing import Annotated
5+
6+
from fastapi import Depends, HTTPException, status
7+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
8+
9+
from .settings import settings
10+
11+
security = HTTPBasic()
12+
13+
14+
def authorize(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
15+
"""
16+
Authorize the request with the correct username and password.
17+
The correct username and password are stored in the settings.
18+
:param credentials: the credentials from the request
19+
:return:
20+
"""
21+
current_username_bytes = credentials.username.encode("utf8")
22+
correct_username_bytes = settings.username.encode("utf8")
23+
is_correct_username = secrets.compare_digest(
24+
current_username_bytes, correct_username_bytes
25+
)
26+
current_password_bytes = credentials.password.encode("utf8")
27+
correct_password_bytes = settings.password.encode("utf8")
28+
is_correct_password = secrets.compare_digest(
29+
current_password_bytes, correct_password_bytes
30+
)
31+
if not (is_correct_username and is_correct_password):
32+
raise HTTPException(
33+
status_code=status.HTTP_401_UNAUTHORIZED,
34+
detail="Incorrect username or password",
35+
headers={"WWW-Authenticate": "Basic"},
36+
)

src/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ class Settings(BaseSettings):
77
"""Class for reading settings from environment variables."""
88

99
server_base_path: str = "./assets/"
10+
username: str = "admin"
11+
password: str = "password"
1012

1113

1214
settings = Settings()

tests/routers/test_member.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ def test_create_member_folder(client, mocker):
2222

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

25-
response = client.post("/api/v1/member", json=member_data)
25+
response = client.post(
26+
"/api/v1/member", json=member_data, auth=("admin", "password")
27+
)
2628

27-
assert service_mock.create_folder_structure.call_count == 1
28-
assert service_mock.create_folder_structure.call_args[0][0] == member_object
2929
assert response.status_code == 200
3030
assert response.json() == member_data
31+
assert service_mock.create_folder_structure.call_count == 1
32+
assert service_mock.create_folder_structure.call_args[0][0] == member_object
3133

3234

3335
def test_update_member_folder_no_id(client, mocker):
@@ -36,7 +38,9 @@ def test_update_member_folder_no_id(client, mocker):
3638

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

39-
response = client.put("/api/v1/member", json=member_data)
41+
response = client.put(
42+
"/api/v1/member", json=member_data, auth=("admin", "password")
43+
)
4044

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

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

52-
response = client.put("/api/v1/member", json=member_data)
56+
response = client.put(
57+
"/api/v1/member", json=member_data, auth=("admin", "password")
58+
)
5359

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

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

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

97106
assert service_mock.create_profile_picture.call_count == 1

tests/routers/test_video.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test_create_video_folder(client, mocker):
2424

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

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

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

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

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

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

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

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

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

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

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

99102
assert service_mock.create_thumbnails.call_count == 1

tests/test_security.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
from fastapi import Depends, FastAPI
3+
from fastapi.security import HTTPBasicCredentials
4+
from fastapi.testclient import TestClient
5+
6+
from src.security import authorize
7+
8+
9+
@pytest.fixture
10+
def client():
11+
app = FastAPI()
12+
13+
@app.get("/secure-endpoint")
14+
def secure_endpoint(credentials: HTTPBasicCredentials = Depends(authorize)):
15+
return {"message": "You are authorized"}
16+
17+
return TestClient(app)
18+
19+
20+
def test_authorize_correct_credentials(mocker, client):
21+
credentials = HTTPBasicCredentials(username="admin", password="password")
22+
mocker.patch("src.security.security", return_value=credentials)
23+
24+
response = client.get("/secure-endpoint", auth=("admin", "password"))
25+
26+
assert response.status_code == 200
27+
assert response.json() == {"message": "You are authorized"}
28+
29+
30+
def test_authorize_incorrect_credentials(mocker, client):
31+
credentials = HTTPBasicCredentials(username="admin", password="password")
32+
mocker.patch("src.security.security", return_value=credentials)
33+
34+
response = client.get("/secure-endpoint", auth=("wrong", "wrong"))
35+
36+
assert response.status_code == 401
37+
assert response.json() == {"detail": "Incorrect username or password"}

tests/test_settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from src.settings import Settings
2+
3+
4+
def test_settings_default_values():
5+
settings = Settings()
6+
assert settings.server_base_path == "./assets/"
7+
assert settings.username == "admin"
8+
assert settings.password == "password"

0 commit comments

Comments
 (0)