Skip to content

Commit

Permalink
Merge branch 'main' into fastapi-conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
paulespinosa committed Oct 8, 2024
2 parents 6b5e65a + fff2022 commit 201e49f
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 628 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/run-tests-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ jobs:
with:
node-version: 20
cache: "npm"
cache-dependency-path: app/package-lock.json
cache-dependency-path: frontend/package-lock.json
- name: Run npm CI
run: npm ci
- name: Test app
- name: Test frontend
run: npm run test -- --no-color --run
- name: Run E2E tests
uses: cypress-io/github-action@v5
Expand Down
187 changes: 183 additions & 4 deletions backend/app/modules/access/auth_controller.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import logging
import random
import jwt
import boto3


from fastapi import Depends, APIRouter, HTTPException, Response, Request
from fastapi import Depends, APIRouter, HTTPException, Response, Request, Cookie
from fastapi.security import HTTPBearer
from fastapi.responses import RedirectResponse, JSONResponse
from botocore.exceptions import ClientError
from botocore.exceptions import ClientError, ParamValidationError

from app.modules.access.schemas import (
UserCreate, UserSignInRequest, UserSignInResponse, ForgotPasswordRequest, ConfirmForgotPasswordResponse,
ConfirmForgotPasswordRequest, RefreshTokenResponse)
ConfirmForgotPasswordRequest, RefreshTokenResponse, InviteRequest, UserRoleEnum, ConfirmInviteRequest, NewPasswordRequest)
from app.modules.workflow.models import ( UnmatchedGuestCase )

