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

Feature/678 add guid to gspc emails #761

Open
wants to merge 68 commits into
base: staging
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
29ae0e8
update UI portion to match api changes
weiwang-gsa Aug 16, 2023
5dfaa53
code clean up
weiwang-gsa Aug 17, 2023
7908a96
address test error
weiwang-gsa Aug 17, 2023
6f9231b
Merge pull request #291 from GSA/wwang/code-refactoring
weiwang-gsa Aug 17, 2023
0fb2cbf
Merge pull request #292 from GSA/wwang/code-refactoring-fix-revert
weiwang-gsa Aug 17, 2023
a0df75b
Merge pull request #294 from GSA/wwang/issue267
weiwang-gsa Aug 17, 2023
740f521
Merge pull request #299 from GSA/wwang/issue209
weiwang-gsa Aug 22, 2023
e9d2081
Merge pull request #301 from GSA/staging
weiwang-gsa Aug 23, 2023
97f17fd
Merge pull request #305 from GSA/staging
mark-meyer Aug 24, 2023
cb9c3c3
Merge pull request #306 from GSA/mmeyer/283-CORS-settings
mark-meyer Aug 24, 2023
ef8aa02
Merge pull request #335 from GSA/staging
weiwang-gsa Sep 12, 2023
c6d0a21
Merge pull request #337 from GSA/wwang/issue332
weiwang-gsa Sep 12, 2023
5b764a6
Merge pull request #351 from GSA/staging
weiwang-gsa Sep 14, 2023
8a713a8
Merge pull request #353 from GSA/staging
weiwang-gsa Sep 14, 2023
4627272
implement structured logs and use gunicorn to manage worker processes
mark-meyer Sep 19, 2023
88aa2f6
simplify documentation
mark-meyer Sep 20, 2023
dcd4010
Merge pull request #358 from GSA/mmeyer/structlogs
mark-meyer Sep 20, 2023
faf3c95
add gunicorn to requirements
mark-meyer Sep 20, 2023
fbab7da
Merge pull request #359 from GSA/mmeyer/structlogs
mark-meyer Sep 20, 2023
db75af6
use correct flags for gunicorn--sigh
mark-meyer Sep 20, 2023
0b58e9b
Merge pull request #360 from GSA/mmeyer/structlogs
mark-meyer Sep 20, 2023
711ad8a
try without gunicorn
mark-meyer Sep 20, 2023
84b1c6d
Merge pull request #361 from GSA/mmeyer/structlogs
mark-meyer Sep 20, 2023
9a5ba3f
spacificy stdout for log stream
mark-meyer Sep 20, 2023
6e616e0
Merge pull request #362 from GSA/mmeyer/structlogs
mark-meyer Sep 20, 2023
94ce37e
Merge pull request #366 from GSA/staging
weiwang-gsa Sep 21, 2023
dd3fc9d
Merge pull request #367 from GSA/wwang/issue364
weiwang-gsa Sep 22, 2023
d871fb3
Merge pull request #378 from GSA/mmeyer/increase-workers
mark-meyer Sep 26, 2023
5e135c6
Merge pull request #379 from GSA/mmeyer/increase-workers
mark-meyer Sep 26, 2023
e53a2fe
Merge pull request #380 from GSA/mmeyer/increase-workers
mark-meyer Sep 26, 2023
2709818
Merge pull request #382 from GSA/mmeyer/increase-workers
mark-meyer Sep 26, 2023
c83df71
Merge pull request #389 from GSA/wwang/issue363
weiwang-gsa Sep 27, 2023
0a7a97d
Merge pull request #387 from GSA/staging
weiwang-gsa Sep 27, 2023
479bcaa
Production Release (#609)
felder101 Jul 26, 2024
5ebfba1
#606 set session timeout to 15min.
john-labbate Jul 30, 2024
726fde2
Merge pull request #611 from GSA/gsa/production-release-2024-07-30
john-labbate Jul 31, 2024
2c1c4a7
Admin: Deleting Reporting Access in the UI is not deleting the record…
john-labbate Aug 8, 2024
6c2be8e
Merge pull request #614 from GSA/gsa/production-release
john-labbate Aug 13, 2024
bc3969d
Update USWDS to latest version 3.8.2 | Training #615
john-labbate Aug 22, 2024
8935f7b
Merge pull request #622 from GSA/gsa/production-release
john-labbate Aug 23, 2024
37afe3d
Coommit includes the following: (#634)
john-labbate Sep 6, 2024
de3c1ef
commit includes:: The system must collect each answer on each quiz ta…
john-labbate Sep 20, 2024
e64591a
Production Release (#662)
felder101 Oct 4, 2024
fb66afa
Merge main branch into dev
felder101 Oct 7, 2024
aa80fd9
Merge pull request #665 from GSA/technical/merge-main-into-dev
john-labbate Oct 8, 2024
0c44904
Production Release (Sprint 41) (#677)
felder101 Oct 18, 2024
76d497b
Sprint 42 (#691)
john-labbate Nov 1, 2024
d3cb254
Production Release (#708)
felder101 Dec 6, 2024
cb87b5b
Production Release (#721)
felder101 Dec 19, 2024
9ca102e
Gsa/production release (#731)
felder101 Jan 14, 2025
6f089f3
Production Release (#743)
felder101 Jan 24, 2025
42a7ced
Production Release (#756)
felder101 Feb 7, 2025
e9cb674
WIP testing performance changes.
john-labbate Feb 13, 2025
53f4cbc
Merge branch 'staging' of https://github.com/GSA/smartpay-training in…
john-labbate Feb 13, 2025
1ffdafc
WIP template out email background task
john-labbate Feb 14, 2025
9d0194d
extend background task timeout.
john-labbate Feb 14, 2025
faa5ab3
batch out bulk inset.
john-labbate Feb 18, 2025
c385678
add missing smtp quit.
john-labbate Feb 18, 2025
fcd22fb
Fix bulk save error.
john-labbate Feb 18, 2025
17eb732
method tweeks and more logging.
john-labbate Feb 18, 2025
0033ce8
Update Procfile
john-labbate Feb 21, 2025
be282bb
more logging!
john-labbate Feb 21, 2025
c75107c
Merge branch 'dev' into feature/678-add-guid-to-gspc-emails
john-labbate Feb 21, 2025
7650fcb
Merge pull request #765 from GSA/feature/678-add-guid-to-gspc-emails
john-labbate Feb 21, 2025
b16c33f
Update gspc_invite.py
john-labbate Feb 21, 2025
3570b51
fixed bug
john-labbate Feb 21, 2025
b4ce7b9
Merge branch 'feature/678-add-guid-to-gspc-emails' of https://github.…
john-labbate Feb 21, 2025
99a0d2f
Merge branch 'dev' of https://github.com/GSA/smartpay-training into f…
john-labbate Feb 21, 2025
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 Procfile
Original file line number Diff line number Diff line change
@@ -1 +1 @@
web: gunicorn -b :$PORT training.main:app --workers $NUM_WORKERS --worker-class uvicorn.workers.UvicornWorker
web: gunicorn -b :$PORT training.main:app --workers $NUM_WORKERS --worker-class uvicorn.workers.UvicornWorker --timeout 1200
59 changes: 59 additions & 0 deletions alembic/versions/db581ea0a1c3_gspc_updates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""gspc_updates

Revision ID: db581ea0a1c3
Revises: 12049328fd0a
Create Date: 2025-02-12 09:56:53.397539

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'db581ea0a1c3'
down_revision = '12049328fd0a'
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add new columns
op.add_column('gspc_invite', sa.Column('gspc_invite_id', sa.UUID(as_uuid=True), nullable=True))
op.add_column('gspc_invite', sa.Column('second_invite_date', sa.DateTime(timezone=True), nullable=True))
op.add_column('gspc_invite', sa.Column('final_invite_date', sa.DateTime(timezone=True), nullable=True))
op.add_column('gspc_invite', sa.Column('completed_date', sa.DateTime(timezone=True), nullable=True))

# Create unique index for gspc_invite_id
op.create_index(
'ix_gspc_invite_gspc_invite_id',
'gspc_invite',
['gspc_invite_id'],
unique=True
)

# Add foreign key column to gspc_completions
op.add_column('gspc_completions', sa.Column('gspc_invite_id', sa.UUID(as_uuid=True), nullable=True))

# Create foreign key constraint
op.create_foreign_key(
'gspc_completions_x_gspc_invite',
'gspc_completions',
'gspc_invite',
['gspc_invite_id'],
['gspc_invite_id']
)


def downgrade() -> None:
# Drop foreign key constraint and column from gspc_completions
op.drop_constraint(None, 'gspc_completions', type_='foreignkey')
op.drop_column('gspc_completions', 'gspc_invite_id')

# Drop index
op.drop_index('ix_gspc_invite_gspc_invite_id', table_name='gspc_invite')

# Drop columns
op.drop_column('gspc_invite', 'completed_date')
op.drop_column('gspc_invite', 'final_invite_date')
op.drop_column('gspc_invite', 'second_invite_date')
op.drop_column('gspc_invite', 'gspc_invite_id')
2 changes: 1 addition & 1 deletion dev/uaa/uaa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ scim:
users:
- paul|wombat|[email protected]|Paul|Smith|openid
- stefan|wallaby|[email protected]|Stefan|Schmidt|openid

- mark|wombat|[email protected]|Mark|openid
oauth:
user:
authorities:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"scripts": {
"build:frontend": "cd training-front-end && npm install && npm run build && cd ..",
"federalist": "npm run build:frontend",
"dev": "(trap 'kill 0' SIGINT; npm run dev:frontend & npm run dev:backend)",
"dev": "(npm run dev:frontend & npm run dev:backend)",
"dev:frontend": "cd training-front-end && npm run dev",
"dev:backend": "uvicorn training.main:app --reload",
"dev:db-start": "docker-compose up -d",
Expand Down
12 changes: 6 additions & 6 deletions training-front-end/src/components/GspcRegistration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@
const quizStarted = ref(false)
const quizSubmitted = ref(false)
const error = ref(props.error)
let redirectExpirationDateString = ""
let expirationDate = ""
let gspcInviteId = ""
let redirectGspcInviteIdString = ""
const certTypeGspc = 2

const questions =
Expand All @@ -63,8 +63,8 @@

onBeforeMount(async () => {
const urlParams = new URLSearchParams(window.location.search);
expirationDate = urlParams.get('expirationDate')
redirectExpirationDateString = 'expirationDate=' + expirationDate
gspcInviteId = urlParams.get('gspcInviteId')
redirectGspcInviteIdString = 'gspcInviteId=' + gspcInviteId
})

function startLoading() {
Expand All @@ -91,7 +91,7 @@
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.value.jwt}`
},
body: JSON.stringify({'responses':{'responses': user_answers}, 'expiration_date': expirationDate})
body: JSON.stringify({'responses':{'responses': user_answers}, 'gspc_invite_id': gspcInviteId})
})
} catch {
const err = new Error("There was a problem connecting with the server")
Expand Down Expand Up @@ -145,7 +145,7 @@
title="gspc_registration"
:header="header"
link-destination-text="the GSA SmartPay Program Certification (GSPC)"
:parameters="redirectExpirationDateString"
:parameters="redirectGspcInviteIdString"
@start-loading="startLoading"
@error="setError"
>
Expand Down
8 changes: 3 additions & 5 deletions training/api/api_v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,21 @@ def auth_exchange(
):
db_user = user_repo.find_by_email(uaa_user.get("email"))
if not db_user:
logging.info(
f"UAA authenticated, but not found in database: {uaa_user['email']}"
)
logging.info("UAA authenticated, but not found in database", extra={'user': uaa_user['email']})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid user."
)

user = User.model_validate(db_user)
if not user.is_admin():
logging.info(f"UAA authenticated, but not an admin: {uaa_user['email']}")
logging.info("UAA authenticated, but not an admin", extra={'user': uaa_user['email']})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to login."
)

jwt_user = UserJWT.model_validate(db_user)
encoded_jwt = jwt.encode(jwt_user.model_dump(), settings.JWT_SECRET, algorithm="HS256")
logging.info(f"Token exchange success for {db_user.email}")
logging.info("Token exchange success", extra={'user': db_user.email})
return {'user': jwt_user, 'jwt': encoded_jwt}
25 changes: 12 additions & 13 deletions training/api/api_v1/gspc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import asdict
from typing import Any
import logging
import csv
Expand All @@ -7,10 +8,10 @@
from training.services import GspcService
from training.repositories import GspcInviteRepository, GspcCompletionRepository
from training.api.deps import gspc_invite_repository, gspc_completion_repository, gspc_service
from training.api.email import send_gspc_invite_email
from training.api.email import InviteTuple, send_gspc_invite_emails
from training.api.auth import RequireRole
from training.config import settings
from training.api.auth import JWTUser
from fastapi import BackgroundTasks


router = APIRouter()
Expand All @@ -19,27 +20,25 @@
@router.post("/gspc-invite")
async def gspc_admin_invite(
gspcInvite: GspcInvite,
background_tasks: BackgroundTasks,
repo: GspcInviteRepository = Depends(gspc_invite_repository),
user=Depends(RequireRole(["Admin"]))
):
'''
Given a list of emails we parse them into two list (valid and invalid).
Then we log each of the valid emails to the db and shoot of an email to each.
Then we log each of the valid emails to the db and shoot off an email to each.
'''
try:
# Parse emails string into valid and invalid email list
gspcInvite.parse()

for email in gspcInvite.valid_emails:
repo.create(email=email, certification_expiration_date=gspcInvite.certification_expiration_date)
# If performance becomes an issue use multithreading to send the emails
try:
params = gspcInvite.certification_expiration_date.strftime('%Y-%m-%d')
link = f"{settings.BASE_URL}/gspc_registration/?expirationDate={params}"
send_gspc_invite_email(to_email=email, link=link)
logging.info(f"Sent gspc invite email to {email}")
except Exception as e:
logging.error("Error sending gspc invite email", e)
entities = repo.bulk_create(emails=gspcInvite.valid_emails, certification_expiration_date=gspcInvite.certification_expiration_date)

# Explicitly load needed props into memory before passing to the background task
entities_data = [InviteTuple(entity.gspc_invite_id, entity.email) for entity in entities]

# Add email sending to background tasks
background_tasks.add_task(send_gspc_invite_emails, invites=entities_data)

# Return object with both list for success and failure messages
return gspcInvite
Expand Down
8 changes: 5 additions & 3 deletions training/api/api_v1/loginless_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ def send_link(
# and try the link.
if not all(role in role_names for role in required_roles):
logging.info(
f"{user.email} does not have the required role to access {page_id_lookup[dest.page_id]['path']}"
"unauthorized access attempt",
extra={'user': user.email, 'path': page_id_lookup[dest.page_id]['path']}
)

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized"
Expand All @@ -114,7 +116,7 @@ def send_link(
url = f"{settings.BASE_URL}{path}?{parameters}"
try:
send_email(to_email=user.email, name=user.name, link=url, training_title=dest.title)
logging.info(f"Sent confirmation email to {user.email} for {path}")
logging.info("Sent confirmation email", extra={'user': user.email, 'path': path})
except Exception as e:
logging.error("Error sending mail", e)
raise HTTPException(
Expand Down Expand Up @@ -151,6 +153,6 @@ async def get_user(
if not db_user:
db_user = repo.create(user)
user_return = UserJWT.model_validate(db_user)
logging.info(f"Confirmed email token for {user.email}")
logging.info("Confirmed email token", extra={'user': user.email})
encoded_jwt = jwt.encode(user_return.model_dump(), settings.JWT_SECRET, algorithm="HS256")
return {'user': user_return, 'jwt': encoded_jwt}
30 changes: 19 additions & 11 deletions training/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging
from typing import Annotated
from urllib.request import urlopen

Expand Down Expand Up @@ -29,6 +30,7 @@ async def __call__(self, request: Request):

user = self.decode_jwt(credentials.credentials)
if user is None:
JWTUser.log_invalid_jwt(credentials.credentials, request.url.path)
raise HTTPException(status_code=403, detail="Invalid or expired token.")
return user

Expand All @@ -38,27 +40,28 @@ def decode_jwt(self, token: str):
except InvalidTokenError:
return

@staticmethod
def log_invalid_jwt(token: str, path: str):
try:
invalid_claim = jwt.decode(token, options={"verify_signature": False})
logging.info("Invalid token", extra={'decoded': invalid_claim, 'path': path})
except InvalidTokenError:
logging.warning("Unprocessable token", extra={'token': token, 'path': path})


class UAAJWTUser(HTTPBearer):
class UAAJWTUser(JWTUser):
'''
Represents a JWT issued by an OAuth server.
Used as part of the Admin SecureAuth flow
'''

async def __call__(self, request: Request):

credentials: HTTPAuthorizationCredentials | None = await super().__call__(request)
user = self.decode_jwt(credentials.credentials)
if user is None:
raise HTTPException(status_code=403, detail="Invalid or expired token.")
return user

def decode_jwt(self, token: str):
token_header = jwt.get_unverified_header(token)
key_id = token_header.get("kid")
jwk = self.get_jwks().get(key_id)

if jwk is None:
logging.info("Unknown jwk", extra={'key_id': key_id})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Unrecognized token."
Expand Down Expand Up @@ -88,6 +91,7 @@ def get_jwks(self):
jwks = json.load(res)

if jwks.get("keys") is None:
logging.warning("Unable to get JSON Web keys from server")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Unable to get required data from authentication server (public keys)."
Expand Down Expand Up @@ -118,6 +122,7 @@ def discover_jwks_endpoint(self) -> str:
jwks_endpoint_uri = data.get("jwks_uri")

if jwks_endpoint_uri is None:
logging.warning("Unable to get jwks endpoint from server")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Unable to get required data from authentication server (JWKS URI)."
Expand All @@ -144,15 +149,17 @@ def __call__(self, user=Depends(JWTUser())):
try:
user_roles = user['roles']
except KeyError:
logging.info("Unauthorized Access", extra={'user': user, 'required_roles': self.required_roles})
raise HTTPException(status_code=401, detail="Not Authorized")

if all(role in user_roles for role in self.required_roles):
return user
else:
logging.info("Unauthorized Access", extra={'user': user, 'required_roles': self.required_roles})
raise HTTPException(status_code=401, detail="Not Authorized")


def user_from_form(jwtToken: Annotated[str, Form()]):
def user_from_form(jwtToken: Annotated[str, Form()], request: Request):
'''
This allows POST requests to send a token as part of form-encoded request.
There are places in the front-end where we want to download a file, but we also
Expand All @@ -163,5 +170,6 @@ def user_from_form(jwtToken: Annotated[str, Form()]):
'''
try:
return jwt.decode(jwtToken, settings.JWT_SECRET, algorithms=["HS256"])
except jwt.exceptions.InvalidTokenError:
except InvalidTokenError:
JWTUser.log_invalid_jwt(jwtToken, request.url.path)
raise HTTPException(status_code=401, detail="Not Authorized")
Loading