Files
backend_authentication/main.py
T
jakob.scheid 70dbf0b120 geändert: Makefile
geändert:       docker-compose.yml
	neue Datei:     keys/ec_private.pem
	neue Datei:     keys/ec_public.pem
	geändert:       main.py
	geändert:       requirements.txt
2025-12-21 17:39:32 +01:00

303 lines
10 KiB
Python

from fastapi import FastAPI, Depends, HTTPException, Response, Request, status, Cookie
from psycopg_pool import AsyncConnectionPool
import psycopg
import uvicorn
import os
from pydantic import BaseModel, Field, model_validator, field_validator
import time
from datetime import date, datetime, timedelta, timezone
from confluent_kafka import Producer
import uuid
import hashlib
import jwt
# class Item(BaseModel):
# name: str
# description: str
# reporter: str
# priority: int
# is_stupid: bool
with open('keys/ec_private.pem', 'rb') as f:
PRIVATE_KEY = f.read()
with open('keys/ec_public.pem', 'rb') as f:
PUBLIC_KEY = f.read()
REGISTRATION_MINIMUM_AGE = 4745
def get_month_length(month, year = time.localtime().tm_year):
assert not (month > 12 or month < 1)
return {
1: 31,
2: 29 if year % 4 == 0 else 28,
3: 31,
4: 30,
5: 31,
6: 30,
7: 31,
8: 31,
9: 30,
10: 31,
11: 30,
12: 31
}[month]
class User(BaseModel):
email: str = Field(..., max_length = 64)
password: str
birthYear: int
birthMonth: int = Field(..., ge=1, le=12)
birthDay: int
firstName: str = Field(..., max_length = 64)
lastName: str = Field(..., max_length = 64)
preInstalledApplications: dict
@model_validator(mode='before')
def strip_all(cls, values):
return {k: v.strip() if type(v) and k != 'password' == str else v for k, v in values.items()}
@field_validator('email')
def validate_email(cls, value):
def e(): raise ValueError('invalid e-mail address')
if not '@' in value or ' ' in value:
e()
user, host = value.split('@')
if host.endswith('.'):
host = host[:-1]
if not user or not host or '.' not in host or host.startswith('.'):
e()
labels = host.split('.')
for l in labels:
if len(l.encode()) < 1 or len(l.encode()) > 63:
e()
return value
@field_validator('password')
def validate_password_security(cls, password):
password = password
number_of_caps = 0
number_of_smalls = 0
number_of_numbers = 0
number_of_specials = 0
for char in password:
if char.isalnum():
if char.isalpha():
if char.capitalize() == char:
number_of_caps += 1
else:
number_of_smalls += 1
else:
number_of_numbers += 1
else:
number_of_specials += 1
if not (number_of_caps > 0 and number_of_numbers > 0 and number_of_smalls > 0 and number_of_specials > 0 and len(password) > 7):
raise ValueError('the password does not match the requirements')
return password
@model_validator(mode='before')
def validate_pre_installed_applications(cls, values):
...
return values
@model_validator(mode='before')
def validate_birth_date(cls, values):
day = values.get('birthDay')
if day < 1 or day > get_month_length(values.get('birthMonth'), values.get('birthYear')):
raise ValueError('invalid birth day')
today = date.today()
birth_date = date(values.get('birthYear'), values.get('birthMonth'), day)
if not (today >= birth_date):
raise ValueError('the date cannot be in future')
if (today - birth_date).days < REGISTRATION_MINIMUM_AGE:
raise ValueError('age restriction, minimum age is 13 years')
return values
class Session(BaseModel):
password: str
stayLoggedIn: bool = Field(False)
app = FastAPI(
# docs_url = None,
# redoc_url = None,
# openapi_url = None
)
pool: AsyncConnectionPool
@app.on_event('startup')
async def on_startup():
global pool
pool = AsyncConnectionPool(
os.getenv('DB_URL'),
min_size=5,
max_size=40
)
await pool.open()
print('[INFO] Started connection pool')
async with pool.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute('CREATE TABLE IF NOT EXISTS users (id UUID PRIMARY KEY, email VARCHAR(64) UNIQUE, phone VARCHAR(32), password BYTEA)')
await cursor.execute('CREATE TABLE IF NOT EXISTS recovery (id UUID PRIMARY KEY, remail VARCHAR(64))')
print('[INFO] Database initialized')
await conn.commit()
@app.on_event('shutdown')
async def on_shutdown():
await pool.close()
print('[INFO] Closed connection pool')
async def get_conn():
async with pool.connection() as conn:
yield conn
@app.post('/users')
async def create_user(user: User, conn = Depends(get_conn)):
'''Register a user'''
async with conn.cursor() as cursor:
user_id = uuid.uuid4()
print(await (await cursor.execute('SELECT * FROM users')).fetchall())
print(await (await cursor.execute('SELECT * FROM recovery')).fetchall())
try:
await cursor.execute('INSERT INTO users (id, email, phone, password) VALUES (%s, %s, %s, %s)', (user_id, user.email, None, hashlib.sha3_512(user.password.encode()).digest()))
await cursor.execute('INSERT INTO recovery (id, remail) VALUES (%s, %s)', (user_id, None))
except psycopg.errors.UniqueViolation:
raise HTTPException(status_code=409, detail = 'this user is already registered')
conn.commit()
return {
'id': user_id,
'email': user.email,
'remail': None,
'phone': None
}
@app.post('/users/{user}/session')
async def create_session(response: Response, session: Session, user: uuid.UUID, conn = Depends(get_conn)):
'''Create a session'''
async with conn.cursor() as cursor:
await cursor.execute('SELECT COUNT(*) FROM users WHERE id = %s', (user,))
if not (await cursor.fetchone())[0]:
raise HTTPException(status_code = 404, detail = 'user not found')
await cursor.execute('SELECT password FROM users WHERE id = %s', (user,))
if hashlib.sha3_512(session.password.encode()).digest() != (await cursor.fetchone())[0]:
raise HTTPException(status_code = 401, detail = 'Unauthorized')
payload = {
'iss': 'http://localhost:1234',
'sub': str(user),
'aud': 'jcloud-auth',
'iat': datetime.now(tz=timezone.utc),
'exp': datetime.now(tz=timezone.utc) + timedelta(days=20),
}
token = jwt.encode(
payload,
PRIVATE_KEY,
algorithm = 'ES256',
# headers = {
# 'kid': 'ec-key-2025-01'
# }
)
response.set_cookie(
key = 'auth_token',
value = token,
max_age = 1728000 if session.stayLoggedIn else None,
httponly = True,
secure = False,
samesite = 'strict'
)
response.set_cookie(
key = 'user',
value = str(user),
max_age = 1728000 if session.stayLoggedIn else None,
httponly = True,
secure = False,
samesite = 'strict',
)
response.set_cookie
return {
'user': user,
'authToken': token,
'exp': datetime.now(tz=timezone.utc) + timedelta(days=20)
}
@app.delete('/users/session', status_code = status.HTTP_204_NO_CONTENT)
async def delete_session(response: Response):
'''Delete a session'''
response.set_cookie(
key = 'auth_token',
value = '',
max_age = -1,
httponly = True,
secure = False
)
response.set_cookie(
key = 'user',
value = '',
max_age = -1,
httponly = True,
secure = False
)
@app.get('/users/{user}/validity')
async def validate_token(user: uuid.UUID, conn = Depends(get_conn), Auth: str = Cookie(...)):
'''Validate a token'''
async with conn.cursor() as cursor:
await cursor.execute('SELECT COUNT(*) FROM users WHERE id = %s', (user,))
if not (await cursor.fetchone())[0]:
raise HTTPException(status_code = 404, detail = 'User not found')
try:
jwt.decode(
Auth,
PUBLIC_KEY,
algorithms = ['ES256'],
audience = 'jcloud-auth',
issuer = 'http://localhost:1234',
subject = str(user)
)
except:
raise HTTPException(status_code = 401, detail = 'Unauthorized')
return b'success'
@app.get('/health')
async def get_health_status():
'''Sends `OK`'''
return b'OK'
# @app.post('/item')
# async def create_item(item: Item, conn = Depends(get_conn)):
# async with conn.cursor() as cursor:
# await cursor.execute('INSERT INTO items (name, description, reporter, priority, is_stupid) VALUES (%s, %s, %s, %s, %s) RETURNING *', (item.name, item.description, item.reporter, item.priority, item.is_stupid))
# i = await cursor.fetchone()
# return {'id': i[0], 'description' : i[1], 'reporter': i[2], 'priority': i[3], 'is_stupid': i[4]}
# @app.get('/item/{item_id}')
# async def read_item(item_id, conn = Depends(get_conn)):
# async with conn.cursor() as cursor:
# await cursor.execute('SELECT * FROM items WHERE id = %s', (item_id,))
# i = await cursor.fetchone()
# return {'id': i[0], 'name' : i[1], 'description': i[2], 'reporter': i[3], 'priority': i[4], 'is_stupid': i[5]}
# @app.get('/items')
# async def read_item(conn = Depends(get_conn)):
# async with conn.cursor() as cursor:
# await cursor.execute('SELECT * FROM items')
# items = await cursor.fetchall()
# return [{'id': i[0], 'description' : i[1], 'reporter': i[2], 'priority': i[3], 'is_stupid': i[4]} for i in items]
# @app.get('/datadir')
# async def get_datadir(conn = Depends(get_conn)):
# async with conn.cursor() as cursor:
# await cursor.execute('SHOW data_directory')
# return {'res': await cursor.fetchall()}
if __name__ == '__main__':
uvicorn.run(
'main:app',
host = '0.0.0.0',
port = 1234,
reload = True,
reload_dirs = ['/app'],
server_header = False
)