Feat(API + Docs): Handle user sessions

- Improved documentation
- Wrote tests for user sessions/authentications: including session
  flow(login, status, logout)
This commit is contained in:
minhtrannhat
2022-12-24 22:01:23 -05:00
parent 8a695f2efb
commit f129c6884e
5 changed files with 123 additions and 1 deletions

View File

@@ -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"]}

View 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))

View File

@@ -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

View 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