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
No known key found for this signature in database
GPG Key ID: 894C6A5801E01CA9
5 changed files with 123 additions and 1 deletions

View File

@ -10,7 +10,8 @@
### Development workflow ### Development workflow
- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root. - Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root to get into the backend python virtual environment.
- Run tests, lints, formats with `pdm run {test, lint, format}`.
### Dependencies ### Dependencies
@ -40,7 +41,26 @@
### Backend Technical Write-up ### Backend Technical Write-up
#### Quart specific terminologies
`blueprint`: a collection of route handlers/API functionalities.
#### API route trailing slashes
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected.
#### Difference between database schema and database model #### Difference between database schema and database model
- A schema defines the structure of data within the database. - A schema defines the structure of data within the database.
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member. - A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
#### Managing user's sessions (Authentication)
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request.
- Logout results in the cookie being deleted.
#### Idempotent routes
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.

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-base = "quart --app src/backend/run.py recreate_db"
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"} recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.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 # Each blueprint is a logical collection of features in our web app
from backend.blueprints.control import blueprint as control_blueprint 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 # For making sure error responses are in JSON format
from backend.lib.api_error import APIError from backend.lib.api_error import APIError
@ -36,7 +37,9 @@ rate_limiter: RateLimiter = RateLimiter(app)
schema = QuartSchema(app, convert_casing=True) schema = QuartSchema(app, convert_casing=True)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
# registers these groups of routes handlers
app.register_blueprint(control_blueprint) app.register_blueprint(control_blueprint)
app.register_blueprint(sessions_blueprint)
# rate limiting # 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