from datetime import timedelta from secrets import compare_digest from typing import Annotated, Any from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.security import OAuth2PasswordRequestForm from fluksio import crud from fluksio.api.deps import CurrentUser, SessionDep, get_current_active_superuser from fluksio.api.routes.oauth import too_many from fluksio.core import security from fluksio.core.config import settings from fluksio.models import Message, NewPassword, Token, UserPublic, UserUpdate from fluksio.utils import ( generate_password_reset_token, generate_reset_password_email, password_fingerprint, send_email, verify_password_reset_token, ) router = APIRouter(tags=["login"]) #: Attempts per address per window, and the window. Argon2 is deliberately #: expensive — tens of megabytes and several passes per verification — and #: this route is unauthenticated and runs in the shared threadpool, so a #: burst of guesses is a memory and a concurrency problem before it is a #: credential one. A person who mistypes twice is nowhere near it. LOGIN_ATTEMPTS = 10 LOGIN_WINDOW_S = 300.0 @router.post("/login/access-token") def login_access_token( request: Request, session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: """ OAuth2 compatible token login, get an access token for future requests """ if too_many(request, "login", LOGIN_ATTEMPTS, LOGIN_WINDOW_S, record=False): raise HTTPException( status_code=429, detail="Too many sign-in attempts. Try again shortly." ) user = crud.authenticate( session=session, email=form_data.username, password=form_data.password ) if not user: # Only the failures are counted, so somebody signing in all day is # never near the limit and somebody guessing is. too_many(request, "login", LOGIN_ATTEMPTS, LOGIN_WINDOW_S) raise HTTPException(status_code=400, detail="Incorrect email or password") elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return Token( access_token=security.create_access_token( user.id, expires_delta=access_token_expires ) ) @router.post("/login/test-token", response_model=UserPublic) def test_token(current_user: CurrentUser) -> Any: """ Test access token """ return current_user @router.post("/password-recovery/{email}") def recover_password( email: str, session: SessionDep, background: BackgroundTasks ) -> Message: """ Password Recovery """ user = crud.get_user_by_email(session=session, email=email) # Always return the same response to prevent email enumeration attacks # Only send email if user actually exists if user: password_reset_token = generate_password_reset_token( email=email, hashed_password=user.hashed_password ) email_data = generate_reset_password_email( email_to=user.email, email=email, token=password_reset_token ) # After the response, not before it: sending is a synchronous SMTP # conversation, and doing it inline made the reply slow exactly when # the address exists — which is the enumeration the identical wording # below is there to prevent. background.add_task( send_email, email_to=user.email, subject=email_data.subject, html_content=email_data.html_content, ) return Message( message="If that email is registered, we sent a password recovery link" ) @router.post("/reset-password/") def reset_password(session: SessionDep, body: NewPassword) -> Message: """ Reset password """ claims = verify_password_reset_token(token=body.token) if not claims: raise HTTPException(status_code=400, detail="Invalid token") email, fingerprint = claims user = crud.get_user_by_email(session=session, email=email) if not user: # Don't reveal that the user doesn't exist - use same error as invalid token raise HTTPException(status_code=400, detail="Invalid token") elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") if not compare_digest(fingerprint, password_fingerprint(user.hashed_password)): # The link already set a password, or the password moved since it was # sent. Either way this one is spent — a reset link used to work over # and over for the whole of its 48 hours. raise HTTPException(status_code=400, detail="Invalid token") user_in_update = UserUpdate(password=body.new_password) crud.update_user( session=session, db_user=user, user_in=user_in_update, ) return Message(message="Password updated successfully") @router.post( "/password-recovery-html-content/{email}", dependencies=[Depends(get_current_active_superuser)], response_class=HTMLResponse, ) def recover_password_html_content(email: str, session: SessionDep) -> Any: """ HTML Content for Password Recovery """ user = crud.get_user_by_email(session=session, email=email) if not user: raise HTTPException( status_code=404, detail="The user with this username does not exist in the system.", ) password_reset_token = generate_password_reset_token( email=email, hashed_password=user.hashed_password ) email_data = generate_reset_password_email( email_to=user.email, email=email, token=password_reset_token ) return HTMLResponse( content=email_data.html_content, headers={"subject:": email_data.subject} )