Feat(API + Docs): Handle user sessions (#5)
- Improved documentation - Wrote tests for user sessions/authentications: including session flow(login, status, logout)
This commit is contained in:
@@ -77,3 +77,5 @@ start = {cmd = "quart --app src/backend/run.py run --port 5050", env_file = "dev
|
||||
recreate-db-base = "quart --app src/backend/run.py recreate_db"
|
||||
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
|
||||
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
|
||||
|
||||
all = {composite = ["lint", "format", "test"]}
|
||||
|
73
backend/src/backend/blueprints/sessions.py
Normal file
73
backend/src/backend/blueprints/sessions.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from bcrypt import checkpw
|
||||
from pydantic import EmailStr
|
||||
from quart import Blueprint, ResponseReturnValue, g
|
||||
from quart_auth import AuthUser, current_user, login_required, login_user, logout_user
|
||||
from quart_rate_limiter import rate_exempt, rate_limit
|
||||
from quart_schema import validate_request, validate_response
|
||||
|
||||
from backend.lib.api_error import APIError
|
||||
from backend.models.member import select_member_by_email
|
||||
|
||||
# Blueprint for user session (authentication) route handlers
|
||||
blueprint = Blueprint("sessions", __name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginData:
|
||||
email: EmailStr
|
||||
password: str
|
||||
remember: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
member_id: int
|
||||
|
||||
|
||||
@blueprint.post("/sessions/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@validate_request(LoginData)
|
||||
async def login(data: LoginData) -> ResponseReturnValue:
|
||||
"""
|
||||
Login to the app.
|
||||
|
||||
By providing credentials and then saving the returned cookie.
|
||||
"""
|
||||
result = await select_member_by_email(g.connection, data.email)
|
||||
|
||||
if result is None:
|
||||
raise APIError(401, "INVALID_CREDENTIALS")
|
||||
|
||||
passwords_match = checkpw(
|
||||
data.password.encode("utf_8"),
|
||||
result.password_hash.encode("utf_8"),
|
||||
)
|
||||
|
||||
if passwords_match:
|
||||
login_user(AuthUser(str(result.id)), data.remember)
|
||||
return {}, 200
|
||||
|
||||
else:
|
||||
raise APIError(401, "INVALID_CREDENTIALS")
|
||||
|
||||
|
||||
@blueprint.delete("/sessions/")
|
||||
@rate_exempt
|
||||
async def logout() -> ResponseReturnValue:
|
||||
"""Logout from the app.
|
||||
Deletes the session cookie.
|
||||
"""
|
||||
logout_user()
|
||||
return {}
|
||||
|
||||
|
||||
@blueprint.get("/sessions/")
|
||||
@rate_limit(10, timedelta(minutes=1))
|
||||
@login_required
|
||||
@validate_response(Status)
|
||||
async def status() -> ResponseReturnValue:
|
||||
assert current_user.auth_id is not None # nosec
|
||||
return Status(member_id=int(current_user.auth_id))
|
@@ -20,6 +20,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
|
||||
|
||||
# Each blueprint is a logical collection of features in our web app
|
||||
from backend.blueprints.control import blueprint as control_blueprint
|
||||
from backend.blueprints.sessions import blueprint as sessions_blueprint
|
||||
|
||||
# For making sure error responses are in JSON format
|
||||
from backend.lib.api_error import APIError
|
||||
@@ -36,7 +37,9 @@ rate_limiter: RateLimiter = RateLimiter(app)
|
||||
schema = QuartSchema(app, convert_casing=True)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# registers these groups of routes handlers
|
||||
app.register_blueprint(control_blueprint)
|
||||
app.register_blueprint(sessions_blueprint)
|
||||
|
||||
|
||||
# rate limiting
|
||||
|
24
backend/tests/blueprints/test_sessions.py
Normal file
24
backend/tests/blueprints/test_sessions.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from quart import Quart
|
||||
|
||||
|
||||
async def test_session_flow(app: Quart) -> None:
|
||||
test_client = app.test_client()
|
||||
await test_client.post(
|
||||
"/sessions/",
|
||||
json={"email": "member@todo.minhtrannhat.com", "password": "password"},
|
||||
)
|
||||
response = await test_client.get("/sessions/")
|
||||
assert (await response.get_json())["memberId"] == 1
|
||||
await test_client.delete("/sessions/")
|
||||
response = await test_client.get("/sessions/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_login_invalid_password(app: Quart) -> None:
|
||||
test_client = app.test_client()
|
||||
await test_client.post(
|
||||
"/sessions/",
|
||||
json={"email": "member@todo.minhtrannhat.com", "password": "incorrect"},
|
||||
)
|
||||
response = await test_client.get("/sessions/")
|
||||
assert response.status_code == 401
|
Reference in New Issue
Block a user