from app.modules.access.crud import create_user, delete_user, get_user
from app.modules.deps import (SettingsDep, DbSessionDep, CognitoIdpDep,
Expand Down Expand Up @@ -250,7 +252,7 @@ def refresh(request: Request,

@router.post(
"/forgot-password",
description="Handles forgot password requests by hashing credentialsand sending to AWS Cognito",
description="Handles forgot password requests by hashing credentials and sending to AWS Cognito",
)
def forgot_password(body: ForgotPasswordRequest,
settings: SettingsDep,
Expand Down Expand Up @@ -301,3 +303,180 @@ def confirm_forgot_password(body: ConfirmForgotPasswordRequest,
})

return {"message": "Password reset successful"}




@router.post("/invite",
description="Invites a new user to application after creating a new account with user email and a temporary password in AWS Cognito.",
)
def invite(body: InviteRequest,
request: Request,
settings: SettingsDep,
db: DbSessionDep,
cognito_client: CognitoIdpDep):

id_token = request.cookies.get('id_token')
refresh_token = request.cookies.get('refresh_token')

if None in (refresh_token, id_token):
raise HTTPException(status_code=401,
detail="Missing refresh token or id token")

decoded_id_token = jwt.decode(id_token,
algorithms=["RS256"],
options={"verify_signature": False})

coordinator_email = decoded_id_token.get('email')
if not coordinator_email:
raise HTTPException(status_code=401,
detail="Missing 'email' field in the decoded ID token.")

numbers = '0123456789'
lowercase_chars = 'abcdefghijklmnopqrstuvwxyz'
uppercase_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
symbols = '.-_~'
temporary_password = ''.join(random.choices(numbers, k=3)) + ''.join(random.choices(lowercase_chars, k=3)) + ''.join(random.choices(symbols, k=1)) + ''.join(random.choices(uppercase_chars, k=3))

try:
cognito_client.admin_create_user(
UserPoolId=settings.COGNITO_USER_POOL_ID,
Username=body.email,
TemporaryPassword=temporary_password,
ClientMetadata={
'url': settings.ROOT_URL
},
DesiredDeliveryMediums=["EMAIL"]
)

except ClientError as error:
if error.response['Error']['Code'] == 'UserNotFoundException':
raise HTTPException(status_code=400, detail="User not found. Confirmation not sent.")
else:
raise HTTPException(status_code=500, detail=error.response['Error']['Message'])

try:

user = create_user(db, UserCreate(
role=UserRoleEnum.GUEST,
email=body.email,
firstName=body.firstName,
middleName=body.middleName,
lastName=body.lastName
))
guest_id = user.id
coordinator = get_user(db, coordinator_email)
if not coordinator:
raise HTTPException(status_code=400, detail="Coordinator not found")
coordinator_id = coordinator.id

unmatched_case_repo = UnmatchedGuestCase(db)
unmatched_case_repo.add_case(
guest_id=guest_id,
coordinator_id=coordinator_id
)
except Exception as error:
raise HTTPException(status_code=400, detail=str(error))





@router.post("/confirm-invite", description="Confirms user invite by signing them in using the link sent to their email")
def confirm_invite(
body: ConfirmInviteRequest,
settings: SettingsDep,
cognito_client: CognitoIdpDep,
calc_secret_hash: SecretHashFuncDep
):
secret_hash = calc_secret_hash(body.email)

try:
auth_response = cognito_client.initiate_auth(
ClientId=settings.COGNITO_CLIENT_ID,
AuthFlow='USER_PASSWORD_AUTH',
AuthParameters={
'USERNAME': body.email,
'PASSWORD': body.password,
'SECRET_HASH': secret_hash
}
)

if auth_response.get('ChallengeName') == 'NEW_PASSWORD_REQUIRED':
userId = auth_response['ChallengeParameters']['USER_ID_FOR_SRP']
sessionId = auth_response['Session']
return RedirectResponse(f"{settings.ROOT_URL}/create-password?userId={userId}&sessionId={sessionId}")
else:
return RedirectResponse(f"{settings.ROOT_URL}/create-password?error=There was an unexpected error. Please try again.")

except ClientError as e:
error_code = e.response['Error']['Code']
error_messages = {
'NotAuthorizedException': "Incorrect username or password. Your invitation link may be invalid.",
'UserNotFoundException': "User not found. Confirmation not sent.",
'TooManyRequestsException': "Too many attempts to use invite in a short amount of time."
}
msg = error_messages.get(error_code, e.response['Error']['Message'])
raise HTTPException(status_code=400, detail={"code": error_code, "message": msg})
except ParamValidationError as e:
msg = f"The parameters you provided are incorrect: {e}"
raise HTTPException(status_code=400, detail={"code": "ParamValidationError", "message": msg})



@router.post("/new-password",
description="Removes auto generated password and replaces with user assigned password. Used for account setup.",
response_model=UserSignInResponse)
def new_password(
body: NewPasswordRequest,
response: Response,
settings: SettingsDep,
db: DbSessionDep,
cognito_client: CognitoIdpDep,
calc_secret_hash: SecretHashFuncDep
):

secret_hash = calc_secret_hash(body.userId)

try:
auth_response = cognito_client.respond_to_auth_challenge(
ClientId=settings.COGNITO_CLIENT_ID,
ChallengeName='NEW_PASSWORD_REQUIRED',
Session=body.sessionId,
ChallengeResponses={
'NEW_PASSWORD': body.password,
'USERNAME': body.userId,
'SECRET_HASH': secret_hash
},
)
except ClientError as e:
raise HTTPException(status_code=500, detail={
"code": e.response['Error']['Code'],
"message": e.response['Error']['Message']
})

access_token = auth_response['AuthenticationResult']['AccessToken']
refresh_token = auth_response['AuthenticationResult']['RefreshToken']
id_token = auth_response['AuthenticationResult']['IdToken']

decoded_id_token = jwt.decode(id_token,
algorithms=["RS256"],
options={"verify_signature": False})

try:
user = get_user(db, decoded_id_token['email'])
if user is None:
raise HTTPException(status_code=404, detail="User not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")

response.set_cookie("refresh_token", refresh_token, httponly=True)
response.set_cookie("id_token", id_token, httponly=True)

return {
"user": user,
"token": access_token
}



2 changes: 1 addition & 1 deletion backend/app/modules/access/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ class Role(Base):
id = Column(Integer, primary_key=True, index=True)
type = Column(String, nullable=False, unique=True)

users = relationship("User", back_populates="role")
users = relationship("User", back_populates="role")
20 changes: 20 additions & 0 deletions backend/app/modules/access/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ class ConfirmForgotPasswordResponse(BaseModel):
message: str


class InviteRequest(BaseModel):
email: EmailStr
firstName: str
middleName: str
lastName: str

class Cookies(BaseModel):
refresh_token: str
id_token: str

class ConfirmInviteRequest(BaseModel):
email: str
password: str

class NewPasswordRequest(BaseModel):
userId: str
password: str
sessionId: str


# class SmartNested(Nested):
# '''
# Schema attribute used to serialize nested attributes to
Expand Down
2 changes: 1 addition & 1 deletion frontend/cypress/e2e/create-new-password.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('Forgot Password', () => {
oneHourFromNow.setHours(oneHourFromNow.getHours() + 1);
const cookieExpiration = oneHourFromNow.toUTCString();

cy.intercept('POST', '/api/auth/new_password', {
cy.intercept('POST', '/api/auth/new-password', {
statusCode: 200,
headers: {
'Set-Cookie': `session=fake_session_value; expires=${cookieExpiration}; path=/`,
Expand Down
Loading

0 comments on commit 201e49f

Please sign in to comment.