130 lines
3.6 KiB
Python
130 lines
3.6 KiB
Python
import bcrypt
|
|
from datetime import timedelta
|
|
from fastapi.exceptions import HTTPException
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from fastapi import APIRouter as FastAPI, Depends, Response, Request
|
|
|
|
|
|
from config import (
|
|
LOGGER,
|
|
SESSION_EXPIRE_LIMIT,
|
|
COOKIE_SAMESITE_POLICY,
|
|
COOKIE_DOMAIN,
|
|
MANAGER,
|
|
SESSION_COOKIE_NAME,
|
|
LIMITER,
|
|
SECURE_COOKIE,
|
|
COOKIE_PATH,
|
|
COOKIE_HTTPONLY,
|
|
UNVERIFIED_MANAGER,
|
|
)
|
|
from responses import (
|
|
Response as APIResponse,
|
|
UnprocessableEntity,
|
|
BadRequest,
|
|
)
|
|
from orm.users import (
|
|
UserModel,
|
|
PrivateUserModel,
|
|
get_user_by_username,
|
|
)
|
|
|
|
router = FastAPI()
|
|
|
|
|
|
@router.post(
|
|
"/user",
|
|
tags=["Users"],
|
|
status_code=200,
|
|
responses={
|
|
200: {"model": APIResponse},
|
|
400: {"model": BadRequest},
|
|
422: {"model": UnprocessableEntity},
|
|
},
|
|
)
|
|
@LIMITER.limit("5/minute")
|
|
async def login(
|
|
request: Request, response: Response, data: OAuth2PasswordRequestForm = Depends()
|
|
):
|
|
"""
|
|
Performs user authentication. Endpoint is limited to 5 hits per minute
|
|
"""
|
|
|
|
if request.cookies.get(SESSION_COOKIE_NAME):
|
|
raise HTTPException(status_code=400, detail="Please logout first")
|
|
username = data.username
|
|
if len(username) > 32:
|
|
raise HTTPException(
|
|
status_code=413, detail="Authentication failed: username is too long"
|
|
)
|
|
try:
|
|
password = data.password.encode()
|
|
if len(password) > 72:
|
|
raise HTTPException(
|
|
status_code=413, detail="Authentication failed: password is too long"
|
|
)
|
|
except UnicodeEncodeError as e:
|
|
LOGGER.warning(
|
|
f"An error occurred while attempting to decode password for user {username} -> {type(e).__name__}: {e}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail="Authentication failed: invalid characters in password",
|
|
)
|
|
if not (
|
|
user := await get_user_by_username(
|
|
username, include_secrets=True, restricted_ok=True
|
|
)
|
|
):
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail="Authentication failed: the user does not exist",
|
|
)
|
|
user: PrivateUserModel
|
|
if not bcrypt.checkpw(password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail="Authentication failed: password mismatch",
|
|
)
|
|
token = MANAGER.create_access_token(
|
|
expires=timedelta(seconds=SESSION_EXPIRE_LIMIT),
|
|
data={"sub": str(user.public_id)},
|
|
)
|
|
response.set_cookie(
|
|
secure=SECURE_COOKIE,
|
|
key=SESSION_COOKIE_NAME,
|
|
max_age=SESSION_EXPIRE_LIMIT,
|
|
value=token,
|
|
httponly=COOKIE_HTTPONLY,
|
|
samesite=COOKIE_SAMESITE_POLICY,
|
|
domain=COOKIE_DOMAIN or None,
|
|
path=COOKIE_PATH or "/",
|
|
)
|
|
return APIResponse(status_code=200, msg="Authentication successful")
|
|
|
|
|
|
@router.get(
|
|
"/user/logout",
|
|
tags=["Users"],
|
|
status_code=200,
|
|
responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}},
|
|
)
|
|
@LIMITER.limit("5/minute")
|
|
async def logout(
|
|
request: Request, response: Response, _user: UserModel = Depends(UNVERIFIED_MANAGER)
|
|
):
|
|
"""
|
|
Deletes a user's session cookie, logging them
|
|
out. Endpoint is limited to 5 hits per minute
|
|
"""
|
|
|
|
response.delete_cookie(
|
|
secure=SECURE_COOKIE,
|
|
key=SESSION_COOKIE_NAME,
|
|
httponly=COOKIE_HTTPONLY,
|
|
samesite=COOKIE_SAMESITE_POLICY,
|
|
domain=COOKIE_DOMAIN or None,
|
|
path=COOKIE_PATH or "/",
|
|
)
|
|
return APIResponse(status_code=200, msg="Logged out")
|