Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve error handling. Fix logout endpoint. Handle sign-in and MFA errors. #389

Merged
merged 21 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1fe9c73
test: Tidy command tests
jpmckinney Aug 23, 2024
9b48b34
chore: Use "admin", not "ocp"
jpmckinney Aug 23, 2024
8eeb6d6
chore: There is no need to raise a 500 error, because FastAPI will do…
jpmckinney Aug 23, 2024
ed16bbf
fix: Report 400 (request validation errors) to Sentry. Use 422 instea…
jpmckinney Aug 23, 2024
984ab04
chore: Use (and document) appropriate HTTP status codes
jpmckinney Aug 23, 2024
a80caaa
feat: Don't do bare "except Exception" and don't report unexpected an…
jpmckinney Aug 23, 2024
ed46e9d
feat: Improve error messages for integrity errors
jpmckinney Aug 23, 2024
5eec7bf
i18n: Regenerate PO file. Correct one typo.
jpmckinney Aug 23, 2024
4c43d1b
feat: Add general error handler, to return JSON response for unexpect…
jpmckinney Aug 23, 2024
9dd3fe2
test: Fix test_update_user
jpmckinney Aug 23, 2024
0f0c7be
fix: Fix logout endpoint
jpmckinney Aug 23, 2024
94a39a3
chore: Remove unused loggers
jpmckinney Aug 23, 2024
b400be9
test: Fix test expectation for new error message
jpmckinney Aug 23, 2024
44c059e
feat: Log 413 errors to Sentry, to monitor whether 5MB is too small, …
jpmckinney Aug 23, 2024
392da23
fix: Prevent user enumeration on login endpoint
jpmckinney Aug 23, 2024
c7a0a98
chore: Satisfy mypy. build: Upgrade moto
jpmckinney Aug 23, 2024
36a24ef
fix: Handle EnableSoftwareTokenMFAException and CodeMismatchException…
jpmckinney Aug 23, 2024
ade9c5b
chore: Fix CI, lint and i18n tests
jpmckinney Aug 23, 2024
53414c1
Merge branch 'main' into user-test
jpmckinney Aug 23, 2024
eda2ad0
i18n: Fix pybabel command to run only against app (like it had with b…
jpmckinney Aug 23, 2024
e3f8001
test: Add 1-2 seconds to offsets to avoid timing issues
jpmckinney Aug 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/i18n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- run: pip install -r requirements.txt .
- name: Update catalogs
run: |
pybabel extract -k '_ i' -o messages.pot .
pybabel extract -k '_ i' -o messages.pot app
pybabel update -N -i messages.pot -d locale
- name: Count incomplete translations
shell: bash
Expand Down
10 changes: 8 additions & 2 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def verify_jwk_token(self, jwt_credentials: JWTAuthorizationCredentials) -> bool

return alg_obj.verify(msg, prepared_key, sig)

async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None:
async def __call__(self, request: Request) -> JWTAuthorizationCredentials:
"""
Authenticate and verify the provided JWT token in the request.

Expand All @@ -86,7 +86,13 @@ async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None

jwt_token = credentials.credentials

message, signature = jwt_token.rsplit(".", 1)
if "." in jwt_token:
message, signature = jwt_token.rsplit(".", 1)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("JWK invalid"),
)

try:
jwt_credentials = JWTAuthorizationCredentials(
Expand Down
16 changes: 8 additions & 8 deletions app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def get_aws_client() -> Generator[aws.Client, None, None]:


async def get_auth_credentials(request: Request) -> auth.JWTAuthorizationCredentials | None:
return await auth.JWTAuthorization().__call__(request)
return await auth.JWTAuthorization()(request)


async def get_current_user(credentials: auth.JWTAuthorizationCredentials = Depends(get_auth_credentials)) -> str:
Expand Down Expand Up @@ -57,16 +57,16 @@ async def get_user(username: str = Depends(get_current_user), session: Session =
user = models.User.first_by(session, "external_id", username)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
status_code=status.HTTP_404_NOT_FOUND,
detail=_("User not found"),
)
return user


async def get_admin_user(user: models.User = Depends(get_user)) -> models.User:
if not user.is_ocp():
if not user.is_admin():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_403_FORBIDDEN,
detail=_("Insufficient permissions"),
)
return user
Expand All @@ -85,7 +85,7 @@ def raise_if_unauthorized(
for role in roles:
match role:
case models.UserType.OCP:
if user.is_ocp():
if user.is_admin():
break
case models.UserType.FI:
if user.lender_id == application.lender_id:
Expand All @@ -102,14 +102,14 @@ def raise_if_unauthorized(
expired_at = application.expired_at
if expired_at and expired_at < datetime.now(expired_at.tzinfo):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=_("Application expired"),
)

if statuses:
if application.status not in statuses:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=_("Application status should not be %(status)s", status=_(application.status)),
)

Expand Down Expand Up @@ -168,7 +168,7 @@ def _get_application_as_guest_via_uuid(session: Session, uuid: str) -> models.Ap

if application.status == models.ApplicationStatus.LAPSED:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=_("Application lapsed"),
)

Expand Down
9 changes: 8 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from app.i18n import _
from app.routers import applications, downloads, guest, lenders, statistics, users
from app.settings import app_settings

Expand All @@ -21,3 +23,8 @@
app.include_router(downloads.router)
app.include_router(lenders.router)
app.include_router(statistics.router)


@app.exception_handler(500)
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse({"detail": _("An unexpected error occurred")}, status_code=500)
2 changes: 1 addition & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,7 @@ class UserBase(SQLModel):
default=datetime.utcnow(), sa_column=Column(DateTime(timezone=True), nullable=False, server_default=func.now())
)

def is_ocp(self) -> bool:
def is_admin(self) -> bool:
return self.type == UserType.OCP


Expand Down
23 changes: 6 additions & 17 deletions app/routers/applications.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import logging
from datetime import datetime
from typing import Any, cast

from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session, joinedload
Expand All @@ -13,8 +11,6 @@
from app.i18n import _
from app.util import SortOrder, get_order_by

logger = logging.getLogger(__name__)

router = APIRouter()


Expand Down Expand Up @@ -171,7 +167,7 @@ async def approve_application(
not_validated_fields.append(key)
if not_validated_fields:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=_("Some borrower data field are not verified"),
)

Expand All @@ -182,7 +178,7 @@ async def approve_application(
not_validated_documents.append(document.type)
if not_validated_documents:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=_("Some documents are not verified"),
)

Expand Down Expand Up @@ -458,7 +454,7 @@ async def get_application(
Retrieve an application by its ID.

:return: The application with the specified ID and its associated relations.
:raise: HTTPException with status code 401 if the user is not authorized to view the application.
:raise: HTTPException if the user is not authorized to view the application.
"""
return util.get_modified_data_fields(session, application)

Expand All @@ -485,7 +481,7 @@ async def start_application(

:param id: The ID of the application to start.
:return: The started application with its associated relations.
:raise: HTTPException with status code 401 if the user is not authorized to start the application.
:raise: HTTPException if the user is not authorized to start the application.
"""
with rollback_on_error(session):
application.status = models.ApplicationStatus.STARTED
Expand Down Expand Up @@ -576,14 +572,7 @@ async def email_borrower(
user_id=user.id,
)

try:
message_id = mail.send_mail_request_to_borrower(client.ses, application, payload.message)
except ClientError as e:
logger.exception(e)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=_("There was an error"),
)
message_id = mail.send_mail_request_to_borrower(client.ses, application, payload.message)
models.Message.create(
session,
application_id=application.id,
Expand Down Expand Up @@ -612,6 +601,6 @@ async def previous_contracts(
Get the previous awards associated with an application.

:return: A list of previous awards associated with the application.
:raise: HTTPException with status code 401 if the user is not authorized to access the application.
:raise: HTTPException if the user is not authorized to access the application.
"""
return application.previous_awards(session)
4 changes: 2 additions & 2 deletions app/routers/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def get_borrower_document(
session,
type=(
models.ApplicationActionType.OCP_DOWNLOAD_DOCUMENT
if user.is_ocp()
if user.is_admin()
else models.ApplicationActionType.FI_DOWNLOAD_DOCUMENT
),
data={"file_name": document.name},
Expand Down Expand Up @@ -116,7 +116,7 @@ async def download_application(
session,
type=(
models.ApplicationActionType.OCP_DOWNLOAD_APPLICATION
if user.is_ocp()
if user.is_admin()
else models.ApplicationActionType.FI_DOWNLOAD_APPLICATION
),
application_id=application.id,
Expand Down
Loading