diff --git a/src/api/anon.py b/src/api/anon.py new file mode 100644 index 0000000..1445a7a --- /dev/null +++ b/src/api/anon.py @@ -0,0 +1,40 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from psycopg2._psycopg import connection + +import db.users as db +import settings.settings as settings +from api.utils import get_password_hash +from db.internal import get_db_connection + +anon_router = APIRouter(prefix="/api/anon", tags=["anon"]) + + +@anon_router.post("/add/admin") +async def add_admin( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)] +): + if not settings.settings.allow_create_admins: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + hashed_password = get_password_hash(password) + return db.create_user(conn, username, hashed_password, "admin") + +@anon_router.post("/add/user") +async def add_user( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)] +): + if not settings.settings.allow_create_users: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + hashed_password = get_password_hash(password) + return db.create_user(conn, username, hashed_password, "user") diff --git a/src/api/auth.py b/src/api/auth.py index 19d103f..4e29d3f 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -8,7 +8,7 @@ from psycopg2._psycopg import connection from api.models import Token from api.utils import authenticate_user, create_access_token from db.internal import get_db_connection -from settings import settings +from settings import startup_settings auth_router = APIRouter(prefix="/api", tags=["auth"]) @@ -18,16 +18,16 @@ async def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], conn: Annotated[connection, Depends(get_db_connection)] ) -> Token: - user = authenticate_user(conn, form_data.username, form_data.password) # change db - if not user: + password_correct = authenticate_user(conn, form_data.username, form_data.password) + if not password_correct: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expire_time = timedelta(minutes=settings.access_token_expiration_time) + access_token_expire_time = timedelta(minutes=startup_settings.access_token_expiration_time) access_token = create_access_token( - data={"sub": user.username}, expires_delta=access_token_expire_time + data={"sub": form_data.username}, expires_delta=access_token_expire_time ) return Token(access_token=access_token, token_type="bearer") diff --git a/src/api/general.py b/src/api/general.py new file mode 100644 index 0000000..904bb28 --- /dev/null +++ b/src/api/general.py @@ -0,0 +1,64 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +import settings.settings as settings +from api.models import User +from api.utils import get_current_user +from settings.settings import load_settings, reset_settings, save_settings + +general_router = APIRouter(prefix="/api", tags=["status"]) + + +@general_router.get('/ping') +async def ping(): + return {'ok'} + + +@general_router.get('/settings/get') +async def get_settings(): + return settings.settings + + +@general_router.post('/settings/update') +async def update_settings(data: dict, current_user: Annotated[User, Depends(get_current_user)]): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + for key, value in data.items(): + setattr(settings.settings, key, value) + return settings.settings + + +@general_router.get('/settings/reset') +async def reset_settings_api(current_user: Annotated[User, Depends(get_current_user)]): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + reset_settings() + return settings.settings + + +@general_router.get('/settings/load_from_file') +async def load_settings_api(current_user: Annotated[User, Depends(get_current_user)]): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + load_settings() + return settings.settings + +@general_router.get('/settings/save_to_file') +async def save_settings_api(current_user: Annotated[User, Depends(get_current_user)]): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + save_settings() + return settings.settings diff --git a/src/api/models.py b/src/api/models.py index 3b0035d..306ea91 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -16,12 +16,14 @@ class User(BaseModel): def fill(self, params): self.username = params['username'] self.password = params['password'] + self.role = params['role'] self.disabled = params['disabled'] self.groups_ids = params['groups_ids'] self.last_seen_at = params['last_seen_at'] self.created_at = params['created_at'] username: str = '' password: str = '' + role: str = 'user' disabled: bool = False groups_ids: list[str] | None = None last_seen_at: datetime | None = None diff --git a/src/api/status.py b/src/api/status.py deleted file mode 100644 index cd2535e..0000000 --- a/src/api/status.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import APIRouter - -status_router = APIRouter(prefix="/api", tags=["status"]) - - -@status_router.get('/ping') -async def ping(): - return {'ok'} diff --git a/src/api/tests.py b/src/api/tests.py deleted file mode 100644 index 5e51621..0000000 --- a/src/api/tests.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status - -from api.models import User -from api.utils import get_current_user - -test_router = APIRouter(prefix="/api", tags=["test"]) - - -@test_router.get('/test-private') -async def test_private_func(token: Annotated[User, Depends(get_current_user)]): - return {'private nya'} - - -@test_router.post('/test') -async def test_func(text: str): - print(text) - if text == 'thighs': - raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED) - return {'nya'} diff --git a/src/api/users.py b/src/api/users.py index 8f65153..164abb2 100644 --- a/src/api/users.py +++ b/src/api/users.py @@ -1,11 +1,12 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from psycopg2._psycopg import connection import db.users as db +import settings.settings as settings from api.models import User -from api.utils import get_current_user +from api.utils import get_current_user, get_password_hash from db.internal import get_db_connection users_router = APIRouter(prefix="/api/users", tags=["users"]) @@ -15,19 +16,126 @@ users_router = APIRouter(prefix="/api/users", tags=["users"]) async def read_users_me(current_user: Annotated[User, Depends(get_current_user)]): return current_user - @users_router.post("/user") -async def read_users_any(username: str, conn: Annotated[connection, Depends(get_db_connection)]): +async def read_users_any( + username: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) user = User() - user.fill(db.get_user(conn, username)) + user_data = db.get_user(conn, username) + if user_data is None: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such user", + ) + user.fill(user_data) return user -@users_router.post("/add") -async def add_user(username: str, password: str, conn: Annotated[connection, Depends(get_db_connection)]): - return db.create_user(conn, username, password) +@users_router.post("/add/admin") +async def add_admin( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if not settings.settings.allow_create_admins_by_admins: + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + hashed_password = get_password_hash(password) + return db.create_user(conn, username, hashed_password, "admin") + +@users_router.post("/add/user") +async def add_user( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if not settings.settings.allow_create_users or current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + hashed_password = get_password_hash(password) + return db.create_user(conn, username, hashed_password, "user") @users_router.post("/delete") -async def delete_user(username: str, conn: Annotated[connection, Depends(get_db_connection)]): - return db.delete_user(conn, username) +async def delete_user( + username: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.username == username or current_user.role in settings.settings.admin_roles: + return db.delete_user(conn, username) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@users_router.post("/update/disabled") +async def update_disabled( + username: str, + disabled: bool, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role in settings.settings.admin_roles: + return db.update_user_disabled(conn, username, disabled) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@users_router.post("/update/username") +async def update_username( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.username == username or current_user.role in settings.settings.admin_roles: + return db.update_user_username(conn, username, password) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + +@users_router.post("/update/password") +async def update_password( + username: str, + password: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.username == username or current_user.role in settings.settings.admin_roles: + hashed_password = get_password_hash(password) + return db.update_user_password(conn, username, hashed_password) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@users_router.get("/update/last_seen") +async def update_last_seen( + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + return db.update_user_last_seen(conn, current_user.username) diff --git a/src/api/utils.py b/src/api/utils.py index ec9b0e7..ebd90ba 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -1,47 +1,50 @@ from datetime import datetime, timedelta, timezone from typing import Annotated +import bcrypt import jwt from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError -from passlib.context import CryptContext + +# from passlib.context import CryptContext from psycopg2._psycopg import connection import db.users -import settings.settings as settings +import settings.startup_settings as startup_settings from api.models import TokenData, User from db.internal import get_db_connection -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +def verify_password(plain_password: str, hashed_password: str): + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + +def get_password_hash(password: str): + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + def decode_token(token): - return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return jwt.decode(token, startup_settings.secret_key, algorithms=[startup_settings.algorithm]) def encode_token(payload): - return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) - -def verify_password(plain_password, hashed_password): - return pwd_context.verify(plain_password, hashed_password) - + return jwt.encode(payload, startup_settings.secret_key, algorithm=startup_settings.algorithm) def authenticate_user( conn: connection, username: str, - password: str + user_password: str ): - user = User() - userdata = db.users.get_user(conn, username) - if not userdata: + db_user_password = db.users.get_user_password(conn, username) + if not user_password: return False - if not verify_password(password, user.password): + if not verify_password(user_password, db_user_password): return False - user.fill(userdata) - return user + return True + def create_access_token( data: dict, @@ -66,7 +69,6 @@ async def get_current_user( try: payload = decode_token(token) - print(payload) username = payload.get("sub") if username is None: raise credentials_exception @@ -75,14 +77,16 @@ async def get_current_user( raise credentials_exception user = User() - user.fill(db.users.get_user(conn, username=token_data.username)) - if user is None: + user_data = db.users.get_user(conn, token_data.username) + if user_data is None: raise credentials_exception + user.fill(user_data) + if user.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Inactive user" + detail="User is disabled" ) return user diff --git a/src/create_app.py b/src/create_app.py index a040070..806c671 100644 --- a/src/create_app.py +++ b/src/create_app.py @@ -1,14 +1,15 @@ from fastapi import FastAPI +from api.anon import anon_router from api.auth import auth_router -from api.status import status_router -from api.tests import test_router +from api.general import general_router from api.users import users_router from db.internal import connect_db, disconnect_db -from settings import settings +from settings import startup_settings +from settings.settings import settings_down, settings_up docs_url = None -if settings.swagger_enabled: +if startup_settings.swagger_enabled: docs_url = "/api/docs" app = FastAPI( @@ -19,12 +20,14 @@ app = FastAPI( def create_app(): app.add_event_handler("startup", connect_db) + app.add_event_handler("startup", settings_up) - app.include_router(status_router) + app.include_router(general_router) app.include_router(auth_router) app.include_router(users_router) - app.include_router(test_router) + app.include_router(anon_router) app.add_event_handler("shutdown", disconnect_db) + app.add_event_handler("shutdown", settings_down) return app diff --git a/src/db/internal.py b/src/db/internal.py index 3e99018..bb1c571 100644 --- a/src/db/internal.py +++ b/src/db/internal.py @@ -4,18 +4,18 @@ import psycopg2 from loguru import logger from db.models import database -from settings import settings +from settings import startup_settings def connect_db(): logger.info("Initializing DB connection") try: database.conn = psycopg2.connect( - dbname=settings.db_name, - user=settings.db_user, - password=settings.db_password, - host=settings.db_host, - port=settings.db_port, + dbname=startup_settings.db_name, + user=startup_settings.db_user, + password=startup_settings.db_password, + host=startup_settings.db_host, + port=startup_settings.db_port, ) except Exception as e: logger.error(f"Failed to initialize DB connection: {e}") diff --git a/src/db/users.py b/src/db/users.py index 00d6091..33e317b 100644 --- a/src/db/users.py +++ b/src/db/users.py @@ -6,16 +6,17 @@ from psycopg2._psycopg import connection def create_user( conn: connection, username: str, - password: str + password: str, + role: str = "user" ): with conn.cursor() as cur: cur.execute( """ insert into picrinth.users - (username, password, disabled, created_at) - values (%s, %s, false, now()) + (username, password, role, disabled, created_at) + values (%s, %s, %s, false, now()) """, - (username, password), + (username, password, role), ) conn.commit() return cur.rowcount > 0 @@ -74,6 +75,22 @@ def check_user_disabled( # user updates +def update_user_username( + conn: connection, + username: str, + newUsername: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.users + set username = %s + where username = %s; + """, + (newUsername, username), + ) + conn.commit() + def update_user_password( conn: connection, username: str, @@ -91,22 +108,24 @@ def update_user_password( conn.commit() -def update_user_username( +def update_user_disabled( conn: connection, username: str, - newUsername: str + disabled: bool ): + # if disabled = True -> user is disabled with conn.cursor() as cur: cur.execute( """ update picrinth.users - set username = %s - where username = %s; + set disabled = %s + where username = %s """, - (newUsername, username), + (disabled, username), ) conn.commit() + def update_user_last_seen( conn: connection, username: str @@ -132,8 +151,9 @@ def get_user( with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: cur.execute( """ - select username, password, disabled, - groups_ids, last_seen_at, created_at + select username, password, role, + disabled, groups_ids, + last_seen_at, created_at from picrinth.users where username = %s """, @@ -154,4 +174,4 @@ def get_user_password( """, (username,), ) - return cur.fetchone() + return cur.fetchone()[0] # type: ignore diff --git a/src/settings/settings.py b/src/settings/settings.py index fed0c83..f7cd1d5 100644 --- a/src/settings/settings.py +++ b/src/settings/settings.py @@ -1,22 +1,80 @@ -from decouple import config +import json +import os + +from loguru import logger +from pydantic import BaseModel -def str_to_bool(string: str) -> bool: - if string.lower() == 'true': - return True - return False +class Settings(BaseModel): + def update(self, params): + self.admin_roles = params['admin_roles'] + self.allow_create_admins_by_admins = params['allow_create_admins_by_admins'] + self.allow_create_admins = params['allow_create_admins'] + self.allow_create_users = params['allow_create_users'] + admin_roles: list[str] = ['admin'] + allow_create_admins_by_admins: bool = True + allow_create_admins: bool = True + allow_create_users: bool = True -# database -db_host = str(config('db_host', default='127.0.0.1')) -db_port = int(config('db_port', default=5432)) -db_name = str(config('db_name', default='postgres')) -db_user = str(config('db_user', default='postgres')) -db_password = str(config('db_password', default='postgres')) -# auth -secret_key = str(config('secret_key')) -algorithm = str(config('algorithm', 'HS256')) -access_token_expiration_time = int(config('access_token_expiration_time', default=10080)) +json_path = 'data/' +json_settings_name = 'settings.json' -# other settings -swagger_enabled = str_to_bool(str(config('swagger_enabled', 'false'))) +settings = Settings() + + +def settings_up(): + global settings, json_path, json_settings_name + logger.info('Configuring settings for startup') + try: + if not(os.path.exists(json_path)): + os.mkdir(json_path) + logger.debug(f'Created "{json_path}" directory') + + if os.path.exists(json_path + json_settings_name): + load_settings() + else: + with open(json_path + json_settings_name, 'w') as f: + json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) + logger.info('Wrote settings to the JSON') + logger.info('Successfully configured settings') + except Exception as e: + logger.error(f'Failed to configure settings during startup: {e}') + raise e + +def settings_down(): + global settings, json_path, json_settings_name + logger.info('Saving settings for shutdown') + try: + with open(json_path + json_settings_name, 'w') as f: + json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) + logger.info('Wrote settings to the JSON') + logger.success('Successfully saved settings') + except Exception as e: + logger.error(f'Failed to save settings during shutdown: {e}') + + +def reset_settings(): + global settings, json_path, json_settings_name + logger.info('Resetting settings') + print(settings) + settings = Settings() + print(settings) + with open(json_path + json_settings_name, 'w') as f: + json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) + logger.info('Wrote settings to the JSON') + + +def load_settings(): + global settings, json_path, json_settings_name + logger.info('Loading settings') + with open(json_path + json_settings_name, 'r') as f: + json_settings = json.load(f) + settings = Settings.model_validate_json(json_settings) + logger.info('Loaded settings from the JSON') + +def save_settings(): + global settings, json_path, json_settings_name + with open(json_path + json_settings_name, 'w') as f: + json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) + logger.info('Wrote settings to the JSON') diff --git a/src/settings/startup_settings.py b/src/settings/startup_settings.py new file mode 100644 index 0000000..fed0c83 --- /dev/null +++ b/src/settings/startup_settings.py @@ -0,0 +1,22 @@ +from decouple import config + + +def str_to_bool(string: str) -> bool: + if string.lower() == 'true': + return True + return False + +# database +db_host = str(config('db_host', default='127.0.0.1')) +db_port = int(config('db_port', default=5432)) +db_name = str(config('db_name', default='postgres')) +db_user = str(config('db_user', default='postgres')) +db_password = str(config('db_password', default='postgres')) + +# auth +secret_key = str(config('secret_key')) +algorithm = str(config('algorithm', 'HS256')) +access_token_expiration_time = int(config('access_token_expiration_time', default=10080)) + +# other settings +swagger_enabled = str_to_bool(str(config('swagger_enabled', 'false'))) diff --git a/tables.sql b/tables.sql index 4461383..687b26c 100644 --- a/tables.sql +++ b/tables.sql @@ -1,7 +1,9 @@ -CREATE TABLE public.users ( - id serial NOT NULL, - username text NOT NULL, - "password" text NOT NULL, +CREATE TABLE picrinth.users ( + id serial not null, + username text not null, + "password" text not null, + "role" text not null default "user", + "disabled" bool not null, groups_ids integer[] NULL, last_seen_at timestamp with time zone NULL, created_at timestamp with time zone NULL, @@ -9,11 +11,11 @@ CREATE TABLE public.users ( CONSTRAINT username_unique UNIQUE (username) ); -CREATE TABLE public.groups ( - id serial NOT NULL, - groupname text NOT NULL, - join_code text NOT NULL, - users_ids integer[] NULL, - created_at timestamp with time zone NULL, +CREATE TABLE picrinth.groups ( + id serial not null, + groupname text not null, + join_code text not null, + users_ids integer[] null, + created_at timestamp with time zone null, CONSTRAINT groupname_unique UNIQUE (username) );