aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/app.py17
-rw-r--r--app/app_account.py87
-rw-r--r--app/app_database.py43
-rw-r--r--app/app_sessions.py48
-rw-r--r--app/app_wallet.py95
-rw-r--r--readme.md24
-rw-r--r--sql/queries.sql9
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
5from fastapi import FastAPI, status
6import app_sessions
7import app_account
8import app_wallet
9
10main = FastAPI()
11
12# Add sessions based on signed cookies.
13main.add_middleware(app_sessions.SessionManager)
14
15# Register our request handlers
16main.include_router(app_account.router)
17main.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
5from fastapi import APIRouter, Depends, Request, Form, status
6
7from passlib.context import CryptContext
8import re
9
10from embrace.exceptions import IntegrityError
11from psycopg2.errors import UniqueViolation
12
13from app_sessions import UserSession
14from app_database import db_transaction
15
16
17# Password hashing context.
18# Handles proper salting and migration automatically.
19password_ctx = CryptContext(schemes=['bcrypt'], deprecated='auto')
20
21username_pattern = re.compile(r'^[a-zA-Z0-9-_]{4,16}$')
22
23router = APIRouter()
24
25
26@router.get('/')
27def 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')
37def 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')
64def 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')
80def 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
5from os import environ
6from functools import partial
7from contextlib import contextmanager
8
9import psycopg2
10from psycopg2.extras import NamedTupleCursor
11from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED
12
13import embrace
14from 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.
20db_connect = partial(
21 psycopg2.connect,
22 environ['DATABASE_URL'],
23 cursor_factory=NamedTupleCursor)
24
25db_pool = pool.ConnectionPool(db_connect, limit=4)
26
27
28# Turn our annotated SQL queries into Python functions.
29queries = embrace.module('./sql/', auto_reload=True)
30
31
32@contextmanager
33def 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
5from os import environ
6from functools import partial
7
8from fastapi import Request, HTTPException, status
9from 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.
14cookie_key = environ['COOKIE_SECRET_KEY']
15SessionManager = partial(SessionMiddleware, secret_key=cookie_key)
16
17
18class 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
5from decimal import Decimal
6
7from fastapi import APIRouter, Form, Depends, status
8
9from embrace.exceptions import IntegrityError
10from psycopg2.errors import CheckViolation
11from psycopg2.extensions import ISOLATION_LEVEL_SERIALIZABLE
12
13from app_sessions import UserSession
14from app_database import db_transaction
15
16
17router = APIRouter()
18
19
20# TODO: add paging for the transaction history
21@router.get('/wallet')
22def 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
30@router.post('/wallet/transfer')
31def wallet_transfer(
32 session: UserSession=Depends(UserSession.authenticated),