Compare commits

...

10 Commits

Author SHA1 Message Date
fe23b07e4e
Feat(docs): Added more documentation on backend 2023-02-20 16:50:39 -05:00
minhtrannhat
3c78fe9133 Feat(API): members CRUD functionalities
- Added tests for members flow (creating/deleting/reset)
- Various fixes to tooling
2022-12-26 14:59:32 -05:00
Minh Tran Nhat
026be1328e
Feat(API + Docs): Handle user sessions (#5)
- Improved documentation
- Wrote tests for user sessions/authentications: including session
  flow(login, status, logout)
2022-12-25 03:05:01 +00:00
Minh Tran Nhat
0c42cf5620
Merge pull request #4 from minhtrannhat/backend_quartz
Fix(API): Stop mypy from complaining
2022-12-23 20:19:40 -05:00
minhtrannhat
8a695f2efb
Fix(API): Stop mypy from complaining 2022-12-23 20:18:39 -05:00
minhtrannhat
c641ddd47d
Feat(API): Database schema and models defined
- Wrote tests for database migrations and populate with test data
2022-12-23 20:07:36 -05:00
minhtrannhat
ad5596c61c
Fix(API): Added email template
- Fix the missing logging library
2022-12-21 10:59:46 -05:00
minhtrannhat
8ef0ccf32e
Fix(Frontend): Security vulnerability 2022-12-15 10:08:06 -05:00
minhtrannhat
574a9d17dd
Fix(Frontend): Update packages
- Audit security vulnerabilities
2022-12-15 10:03:32 -05:00
minhtrannhat
61bac977ff
Feat & Fix(API): Sending emails to users
- Send emails to users when they register or when they reset their
  password.
- Fix `.gitignore` not recoginizing changes in `lib` folder
2022-12-15 09:45:16 -05:00
34 changed files with 12167 additions and 27269 deletions

View File

@ -2,37 +2,46 @@
## Frontend
### Development dependencies
### Development Workflow
- `prettier`: Formatter
- Format, lint and test with `npm run {format, lint, test}`.
### Dependencies
- React
- React helment async: manage changes to document head.
- Material UI
- Roboto font
## Backend
### 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
#### Python dependencies
- `quart`: a micro-webframework, async version of Flask.
- `black`: Code formatter
- `isort`: Import formatter
- `mypy`: Type checking
- `flake8`: General Python bugs
- `vulture`: Find unused code in Python programs
- `pytest`: For testing (turbocharged with `async`)
- `black`: Code formatter.
- `isort`: Import formatter.
- `mypy`: Type checking.
- `flake8`: General Python bugs.
- `vulture`: Find unused code in Python programs.
- `pytest`: For testing (turbocharged with `async`).
- `bcrypt`: Hashing and salting password.
- `zxcvbn`: Test password strength.
- `freezegun`: Check for expired token.
- `quart-rate-limiter`: Rate limiting
- `pydantic` and `quart-schema`: Request/Response validation
- `quart-rate-limiter`: Rate limiting.
- `pydantic` and `quart-schema`: Request/Response validation.
- `httpx`: send HTTP POST requests from our app.
#### SQL Dev-deps
- `bandit`: Check for SQL injection vulnerabilities
- `bandit`: Check for SQL injection vulnerabilities.
#### Miscs Dev-deps
- `djhtml`: Generate jinja templates html for emails
- `djhtml`: Generate jinja templates html for emails.

1
backend/.gitignore vendored
View File

@ -14,7 +14,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

36
backend/README.md Normal file
View File

@ -0,0 +1,36 @@
# Backend Technical Write-up
## Steps
- Initial infrastructure
- Rate Limiting
- JSON error handling
- Req/Res schema validation
- Setup database
- Database schema/models
- Usersessions
- CRUD routes functionalities
## 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
- 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.
## 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

@ -2,6 +2,7 @@ TODO_BASE_URL="localhost:5050"
TODO_DEBUG=true
TODO_SECRET_KEY="secret key"
TODO_QUART_DB_DATABASE_URL="postgresql://todo:todo@0.0.0.0:5432/todo"
TODO_QUART_DB_DATA_PATH="migrations/data.py"
# disable for when we not using HTTPS
TODO_QUART_AUTH_COOKIE_SECURE=false

95
backend/pdm.lock generated
View File

@ -4,6 +4,16 @@ version = "22.1.0"
requires_python = ">=3.7,<4.0"
summary = "File support for asyncio."
[[package]]
name = "anyio"
version = "3.6.2"
requires_python = ">=3.6.2"
summary = "High level compatibility layer for multiple asynchronous event loop implementations"
dependencies = [
"idna>=2.8",
"sniffio>=1.1",
]
[[package]]
name = "asyncpg"
version = "0.27.0"
@ -59,6 +69,12 @@ version = "0.4"
requires_python = ">=3.6"
summary = "Query building for the postgresql prepared statements and asyncpg."
[[package]]
name = "certifi"
version = "2022.12.7"
requires_python = ">=3.6"
summary = "Python package for providing Mozilla's CA Bundle."
[[package]]
name = "click"
version = "8.1.3"
@ -161,6 +177,30 @@ version = "4.0.0"
requires_python = ">=3.6.1"
summary = "Pure-Python HPACK header compression"
[[package]]
name = "httpcore"
version = "0.16.2"
requires_python = ">=3.7"
summary = "A minimal low-level HTTP client."
dependencies = [
"anyio<5.0,>=3.0",
"certifi",
"h11<0.15,>=0.13",
"sniffio==1.*",
]
[[package]]
name = "httpx"
version = "0.23.1"
requires_python = ">=3.7"
summary = "The next generation HTTP client."
dependencies = [
"certifi",
"httpcore<0.17.0,>=0.15.0",
"rfc3986[idna2008]<2,>=1.3",
"sniffio",
]
[[package]]
name = "hypercorn"
version = "0.14.3"
@ -427,6 +467,21 @@ dependencies = [
"quart>=0.18.1",
]
[[package]]
name = "rfc3986"
version = "1.5.0"
summary = "Validating URI References per RFC 3986"
[[package]]
name = "rfc3986"
version = "1.5.0"
extras = ["idna2008"]
summary = "Validating URI References per RFC 3986"
dependencies = [
"idna",
"rfc3986==1.5.0",
]
[[package]]
name = "six"
version = "1.16.0"
@ -439,6 +494,12 @@ version = "5.0.0"
requires_python = ">=3.6"
summary = "A pure Python implementation of a sliding window memory map manager"
[[package]]
name = "sniffio"
version = "1.3.0"
requires_python = ">=3.7"
summary = "Sniff out which async library your code is running under"
[[package]]
name = "stevedore"
version = "4.1.1"
@ -493,15 +554,24 @@ dependencies = [
"h11<1,>=0.9.0",
]
[[package]]
name = "zxcvbn"
version = "4.4.28"
summary = ""
[metadata]
lock_version = "4.1"
content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c2474b52"
content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e"
[metadata.files]
"aiofiles 22.1.0" = [
{url = "https://files.pythonhosted.org/packages/86/26/6e5060a159a6131c430e8a01ec8327405a19a449a506224b394e36f2ebc9/aiofiles-22.1.0.tar.gz", hash = "sha256:9107f1ca0b2a5553987a94a3c9959fe5b491fdf731389aa5b7b1bd0733e32de6"},
{url = "https://files.pythonhosted.org/packages/a0/48/d5d1ab7cfe46e573c3694fa1365442a7d7cadc3abb03d8507e58a3755bb2/aiofiles-22.1.0-py3-none-any.whl", hash = "sha256:1142fa8e80dbae46bb6339573ad4c8c0841358f79c6eb50a493dceca14621bad"},
]
"anyio 3.6.2" = [
{url = "https://files.pythonhosted.org/packages/77/2b/b4c0b7a3f3d61adb1a1e0b78f90a94e2b6162a043880704b7437ef297cad/anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
{url = "https://files.pythonhosted.org/packages/8b/94/6928d4345f2bc1beecbff03325cad43d320717f51ab74ab5a571324f4f5a/anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
]
"asyncpg 0.27.0" = [
{url = "https://files.pythonhosted.org/packages/04/78/06b4979eb2b553a450fe38008353f5cba152a66de83c64b1639046e9ca0e/asyncpg-0.27.0.tar.gz", hash = "sha256:720986d9a4705dd8a40fdf172036f5ae787225036a7eb46e704c45aa8f62c054"},
{url = "https://files.pythonhosted.org/packages/05/46/250c9e277b77ccbfd1b1746c529fc61250280f854d686b2775f8e4d59be5/asyncpg-0.27.0-cp39-cp39-win32.whl", hash = "sha256:8934577e1ed13f7d2d9cea3cc016cc6f95c19faedea2c2b56a6f94f257cea672"},
@ -593,6 +663,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
{url = "https://files.pythonhosted.org/packages/31/5a/c5ecd08a0c9b4dfece3b41aeefc3770968b4a2da1784941c9c8dd1c65347/buildpg-0.4-py3-none-any.whl", hash = "sha256:20d539976c81ea6f5529d3930016b0482ed0ff06def3d6da79d0fc0a3bbaeeb1"},
{url = "https://files.pythonhosted.org/packages/48/f2/ff0e51a3c2390538da6eb4f85e30d87aafbcc6d057c6c9bb9fa222c8f2fc/buildpg-0.4.tar.gz", hash = "sha256:3a6c1f40fb6c826caa819d84727e36a1372f7013ba696637b492e5935916d479"},
]
"certifi 2022.12.7" = [
{url = "https://files.pythonhosted.org/packages/37/f7/2b1b0ec44fdc30a3d31dfebe52226be9ddc40cd6c0f34ffc8923ba423b69/certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
{url = "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
]
"click 8.1.3" = [
{url = "https://files.pythonhosted.org/packages/59/87/84326af34517fca8c58418d148f2403df25303e02736832403587318e9e8/click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
{url = "https://files.pythonhosted.org/packages/c2/f1/df59e28c642d583f7dacffb1e0965d0e00b218e0186d7858ac5233dce840/click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
@ -644,6 +718,14 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
{url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
{url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
]
"httpcore 0.16.2" = [
{url = "https://files.pythonhosted.org/packages/91/52/93f22e5441539256c0d113faf17e45284aee16eebdd95089e3ca6f480b18/httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"},
{url = "https://files.pythonhosted.org/packages/9b/20/26f6cc4fd00391f8f1c57b0020f5c6eec23904723db04b6f7608e222d815/httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"},
]
"httpx 0.23.1" = [
{url = "https://files.pythonhosted.org/packages/8a/df/a3e8b91dfb452e645ef110985a30f0915276a1a2144004c7671c07bb203c/httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
{url = "https://files.pythonhosted.org/packages/e1/74/cdce73069e021ad5913451b86c2707b027975cf302016ca557686d87eb41/httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
]
"hypercorn 0.14.3" = [
{url = "https://files.pythonhosted.org/packages/2e/e8/2a927f26a0cb994451b07622bf778ac6b3cb9fe45216e13feaeebb27c9b0/Hypercorn-0.14.3.tar.gz", hash = "sha256:4a87a0b7bbe9dc75fab06dbe4b301b9b90416e9866c23a377df21a969d6ab8dd"},
{url = "https://files.pythonhosted.org/packages/be/8b/e856965857137bc594e8fd52fef1d00b79913a6a01033cba772276aff10b/Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"},
@ -906,6 +988,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
{url = "https://files.pythonhosted.org/packages/dd/f3/f245c3bb75d0e166a87ff458ebc89489c210d235de5c50db4ef397295c79/quart-schema-0.14.3.tar.gz", hash = "sha256:c3105d61bf4fe9d8db30dc4fd9bdcc163fa739bf818c5d22537a89590b442fc7"},
{url = "https://files.pythonhosted.org/packages/ff/4d/8f646a8b7becb325311029fbc7b9d6bfd2dec7aab0ef675dffbcdba8efd1/quart_schema-0.14.3-py3-none-any.whl", hash = "sha256:538e4d2b7da93ad73cd0fa6301415c907f23ffb00d5772a8f802a18ba62cfc33"},
]
"rfc3986 1.5.0" = [
{url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
{url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
]
"six 1.16.0" = [
{url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
{url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@ -914,6 +1000,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
{url = "https://files.pythonhosted.org/packages/21/2d/39c6c57032f786f1965022563eec60623bb3e1409ade6ad834ff703724f3/smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
{url = "https://files.pythonhosted.org/packages/6d/01/7caa71608bc29952ae09b0be63a539e50d2484bc37747797a66a60679856/smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
]
"sniffio 1.3.0" = [
{url = "https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
{url = "https://files.pythonhosted.org/packages/cd/50/d49c388cae4ec10e8109b1b833fd265511840706808576df3ada99ecb0ac/sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
]
"stevedore 4.1.1" = [
{url = "https://files.pythonhosted.org/packages/66/c0/26afabea111a642f33cfd15f54b3dbe9334679294ad5c0423c556b75eba2/stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"},
{url = "https://files.pythonhosted.org/packages/74/a3/72792236f981aee8bb1ef15e72a6f65444150834830f6a97178fe1e2cdf4/stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"},
@ -942,3 +1032,6 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
{url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
{url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
]
"zxcvbn 4.4.28" = [
{url = "https://files.pythonhosted.org/packages/54/67/c6712608c99e7720598e769b8fb09ebd202119785adad0bbce25d330243c/zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"},
]

View File

@ -16,6 +16,8 @@ dependencies = [
"pydantic[email]>=1.10.2",
"quart-schema>=0.14.3",
"quart-db[postgresql]>=0.4.1",
"httpx>=0.23.1",
"zxcvbn>=4.4.28",
]
[project.optional-dependencies]
@ -52,6 +54,10 @@ addopts = "--showlocals"
asyncio_mode = "auto"
pythonpath = ["src"]
[[tool.mypy.overrides]]
module =["h11"]
ignore_missing_imports = true
[tool.pdm.scripts]
format-black = "black src/ tests/"
format-djhtml = "djhtml src/backend/templates -t 2 --in-place"
@ -72,3 +78,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 = ["format", "lint", "test"]}

View File

@ -1,7 +1,6 @@
from quart import Blueprint, ResponseReturnValue
from quart_rate_limiter import rate_exempt
blueprint = Blueprint("control", __name__)

View File

@ -0,0 +1,217 @@
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
import asyncpg # type: ignore
import bcrypt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from pydantic import EmailStr
from quart import Blueprint, ResponseReturnValue, current_app, g
from quart_auth import current_user, login_required
from quart_rate_limiter import rate_limit
from quart_schema import validate_request
from zxcvbn import zxcvbn # type: ignore
from backend.lib.api_error import APIError
from backend.lib.email import send_email
from backend.models.member import (
insert_member,
select_member_by_email,
select_member_by_id,
update_member_email_verified,
update_member_password,
)
blueprint = Blueprint("members", __name__)
MINIMUM_STRENGTH = 3
EMAIL_VERIFICATION_SALT = "email verify"
ONE_MONTH = int(timedelta(days=30).total_seconds())
FORGOTTEN_PASSWORD_SALT = "forgotten password" # nosec
ONE_DAY = int(timedelta(hours=24).total_seconds())
@dataclass
class MemberData:
email: str
password: str
@blueprint.post("/members/")
@rate_limit(10, timedelta(seconds=10))
@validate_request(MemberData)
async def register(data: MemberData) -> ResponseReturnValue:
"""Create a new Member.
This allows a Member to be created.
"""
strength = zxcvbn(data.password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
hashed_password = bcrypt.hashpw(
data.password.encode("utf-8"),
bcrypt.gensalt(14),
)
try:
member = await insert_member(
g.connection,
data.email,
hashed_password.decode(),
)
except asyncpg.exceptions.UniqueViolationError:
pass
else:
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
)
token = serializer.dumps(member.id)
await send_email(
member.email,
"Welcome",
"welcome.html",
{"token": token},
)
return {}, 201
@dataclass
class TokenData:
token: str
@blueprint.put("/members/email/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(TokenData)
async def verify_email(data: TokenData) -> ResponseReturnValue:
"""Call to verify an email.
This requires the user to supply a valid token.
"""
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
)
try:
member_id = serializer.loads(data.token, max_age=ONE_MONTH)
except SignatureExpired:
raise APIError(403, "TOKEN_EXPIRED")
except BadSignature:
raise APIError(400, "TOKEN_INVALID")
else:
await update_member_email_verified(g.connection, member_id)
return {}
@dataclass
class PasswordData:
current_password: str
new_password: str
@blueprint.put("/members/password/")
@rate_limit(5, timedelta(minutes=1))
@login_required
@validate_request(PasswordData)
async def change_password(data: PasswordData) -> ResponseReturnValue:
"""Update the members password.
This allows the user to update their password.
"""
strength = zxcvbn(data.new_password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
member_id = int(cast(str, current_user.auth_id))
member = await select_member_by_id(g.connection, member_id)
assert member is not None # nosec
passwords_match = bcrypt.checkpw(
data.current_password.encode("utf-8"),
member.password_hash.encode("utf-8"),
)
if not passwords_match:
raise APIError(401, "INVALID_PASSWORD")
hashed_password = bcrypt.hashpw(
data.new_password.encode("utf-8"),
bcrypt.gensalt(14),
)
await update_member_password(g.connection, member_id, hashed_password.decode())
await send_email(
member.email,
"Password changed",
"password_changed.html",
{},
)
return {}
@dataclass
class ForgottenPasswordData:
email: EmailStr
@blueprint.put("/members/forgotten-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ForgottenPasswordData)
async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
"""Call to trigger a forgotten password email.
This requires a valid member email.
"""
member = await select_member_by_email(g.connection, data.email)
if member is not None:
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
)
token = serializer.dumps(member.id)
await send_email(
member.email,
"Forgotten password",
"forgotten_password.html",
{"token": token},
)
return {}
@dataclass
class ResetPasswordData:
password: str
token: str
@blueprint.put("/members/reset-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ResetPasswordData)
async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
"""Call to reset a password using a token.
This requires the user to supply a valid token and a
new password.
"""
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
)
try:
member_id = serializer.loads(data.token, max_age=ONE_DAY)
except SignatureExpired:
raise APIError(403, "TOKEN_EXPIRED")
except BadSignature:
raise APIError(400, "TOKEN_INVALID")
else:
strength = zxcvbn(data.password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
hashed_password = bcrypt.hashpw(
data.password.encode("utf-8"),
bcrypt.gensalt(14),
)
await update_member_password(g.connection, member_id, hashed_password.decode())
member = await select_member_by_id(
g.connection, int(cast(str, current_user.auth_id))
)
assert member is not None # nosec
await send_email(
member.email,
"Password changed",
"password_changed.html",
{},
)
return {}

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

View File

@ -0,0 +1,4 @@
class APIError(Exception):
def __init__(self, status_code: int, code: str) -> None:
self.status_code = status_code
self.code = code

View File

@ -0,0 +1,40 @@
import logging
from typing import Any, cast
import httpx
from quart import current_app, render_template
log = logging.getLogger(__name__)
class PostmarkError(Exception):
def __init__(self, error_code: int, message: str) -> None:
self.error_code = error_code
self.message = message
async def send_email(
to: str,
subject: str,
template: str,
ctx: dict[str, Any],
) -> None:
content = await render_template(template, **ctx)
log.info("Sending %s to %s\n%s", template, to, content)
token = current_app.config.get("POSTMARK_TOKEN")
if token is not None:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.postmarkapp.com/email",
json={
"From": "Todo <todo@minhtrannhat.com>",
"To": to,
"Subject": subject,
"Tag": template,
"HtmlBody": content,
},
headers={"X-Postmark-Server-Token": token},
)
data = cast(dict, response.json())
if response.status_code != 200:
raise PostmarkError(data["ErrorCode"], data["Message"])

View File

@ -0,0 +1,31 @@
from quart_db import Connection
async def migrate(connection: Connection) -> None:
await connection.execute(
"""CREATE TABLE members (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created TIMESTAMP NOT NULL DEFAULT now(),
email TEXT NOT NULL,
email_verified TIMESTAMP,
password_hash TEXT NOT NULL
)""",
)
await connection.execute(
"""CREATE UNIQUE INDEX members_unique_email_idx
ON members (LOWER(email)
)"""
)
await connection.execute(
"""CREATE TABLE todos (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
complete BOOLEAN NOT NULL DEFAULT FALSE,
due TIMESTAMPTZ,
member_id INT NOT NULL REFERENCES members(id),
task TEXT NOT NULL
)""",
)
async def valid_migration(connection: Connection) -> bool:
return True

View File

@ -0,0 +1,14 @@
from quart_db import Connection
async def execute(connection: Connection) -> None:
await connection.execute(
"""INSERT INTO members (email, password_hash)
VALUES ('member@todo.minhtrannhat.com',
'$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe'
)"""
)
await connection.execute(
"""INSERT INTO todos (member_id, task)
VALUES (1, 'Test Task')"""
)

View File

View File

@ -0,0 +1,60 @@
from dataclasses import dataclass
from datetime import datetime
from quart_db import Connection
@dataclass
class Member:
id: int
email: str
password_hash: str
created: datetime
email_verified: datetime | None
async def select_member_by_email(db: Connection, email: str) -> Member | None:
result = await db.fetch_one(
"""SELECT id, email, password_hash, created, email_verified
FROM members
WHERE LOWER(email) = LOWER(:email)""",
{"email": email},
)
return None if result is None else Member(**result)
async def select_member_by_id(db: Connection, id: int) -> Member | None:
result = await db.fetch_one(
"""SELECT id, email, password_hash, created, email_verified
FROM members
WHERE id = :id""",
{"id": id},
)
return None if result is None else Member(**result)
async def insert_member(db: Connection, email: str, password_hash: str) -> Member:
result = await db.fetch_one(
"""INSERT INTO members (email, password_hash)
VALUES (:email, :password_hash)
RETURNING id, email, password_hash, created,
email_verified""",
{"email": email, "password_hash": password_hash},
)
return Member(**result) # type: ignore
async def update_member_password(db: Connection, id: int, password_hash: str) -> None:
await db.execute(
"""UPDATE members
SET password_hash = :password_hash
WHERE id = :id""",
{"id": id, "password_hash": password_hash},
)
async def update_member_email_verified(db: Connection, id: int) -> None:
await db.execute(
"UPDATE members SET email_verified = now() WHERE id = :id",
{"id": id},
)

View File

@ -0,0 +1,102 @@
from dataclasses import dataclass
from datetime import datetime
from pydantic import constr
from quart_db import Connection
@dataclass
class Todo:
complete: bool
due: datetime | None
id: int
task: constr(strip_whitespace=True, min_length=1) # type: ignore
async def select_todos(
connection: Connection,
member_id: int,
complete: bool | None = None,
) -> list[Todo]:
if complete is None:
query = """SELECT id, complete, due, task
FROM todos
WHERE member_id = :member_id"""
values = {"member_id": member_id}
else:
query = """SELECT id, complete, due, task
FROM todos
WHERE member_id = :member_id
AND complete = :complete"""
values = {"member_id": member_id, "complete": complete}
return [Todo(**row) async for row in await connection.iterate(query, values)]
async def select_todo(
connection: Connection,
id: int,
member_id: int,
) -> Todo | None:
result = await connection.fetch_one(
"""SELECT id, complete, due, task
FROM todos
WHERE id = :id AND member_id = :member_id""",
{"id": id, "member_id": member_id},
)
return None if result is None else Todo(**result)
async def insert_todo(
connection: Connection,
member_id: int,
task: str,
complete: bool,
due: datetime | None,
) -> Todo:
result = await connection.fetch_one(
"""INSERT INTO todos (complete, due, member_id, task)
VALUES (:complete, :due, :member_id, :task)
RETURNING id, complete, due, task""",
{
"member_id": member_id,
"task": task,
"complete": complete,
"due": due,
},
)
return Todo(**result) # type: ignore
async def update_todo(
connection: Connection,
id: int,
member_id: int,
task: str,
complete: bool,
due: datetime | None,
) -> Todo | None:
result = await connection.fetch_one(
"""UPDATE todos
SET complete = :complete, due = :due, task = :task
WHERE id = :id AND member_id = :member_id
RETURNING id, complete, due, task""",
{
"id": id,
"member_id": member_id,
"task": task,
"complete": complete,
"due": due,
},
)
return None if result is None else Todo(**result)
async def delete_todo(
connection: Connection,
id: int,
member_id: int,
) -> None:
await connection.execute(
"DELETE FROM todos WHERE id = :id AND member_id = :member_id",
{"id": id, "member_id": member_id},
)

View File

@ -1,28 +1,30 @@
from quart import Quart, ResponseReturnValue
# logging
import logging
import os
from subprocess import call # no sec
from subprocess import call # nosec
from urllib.parse import urlparse
# Each blueprint is a logical collection of features in our web app
from backend.blueprints.control import blueprint as control_blueprint
# For making sure error responses are in JSON format
from backend.lib.api_error import APIError
# Rate limiting
from quart_rate_limiter import RateLimiter
from quart_rate_limiter import RateLimitExceeded
from quart import Quart, ResponseReturnValue
# Authentication
from quart_auth import AuthManager
# Request/Response validation
from quart_schema import QuartSchema, RequestSchemaValidationError
# PostgreSQL database driver
from quart_db import QuartDB
# Rate limiting
from quart_rate_limiter import RateLimiter, RateLimitExceeded
# Request/Response validation
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.members import blueprint as members_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
app: Quart = Quart(__name__)
@ -34,9 +36,12 @@ auth_manager: AuthManager = AuthManager(app)
quart_db = QuartDB(app)
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)
app.register_blueprint(members_blueprint)
# rate limiting

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<title>Todo - email</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body
style="
font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
font-size: 14px;
font-style: normal;
margin: 0;
"
>
<table
width="100%"
height="100%"
cellpadding="0"
cellspacing="0"
border="0"
>
<tr>
<td align="center">
<table
height="100%"
cellpadding="20"
cellspacing="0"
border="0"
style="max-width: 540px"
>
<tr>
<td align="left" width="540">
{% block welcome %} Hello, {% endblock %}
</td>
</tr>
<tr>
<td align="left" width="540">
{% block content %} Example content {% endblock %}
</td>
</tr>
<tr>
<td align="center" width="540">The Todo team</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,9 @@
{% extends "email.html" %}
{% block content %}
You can use this
<a href="{{ config['BASE_URL'] }}/reset-password/{{ token }}/">
link
</a>
to reset your password.
{% endblock %}

View File

@ -0,0 +1,6 @@
{% extends "email.html" %}
{% block content %}
Your Todo password has been successfully changed.
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "email.html" %}
{% block welcome %}
Hello and welcome to tozo!
{% endblock %}
{% block content %}
Please confirm you signed up by following this
<a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
link
</a>.
{% endblock %}

View File

@ -3,6 +3,7 @@ TODO_DEBUG=true
TODO_SECRET_KEY="secret key"
TODO_TESTING=true
TODO_QUART_DB_DATABASE_URL="postgresql://todo_test:todo_test@0.0.0.0:5432/todo_test"
TODO_QUART_DB_DATA_PATH="migrations/data.py"
# disable for when we not using HTTPS
TODO_QUART_AUTH_COOKIE_SECURE=false

View File

View File

@ -0,0 +1,66 @@
import pytest
from freezegun import freeze_time
from itsdangerous import URLSafeTimedSerializer
from quart import Quart
from backend.blueprints.members import EMAIL_VERIFICATION_SALT
async def test_register(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {
"email": "new@todo.minhtrannhat.com",
"password": "testPassword2$",
}
await test_client.post("/members/", json=data)
response = await test_client.post("/sessions/", json=data)
assert response.status_code == 200
assert "Sending welcome.html to new@todo.minhtrannhat.com" in caplog.text
@pytest.mark.parametrize(
"time, expected",
[("2022-01-01", 403), (None, 200)],
)
async def test_verify_email(app: Quart, time: str | None, expected: int) -> None:
with freeze_time(time):
signer = URLSafeTimedSerializer(app.secret_key, salt=EMAIL_VERIFICATION_SALT)
token = signer.dumps(1)
test_client = app.test_client()
response = await test_client.put("/members/email/", json={"token": token})
assert response.status_code == expected
async def test_verify_email_invalid_token(app: Quart) -> None:
test_client = app.test_client()
response = await test_client.put("/members/email/", json={"token": "invalid"})
assert response.status_code == 400
async def test_change_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {
"email": "new_password@todo.minhtrannhat.com",
"password": "testPassword2$",
}
response = await test_client.post("/members/", json=data)
async with test_client.authenticated("2"): # type: ignore
response = await test_client.put(
"/members/password/",
json={
"currentPassword": data["password"],
"newPassword": "testPassword3$",
},
)
assert response.status_code == 200
assert "Sending password_changed.html to new@todo.minhtrannhat.com" in caplog.text
async def test_forgotten_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {"email": "member@todo.minhtrannhat.com"}
response = await test_client.put("/members/forgotten-password/", json=data)
assert response.status_code == 200
assert (
"Sending forgotten_password.html to member@todo.minhtrannhat.com" in caplog.text
)

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

View File

View File

@ -0,0 +1,10 @@
from pytest import LogCaptureFixture
from quart import Quart
from backend.lib.email import send_email
async def test_send_email(app: Quart, caplog: LogCaptureFixture) -> None:
async with app.app_context():
await send_email("member@minhtrannhat.com", "Welcome", "email.html", {})
assert "Sending email.html to member@minhtrannhat.com" in caplog.text

View File

View File

@ -0,0 +1,17 @@
import pytest
from asyncpg.exceptions import UniqueViolationError # type: ignore
from quart_db import Connection
from backend.models.member import insert_member, select_member_by_email
async def test_insert_member(connection: Connection) -> None:
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
with pytest.raises(UniqueViolationError):
await insert_member(connection, "Casing@todo.minhtrannhat.com", "")
async def test_select_member_by_email(connection: Connection) -> None:
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
member = await select_member_by_email(connection, "Casing@todo.minhtrannhat.com")
assert member is not None

View File

@ -0,0 +1,31 @@
import pytest
from quart_db import Connection
from backend.models.todo import delete_todo, insert_todo, select_todo, update_todo
@pytest.mark.parametrize(
"member_id, deleted",
[(1, True), (2, False)],
)
async def test_delete_todo(
connection: Connection, member_id: int, deleted: bool
) -> None:
todo = await insert_todo(connection, 1, "Task", False, None)
await delete_todo(connection, todo.id, member_id)
new_todo = await select_todo(connection, todo.id, 1)
assert (new_todo is None) is deleted
@pytest.mark.parametrize(
"member_id, complete",
[(1, True), (2, False)],
)
async def test_update_todo(
connection: Connection, member_id: int, complete: bool
) -> None:
todo = await insert_todo(connection, 1, "Task", False, None)
await update_todo(connection, todo.id, member_id, "Task", True, None)
new_todo = await select_todo(connection, todo.id, 1)
assert new_todo is not None
assert new_todo.complete is complete

38807
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,10 @@
"@types/react-dom": "^18.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^2.1.3",
"react-scripts": "^5.0.1",
"typescript": "^4.7.4",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"nth-check": ">=2.0.1"
},
"scripts": {
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
@ -48,7 +49,7 @@
]
},
"devDependencies": {
"eslint": "^8.20.0",
"eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"prettier": "^2.7.1",