diff options
-rw-r--r-- | app/app.py | 17 | ||||
-rw-r--r-- | app/app_account.py | 87 | ||||
-rw-r--r-- | app/app_database.py | 43 | ||||
-rw-r--r-- | app/app_sessions.py | 48 | ||||
-rw-r--r-- | app/app_wallet.py | 95 | ||||
-rw-r--r-- | readme.md | 24 | ||||
-rw-r--r-- | sql/queries.sql | 9 |
7 files changed, 322 insertions, 1 deletions
diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..3bf0337 --- /dev/null +++ b/app/app.py | |||
@@ -0,0 +1,17 @@ | |||
1 | # UGE / L2 / Intro to relational databases / Python project prototype | ||
2 | # Author: Pacien TRAN-GIRARD | ||
3 | # Licence: EUPL-1.2 | ||
4 | |||
5 | from fastapi import FastAPI, status | ||
6 | import app_sessions | ||
7 | import app_account | ||
8 | import app_wallet | ||
9 | |||
10 | main = FastAPI() | ||
11 | |||
12 | # Add sessions based on signed cookies. | ||
13 | main.add_middleware(app_sessions.SessionManager) | ||
14 | |||
15 | # Register our request handlers | ||
16 | main.include_router(app_account.router) | ||
17 | main.include_router(app_wallet.router) | ||
diff --git a/app/app_account.py b/app/app_account.py new file mode 100644 index 0000000..3f4869d --- /dev/null +++ b/app/app_account.py | |||
@@ -0,0 +1,87 @@ | |||
1 | # UGE / L2 / Intro to relational databases / Python project prototype | ||
2 | # Author: Pacien TRAN-GIRARD | ||
3 | # Licence: EUPL-1.2 | ||
4 | |||
5 | from fastapi import APIRouter, Depends, Request, Form, status | ||
6 | |||
7 | from passlib.context import CryptContext | ||
8 | import re | ||
9 | |||
10 | from embrace.exceptions import IntegrityError | ||
11 | from psycopg2.errors import UniqueViolation | ||
12 | |||
13 | from app_sessions import UserSession | ||
14 | from app_database import db_transaction | ||
15 | |||
16 | |||
17 | # Password hashing context. | ||
18 | # Handles proper salting and migration automatically. | ||
19 | password_ctx = CryptContext(schemes=['bcrypt'], deprecated='auto') | ||
20 | |||
21 | username_pattern = re.compile(r'^[a-zA-Z0-9-_]{4,16}$') | ||
22 | |||
23 | router = APIRouter() | ||
24 | |||
25 | |||
26 | @router.get('/') | ||
27 | def homepage( | ||
28 | session: UserSession=Depends(UserSession), | ||
29 | ): | ||
30 | if session.is_logged_in(): | ||
31 | return 'Welcome!' | ||
32 | |||
33 | return 'Homepage here.' | ||
34 | |||
35 | |||
36 | @router.post('/account/register') | ||
37 | def account_register( | ||
38 | session: UserSession=Depends(UserSession), | ||
39 | username: str=Form(...), | ||
40 | password: str=Form(...), | ||
41 | ): | ||
42 | try: | ||
43 | if username_pattern.match(username) is None: | ||
44 | return 'error: Invalid username format.' | ||
45 | |||
46 | if not 4 <= len(password) <= 32: | ||
47 | return 'error: Invalid password length.' | ||
48 | |||
49 | hash = password_ctx.hash(password) | ||
50 | with db_transaction() as tx: | ||
51 | user = tx.create_account(username=username, password_hash=hash) | ||
52 | |||
53 | session.login(user.id) | ||
54 | return 'Account succesfully created. Welcome!' | ||
55 | |||
56 | except IntegrityError as exception: | ||
57 | if isinstance(exception.__cause__, UniqueViolation): | ||
58 | return 'error: This username is already taken.' | ||
59 | else: | ||
60 | raise exception | ||
61 | |||
62 | |||
63 | @router.post('/account/login') | ||
64 | def session_login( | ||
65 | session: UserSession=Depends(UserSession), | ||
66 | username: str=Form(...), | ||
67 | password: str=Form(...), | ||
68 | ): | ||
69 | with db_transaction() as tx: | ||
70 | user = tx.fetch_account_username(username=username) | ||
71 | |||
72 | if user is not None and password_ctx.verify(password, user.password_hash): | ||
73 | session.login(user.id) | ||
74 | return 'Welcome back!' | ||
75 | else: | ||
76 | return 'error: Invalid credentials.' | ||
77 | |||
78 | |||
79 | @router.post('/account/logout') | ||
80 | def session_logout( | ||
81 | session: UserSession=Depends(UserSession), | ||
82 | ): | ||
83 | if session.is_logged_in(): | ||
84 | session.logout() | ||
85 | return 'You have been successfully logged out.' | ||
86 | |||
87 | return 'Nothing to do' | ||
diff --git a/app/app_database.py b/app/app_database.py new file mode 100644 index 0000000..77f092a --- /dev/null +++ b/app/app_database.py | |||
@@ -0,0 +1,43 @@ | |||
1 | # UGE / L2 / Intro to relational databases / Python project prototype | ||
2 | # Author: Pacien TRAN-GIRARD | ||
3 | # Licence: EUPL-1.2 | ||
4 | |||
5 | from os import environ | ||
6 | from functools import partial | ||
7 | from contextlib import contextmanager | ||
8 | |||
9 | import psycopg2 | ||
10 | from psycopg2.extras import NamedTupleCursor | ||
11 | from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED | ||
12 | |||
13 | import embrace | ||
14 | from embrace import pool | ||
15 | |||
16 | |||
17 | # Connection to the PostgreSQL database server. | ||
18 | # Using a cursor which exposes result rows as named tuples for convenience. | ||
19 | # TODO: switch to psycopg3 and use asynchronous queries in handlers. | ||
20 | db_connect = partial( | ||
21 | psycopg2.connect, | ||
22 | environ['DATABASE_URL'], | ||
23 | cursor_factory=NamedTupleCursor) | ||
24 | |||
25 | db_pool = pool.ConnectionPool(db_connect, limit=4) | ||
26 | |||
27 | |||
28 | # Turn our annotated SQL queries into Python functions. | ||
29 | queries = embrace.module('./sql/', auto_reload=True) | ||
30 | |||
31 | |||
32 | @contextmanager | ||
33 | def db_transaction(isolation_level=ISOLATION_LEVEL_READ_COMMITTED): | ||
34 | """ | ||
35 | Get a connection from the connection pool and begin a transaction with the | ||
36 | given isolation level. The transaction is automatically rolled back if an | ||
37 | uncaught exception escapes the current context. Otherwise, it is | ||
38 | automatically committed when finishing with no error. | ||
39 | """ | ||
40 | with db_pool.connect() as conn: | ||
41 | conn.set_isolation_level(isolation_level) | ||
42 | with queries.transaction(conn) as tx: | ||
43 | yield tx | ||
diff --git a/app/app_sessions.py b/app/app_sessions.py new file mode 100644 index 0000000..89521fb --- /dev/null +++ b/app/app_sessions.py | |||
@@ -0,0 +1,48 @@ | |||
1 | # UGE / L2 / Intro to relational databases / Python project prototype | ||
2 | # Author: Pacien TRAN-GIRARD | ||
3 | # Licence: EUPL-1.2 | ||
4 | |||
5 | from os import environ | ||
6 | from functools import partial | ||
7 | |||
8 | from fastapi import Request, HTTPException, status | ||
9 | from starlette.middleware.sessions import SessionMiddleware | ||
10 | |||
11 | |||
12 | # Use a signed-cookie session manager. | ||
13 | # The default SameSite policy offers some protection against CSRF attacks. | ||
14 | cookie_key = environ['COOKIE_SECRET_KEY'] | ||
15 | SessionManager = partial(SessionMiddleware, secret_key=cookie_key) | ||
16 | |||
17 | |||
18 | class UserSession: | ||
19 | """ | ||
20 | Session decorator for managing user login sessions. | ||
21 | """ | ||
22 | |||
23 | def __init__(self, request: Request): | ||
24 | self._session = request.session | ||
25 | |||
26 | def is_logged_in(self) -> bool: | ||
27 | return 'user_id' in self._session | ||
28 | |||
29 | def get_user_id(self) -> int: | ||
30 | return self._session['user_id'] | ||
31 | |||
32 | def login(self, user_id: int): | ||
33 | self._session['user_id'] = user_id | ||
34 | |||
35 | def logout(self): | ||
36 | self._session.pop('user_id', None) | ||
37 | |||
38 | @classmethod | ||
39 | def authenticated(cls, request: Request) -> 'UserSession': | ||
40 | """ | ||
41 | Returns the authenticated user session or raises an HTTP Exception, | ||
42 | dropping the request if the user is not logged in. | ||
43 | """ | ||
44 | session = cls(request) | ||
45 | if not session.is_logged_in(): | ||
46 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) | ||
47 | |||
48 | return session | ||
diff --git a/app/app_wallet.py b/app/app_wallet.py new file mode 100644 index 0000000..9dffbd5 --- /dev/null +++ b/app/app_wallet.py | |||
@@ -0,0 +1,95 @@ | |||
1 | # UGE / L2 / Intro to relational databases / Python project prototype | ||
2 | # Author: Pacien TRAN-GIRARD | ||
3 | # Licence: EUPL-1.2 | ||
4 | |||
5 | from decimal import Decimal | ||
6 | |||
7 | from fastapi import APIRouter, Form, Depends, status | ||
8 | |||
9 | from embrace.exceptions import IntegrityError | ||
10 | from psycopg2.errors import CheckViolation | ||
11 | from psycopg2.extensions import ISOLATION_LEVEL_SERIALIZABLE | ||
12 | |||
13 | from app_sessions import UserSession | ||
14 | from app_database import db_transaction | ||
15 | |||
16 | |||
17 | router = APIRouter() | ||
18 | |||
19 | |||
20 | # TODO: add paging for the transaction history | ||
21 | @router.get('/wallet') | ||
22 | def wallet( | ||
23 | session: UserSession=Depends(UserSession.authenticated), | ||
24 | ): | ||
25 | with db_transaction() as tx: | ||
26 | history = tx.fetch_transactions(user_id=session.get_user_id()) | ||
27 | return list(history) | ||
28 | |||
29 | |||