diff --git a/Makefile b/Makefile index 55bc8bc..a5e93dd 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,9 @@ build: podman-compose build reload: - podman rm authentication_app_1 - podman rm authentication_db_1 - podman rm authentication_migration_1 + podman rm backend_authentication_app_1 + podman rm backend_authentication_db_1 + podman rm backend_authentication_migration_1 up: podman-compose up --build diff --git a/docker-compose.yml b/docker-compose.yml index 3dc542b..4f698e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,10 @@ services: - .env db: - image: postgres:15 + image: docker.io/library/postgres:15 environment: - DB_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} env_file: - .env - volumes: - - /srv/data/db/services/authentication:/var/lib/postgresql/data \ No newline at end of file + # volumes: + # - /srv/data/db/services/authentication:/var/lib/postgresql/data \ No newline at end of file diff --git a/keys/ec_private.pem b/keys/ec_private.pem new file mode 100644 index 0000000..86423ad --- /dev/null +++ b/keys/ec_private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEICSXpqvHczoQX4EOXgZjbXRTKZ8xrZGq5urrf6JbEugKoAoGCCqGSM49 +AwEHoUQDQgAEUqghNqolf173mvhnnGTEuTdSvhf9kyV2VeNala0SOgNFfsNrYXx5 +vSlGFr47XIPLhiCzEhtV93TCsoRE8kmfjQ== +-----END EC PRIVATE KEY----- diff --git a/keys/ec_public.pem b/keys/ec_public.pem new file mode 100644 index 0000000..ab343d2 --- /dev/null +++ b/keys/ec_public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUqghNqolf173mvhnnGTEuTdSvhf9 +kyV2VeNala0SOgNFfsNrYXx5vSlGFr47XIPLhiCzEhtV93TCsoRE8kmfjQ== +-----END PUBLIC KEY----- diff --git a/main.py b/main.py index e9da286..733e189 100644 --- a/main.py +++ b/main.py @@ -1,20 +1,131 @@ -from fastapi import FastAPI, Depends +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 +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 +# class Item(BaseModel): +# name: str +# description: str +# reporter: str +# priority: int +# is_stupid: bool -app = FastAPI() + +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 @@ -28,7 +139,8 @@ async def on_startup(): async with pool.connection() as conn: async with conn.cursor() as cursor: - await cursor.execute('CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(64), remail VARCHAR(64), phone VARCHAR(16), password VARCHAR(128))') + 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() @@ -41,38 +153,144 @@ async def get_conn(): async with pool.connection() as conn: yield conn -@app.get('/') -def read_root(): +@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 { - 'message': 'microservice is running' + 'id': user_id, + 'email': user.email, + 'remail': None, + 'phone': None } -@app.post('/item') -async def create_item(item: Item, conn = Depends(get_conn)): +@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('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]} + 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.get('/item/{item_id}') -async def read_item(item_id, conn = Depends(get_conn)): +@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 * 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]} + 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('/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('/health') +async def get_health_status(): + '''Sends `OK`''' + return b'OK' -@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()} +# @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( diff --git a/requirements.txt b/requirements.txt index c62b07c..5016c38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ fastapi psycopg_pool uvicorn psycopg[binary] -pydantic \ No newline at end of file +pydantic +confluent_kafka +pyjwt +cryptography \ No newline at end of file