Merge pull request #12 from BlackChaosNL/fea/users

Add user routes
This commit was merged in pull request #12.
This commit is contained in:
Jeroen Vijgen
2025-07-16 21:49:06 +03:00
committed by GitHub
10 changed files with 281 additions and 119 deletions
+2 -48
View File
@@ -10,8 +10,6 @@ from modules.auth.models import Token
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from tortoise.expressions import Q from tortoise.expressions import Q
from config import settings from config import settings
from modules.users.schemas import user_model
from modules.auth.schemas import register_model
router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@@ -31,7 +29,7 @@ async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
Logs the user into our API, creates tokens and passes them back to User. Logs the user into our API, creates tokens and passes them back to User.
""" """
user: User | None = await User.filter( user: User | None = await User.filter(
Q(email=form.username) | Q(username=form.username) (Q(email=form.username) | Q(username=form.username)) & Q(disabled=False)
).first() ).first()
if user is None: if user is None:
@@ -44,11 +42,6 @@ async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error
) )
if user.disabled is True:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error
)
tokens = await create_jwt_tokens(user) tokens = await create_jwt_tokens(user)
return {"jwt": tokens} return {"jwt": tokens}
@@ -61,7 +54,7 @@ async def logout(user: Annotated[User, Depends(get_current_active_user)]):
Logout destroys all tokens for User that are currently active. Logout destroys all tokens for User that are currently active.
""" """
get_all_tokens = await Token.filter(Q(user__id=user.id)) get_all_tokens = await Token.filter(Q(user__id=user.id) & Q(disabled=False))
if get_all_tokens is None: if get_all_tokens is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_204_NO_CONTENT, detail="An error occurred." status_code=status.HTTP_204_NO_CONTENT, detail="An error occurred."
@@ -98,12 +91,6 @@ async def refresh_login(
detail=token_error, detail=token_error,
) )
if refresh_token.disabled is True:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=token_error,
)
get_all_tokens = await Token.filter(Q(user__id=refresh_token.user_id)) get_all_tokens = await Token.filter(Q(user__id=refresh_token.user_id))
for token in get_all_tokens: for token in get_all_tokens:
@@ -115,36 +102,3 @@ async def refresh_login(
) )
return {"jwt": tokens} return {"jwt": tokens}
@router.post(
"/register", status_code=status.HTTP_201_CREATED, response_model=user_model
)
async def register(user: register_model):
# Prevent existing users from reapplying for our system.
existing_user: User | None = await User.filter(
Q(email=user.email)
& Q(username=user.username)
& Q(name=user.name)
& Q(surname=user.surname)
).get_or_none()
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=user_exists,
)
if user.password != user.validate_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=password_failed,
)
return await User.create(
email=user.email,
username=user.username,
name=user.name,
surname=user.surname,
password=crypt.hash(user.password),
)
+1 -10
View File
@@ -1,14 +1,5 @@
from pydantic import BaseModel, EmailStr
from tortoise.contrib.pydantic import pydantic_model_creator from tortoise.contrib.pydantic import pydantic_model_creator
from modules.auth.models import Token from modules.auth.models import Token
token_model = pydantic_model_creator(Token) token_model = pydantic_model_creator(Token)
class register_model(BaseModel):
email: EmailStr
username: str
name: str
surname: str
password: str
validate_password: str
+4 -2
View File
@@ -61,7 +61,7 @@ async def create_jwt_tokens(user: User) -> Token:
async def get_tokens_from_logged_in_user( async def get_tokens_from_logged_in_user(
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)] token: Annotated[str, Depends(settings.OAUTH2_SCHEME)],
) -> User | None: ) -> User | None:
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -79,4 +79,6 @@ async def get_tokens_from_logged_in_user(
except: except:
raise credentials_exception raise credentials_exception
return await Token.filter(Q(refresh_token=token) & Q(user__id=user_id)).first() return await Token.filter(
Q(refresh_token=token) & Q(user__id=user_id) & Q(disabled=False)
).first()
@@ -1,5 +1,5 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated, List from typing import Annotated, List
@@ -27,7 +27,7 @@ async def all_active_organizations(
organizations: List[Organization] = [] organizations: List[Organization] = []
if len(memberships) < 1: if len(memberships) < 1:
raise HTTPException(status_code=404, detail="No active organizations found!") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active organizations found!")
for member in memberships: for member in memberships:
organizations.append(member.organization) organizations.append(member.organization)
@@ -35,7 +35,7 @@ async def all_active_organizations(
return organizations return organizations
@router.delete("/{org_id}", status_code=204) @router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_organization( async def delete_organization(
user: Annotated[User, Depends(get_current_active_user)], org_id: uuid.UUID user: Annotated[User, Depends(get_current_active_user)], org_id: uuid.UUID
) -> None: ) -> None:
@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
import uuid import uuid
from fastapi import HTTPException, status
from pydantic import EmailStr from pydantic import EmailStr
import pytz import pytz
from tortoise.models import Model from tortoise.models import Model
@@ -41,9 +42,10 @@ class User(Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}" return f"{self.id} - {self.name} {self.surname}"
async def set_password(self, password: str) -> None: async def set_password(self, password: str) -> bool:
self.password = crypt.hash(password) self.password = crypt.hash(password)
await self.save() # Make sure to save the model in DB await self.save() # Make sure to save the model in DB
return True
def check_against_password(self, password: str) -> bool: def check_against_password(self, password: str) -> bool:
return crypt.verify(password, self.password) return crypt.verify(password, self.password)
@@ -55,7 +57,7 @@ class User(Model):
return False return False
if new_password is not verify_new_password: if new_password is not verify_new_password:
return False return False
await self.set_password(new_password) return await self.set_password(new_password)
async def delete(self, force: bool = False) -> None: async def delete(self, force: bool = False) -> None:
if force: if force:
+91 -12
View File
@@ -1,20 +1,99 @@
from fastapi import APIRouter from typing import Annotated, List
from fastapi import APIRouter, Depends
from modules.users.models import User from fastapi import HTTPException, status
from tortoise.expressions import Q
from modules.auth.models import Token
from modules.users.utils import get_current_active_user
from modules.users.schemas import register_model, update_user_model
from modules.users.models import Membership, User
from modules.users.schemas import user_model
from config import settings
router = APIRouter(prefix="/api/v1/users", tags=["users"]) router = APIRouter(prefix="/api/v1/users", tags=["users"])
crypt = settings.CRYPT
@router.get("/") user_exists: str = "Account failed to create, please contact support."
def get_all_users(): password_failed: str = "Password validation failed, please try again."
pass
@router.post("/")
def create_user():
pass
@router.get("/me") @router.post("/", status_code=status.HTTP_201_CREATED, response_model=user_model)
def get_user(): async def create_user(user: register_model):
pass # Prevent existing users from reapplying for our system.
existing_user: User | None = await User.get_or_none(
Q(email=user.email)
& Q(username=user.username)
& Q(name=user.name)
& Q(surname=user.surname)
& Q(disabled=False)
)
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=user_exists,
)
if user.password != user.validate_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=password_failed,
)
return await User.create(
email=user.email,
username=user.username,
name=user.name,
surname=user.surname,
password=crypt.hash(user.password),
)
@router.put("/me", status_code=status.HTTP_204_NO_CONTENT)
async def update_user(
user: Annotated[User, Depends(get_current_active_user)],
updated_user: update_user_model,
):
if updated_user.email:
user.email = updated_user.email
if updated_user.name:
user.name = updated_user.name
if updated_user.surname:
user.surname = updated_user.surname
if (
updated_user.old_password
and updated_user.password
and updated_user.validate_password
):
user.update_password(
updated_user.old_password,
updated_user.password,
updated_user.validate_password,
)
await user.save()
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def update_user(
user: Annotated[User, Depends(get_current_active_user)],
):
memberships: List[Membership] = await Membership.filter(Q(user__id=user.id) & Q(disabled=False))
for membership in memberships:
await membership.acl.delete()
await membership.delete()
tokens: List[Token] = await Token.filter(Q(user__id=user.id) & Q(disabled=False))
for token in tokens:
await token.delete()
await user.delete()
@router.get("/me", response_model=user_model)
async def get_user(user: Annotated[User, Depends(get_current_active_user)]):
return user
@@ -1,5 +1,24 @@
from pydantic import BaseModel, EmailStr
from tortoise.contrib.pydantic import pydantic_model_creator from tortoise.contrib.pydantic import pydantic_model_creator
from modules.users.models import User from modules.users.models import User
user_model = pydantic_model_creator(User, exclude=["password"]) user_model = pydantic_model_creator(User, exclude=["password"])
class register_model(BaseModel):
email: EmailStr
username: str
name: str
surname: str
password: str
validate_password: str
class update_user_model(BaseModel):
email: EmailStr | None
name: str | None
surname: str | None
old_password: str | None
password: str | None
validate_password: str | None
+3 -8
View File
@@ -10,7 +10,7 @@ from config import settings
async def get_user_from_token( async def get_user_from_token(
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)] token: Annotated[str, Depends(settings.OAUTH2_SCHEME)],
) -> User | None: ) -> User | None:
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -28,7 +28,7 @@ async def get_user_from_token(
except: except:
raise credentials_exception raise credentials_exception
return await User.filter(Q(id=user_id)).first() return await User.filter(Q(id=user_id) & Q(disabled=False)).first()
async def get_current_active_user( async def get_current_active_user(
@@ -37,11 +37,6 @@ async def get_current_active_user(
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not found or active", detail="The requested token does not exist or you are not logged in.",
)
if user.disabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not found or active",
) )
return user return user
@@ -1,9 +1,7 @@
from tests.base_test import Test from tests.base_test import Test
from modules.users.models import User
from httpx import AsyncClient from httpx import AsyncClient
from config import settings from config import settings
from unittest.mock import ANY from unittest.mock import ANY
from tortoise.expressions import Q
crypt = settings.CRYPT crypt = settings.CRYPT
@@ -161,35 +159,3 @@ class TestAuthentication(Test):
} }
} }
async def test_setup_new_account(self, client: AsyncClient):
# Ensure account is never available. Prevents account already being available.
check_if_account_exists: User | None = await User.filter(
Q(email="superuser@localhost.com")
).get_or_none()
if check_if_account_exists:
await check_if_account_exists.delete(force=True)
account = await client.post(
"https://localhost/api/v1/auth/register",
json={
"email": "superuser@localhost.com",
"username": "superuser",
"name": "awesome",
"surname": "superuser",
"password": "superuserpassword",
"validate_password": "superuserpassword",
},
)
assert account.status_code == 201
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "superuser@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "superuser",
"username": "superuser",
}
@@ -0,0 +1,154 @@
from tests.base_test import Test
from tortoise.expressions import Q
from tests.base_test import Test
from httpx import AsyncClient
from unittest.mock import ANY
from modules.users.models import User
class TestAccounts(Test):
async def test_setup_new_account(self, client: AsyncClient):
# Ensure account is never available. Prevents account already being available.
check_if_account_exists: User | None = await User.filter(
Q(email="superuser@localhost.com")
).get_or_none()
if check_if_account_exists:
await check_if_account_exists.delete(force=True)
account = await client.post(
"https://localhost/api/v1/users/",
json={
"email": "superuser@localhost.com",
"username": "superuser",
"name": "awesome",
"surname": "superuser",
"password": "superuserpassword",
"validate_password": "superuserpassword",
},
)
assert account.status_code == 201
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "superuser@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "superuser",
"username": "superuser",
}
async def test_me_route(self, client: AsyncClient, create_user_with_org):
_, _, _, tokens = await create_user_with_org()
account = await client.get(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert account.status_code == 200
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "user@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "user",
"username": "user",
}
async def test_update_me_route(self, client: AsyncClient, create_user_with_org):
_, _, _, tokens = await create_user_with_org()
account = await client.get(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert account.status_code == 200
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "user@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "user",
"username": "user",
}
account = await client.put(
"https://localhost/api/v1/users/me",
json={
"email": None,
"name": None,
"surname": "bluey",
"old_password": None,
"password": None,
"validate_password": None,
},
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert account.status_code == 204
account = await client.get(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert account.status_code == 200
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "user@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "bluey",
"username": "user",
}
async def test_remove_account(self, client: AsyncClient, create_user_with_org):
_, _, _, tokens = await create_user_with_org(email="sup3rus3r@gmail.com")
account = await client.get(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert account.status_code == 200
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "sup3rus3r@gmail.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "user",
"username": "user",
}
delete = await client.delete(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert delete.status_code == 204
old = await client.get(
"https://localhost/api/v1/users/me",
headers={"Authorization": f"Bearer {tokens.access_token}"},
)
assert old.status_code == 401
assert old.json() == {
"detail": "The requested token does not exist or you are not logged in.",
}