diff --git a/Dockerfile_backend b/Dockerfile_backend index 0376960c4..162f2a603 100644 --- a/Dockerfile_backend +++ b/Dockerfile_backend @@ -32,6 +32,7 @@ ENV DB_DATABASE="endurain" ENV SECRET_KEY="changeme" ENV ALGORITHM="HS256" ENV ACCESS_TOKEN_EXPIRE_MINUTES=30 +ENV REFRESH_TOKEN_EXPIRE_DAYS=7 ENV STRAVA_CLIENT_ID="changeme" ENV STRAVA_CLIENT_SECRET="changeme" ENV STRAVA_AUTH_CODE="changeme" diff --git a/README.md b/README.md index f51d3347e..30fd561ef 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ DB_USER | endurain | Yes | N/A DB_PASSWORD | changeme | `No` | N/A DB_DATABASE | endurain | Yes | N/A SECRET_KEY | changeme | `No` | N/A -ALGORITHM | HS256 | Yes | N/A -ACCESS_TOKEN_EXPIRE_MINUTES | 30 | Yes | N/A +ALGORITHM | HS256 | Yes | Currently only HS256 is supported +ACCESS_TOKEN_EXPIRE_MINUTES | 15 | Yes | N/A +REFRESH_TOKEN_EXPIRE_DAYS | 7 | Yes | N/A STRAVA_CLIENT_ID | changeme | `No` | N/A STRAVA_CLIENT_SECRET | changeme | `No` | N/A STRAVA_AUTH_CODE | changeme | `No` | N/A diff --git a/backend/alembic/versions/ab815ee3beae_remove_photo_path_aux_column_from_users_.py b/backend/alembic/versions/ab815ee3beae_remove_photo_path_aux_column_from_users_.py new file mode 100644 index 000000000..741cef3e1 --- /dev/null +++ b/backend/alembic/versions/ab815ee3beae_remove_photo_path_aux_column_from_users_.py @@ -0,0 +1,55 @@ +"""Remove photo_path_aux column from users table + +Revision ID: ab815ee3beae +Revises: 0ab200a7f196 +Create Date: 2024-06-07 21:40:28.789274 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = 'ab815ee3beae' +down_revision: Union[str, None] = '0ab200a7f196' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'photo_path_aux') + + # Update admin password + new_password = '$2b$12$.b.fWl/Bu/sIx/6ZtIgJPuXaqfqkIi8NwnxeP6SQiQPZB3kKxD5tm' # replace this with the actual new password + op.execute( + sa.text( + """ + UPDATE users + SET password = :new_password + WHERE username = 'admin' + """ + ).bindparams(new_password=new_password) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('photo_path_aux', mysql.VARCHAR(length=250), nullable=True, comment='Auxiliary photo path')) + + old_password = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918' # replace this with the actual old password + op.execute( + sa.text( + """ + UPDATE users + SET password = :old_password + WHERE username = 'admin' + """ + ).bindparams(old_password=old_password) + ) + + # ### end Alembic commands ### diff --git a/backend/constants.py b/backend/constants.py index 68e75d624..3617be5fb 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -5,7 +5,8 @@ API_VERSION = "v0.2.1" # JWT Token constants JWT_ALGORITHM = os.environ.get("ALGORITHM") -JWT_EXPIRATION_IN_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES")) +JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES")) +JWT_REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS")) JWT_SECRET_KEY = os.environ.get("SECRET_KEY") # Scopes definition diff --git a/backend/crud/crud_users.py b/backend/crud/crud_users.py index b0531be87..8c9d31cef 100644 --- a/backend/crud/crud_users.py +++ b/backend/crud/crud_users.py @@ -34,13 +34,13 @@ def format_user_birthdate(user): return user -def authenticate_user(username: str, password: str, db: Session): +def authenticate_user(username: str, db: Session): try: # Get the user from the database user = ( db.query(models.User) .filter( - models.User.username == username, models.User.password == password + models.User.username == username ) .first() ) diff --git a/backend/dependencies/dependencies_security.py b/backend/dependencies/dependencies_security.py new file mode 100644 index 000000000..85751e5ab --- /dev/null +++ b/backend/dependencies/dependencies_security.py @@ -0,0 +1,136 @@ +import logging + +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +# import the jwt module from the joserfc package +from joserfc import jwt +from joserfc.jwk import OctKey + +from constants import ( + JWT_ALGORITHM, + JWT_SECRET_KEY, + ADMIN_ACCESS, +) + + +# Define the OAuth2 scheme for handling bearer tokens +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +# Define a loggger created on main.py +logger = logging.getLogger("myLogger") + + +def decode_token(token: Annotated[str, Depends(oauth2_scheme)]): + try: + # Decode the token and return the payload + return jwt.decode(token, OctKey.import_key(JWT_SECRET_KEY)) + except Exception: + # Log the error and raise the exception + logger.info("Unable to decode token | Returning 401 response") + + # Raise an HTTPException with a 401 Unauthorized status code + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unable to decode token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def validate_token_expiration(token: Annotated[str, Depends(oauth2_scheme)]): + # Try to decode the token and check if it is expired + try: + # Decode the token + # Mark exp claim as required + claims_requests = jwt.JWTClaimsRegistry(exp={"essential": True}) + + # decodes the token + payload = decode_token(token) + + # Validate token exp + claims_requests.validate(payload.claims) + except Exception: + # Log the error and raise the exception + logger.info("Token expired during validation | Returning 401 response") + + # Raise an HTTPException with a 401 Unauthorized status code + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token no longer valid", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_token_scopes(token: Annotated[str, Depends(oauth2_scheme)]): + # Decode the token + payload = decode_token(token) + + try: + # Get the scopes from the payload and return it + return payload.claims["scopes"] + except Exception: + # Log the error and raise the exception + logger.info("Scopes not present in token | Returning 401 response") + + # Raise an HTTPException with a 401 Unauthorized status code + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Scopes not present in token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_token_user_id(token: Annotated[str, Depends(oauth2_scheme)]): + # Decode the token + payload = decode_token(token) + + try: + # Get the user id from the payload and return it + return payload.claims["id"] + except Exception: + # Log the error and raise the exception + logger.info("Claim with user ID not present in token | Returning 401 response") + + # Raise an HTTPException with a 401 Unauthorized status code + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Claim with user ID not present in token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_token_access_type(token: Annotated[str, Depends(oauth2_scheme)]): + # Decode the token + payload = decode_token(token) + + try: + # Get the user access_type from the payload and return it + return payload.claims["access_type"] + except Exception: + # Log the error and raise the exception + logger.info( + "Claim with user access Type not present in token | Returning 401 response" + ) + + # Raise an HTTPException with a 401 Unauthorized status code + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Claim with user access Type not present in token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def validate_token_admin_access(token: Annotated[str, Depends(oauth2_scheme)]): + if get_token_access_type(token) != ADMIN_ACCESS: + # Raise an HTTPException with a 403 Forbidden status code + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Unauthorized Access - Admin Access Required", + ) + + +def create_token(data: dict): + # Encode the data and return the token + return jwt.encode({"alg": JWT_ALGORITHM}, data.copy(), JWT_SECRET_KEY) \ No newline at end of file diff --git a/backend/dependencies/dependencies_session.py b/backend/dependencies/dependencies_session.py index 9de4ade19..f1d2ec203 100644 --- a/backend/dependencies/dependencies_session.py +++ b/backend/dependencies/dependencies_session.py @@ -1,36 +1,99 @@ -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi import Depends, HTTPException, status, Request, Security +from fastapi.security import OAuth2PasswordBearer, SecurityScopes from sqlalchemy.orm import Session -from dependencies import dependencies_database -from schemas import schema_access_tokens, schema_users +from typing import Annotated + +from dependencies import dependencies_database, dependencies_security +from schemas import schema_users # Define the OAuth2 scheme for handling bearer tokens oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -def validate_token(token: str = Depends(oauth2_scheme), db: Session = Depends(dependencies_database.get_db)): + +### Dependencies for access token validation + +def validate_access_token(request: Request): + # Extract the access token from the cookies + access_token = request.cookies.get("endurain_access_token") + if not access_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access token missing", + headers={"WWW-Authenticate": "Bearer"}, + ) + # Validate the token expiration - schema_access_tokens.validate_token_expiration(db, token) + dependencies_security.validate_token_expiration(access_token) -def validate_token_and_get_authenticated_user_id( - token: str = Depends(oauth2_scheme), db: Session = Depends(dependencies_database.get_db) +def validate_token_and_return_access_token(request: Request): + # Extract the access token from the cookies + access_token = request.cookies.get("endurain_access_token") + if not access_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access token missing", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Validate the token expiration + dependencies_security.validate_token_expiration(access_token) + + # Return token + return access_token + + +def validate_access_token_and_get_authenticated_user_id( + access_token: Annotated[str, Depends(validate_token_and_return_access_token)], ): - # Validate the token expiration - schema_access_tokens.validate_token_expiration(db, token) - # Return the user ID associated with the token - return schema_access_tokens.get_token_user_id(token) + return dependencies_security.get_token_user_id(access_token) + + +def validate_access_token_and_validate_admin_access( + access_token: Annotated[str, Depends(validate_token_and_return_access_token)], +): + # Check if the token has admin access + dependencies_security.validate_token_admin_access(access_token) + + +### Dependencies for refresh token validation + + +def validate_token_and_return_refresh_token(request: Request): + # Extract the refresh token from the cookies + refresh_token = request.cookies.get("endurain_refresh_token") + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Refresh token missing", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Validate the token expiration + dependencies_security.validate_token_expiration(refresh_token) + + # Return token + return refresh_token + + +def validate_refresh_token_and_get_authenticated_user_id( + refresh_token: Annotated[str, Depends(validate_token_and_return_refresh_token)] +): + # Return the user ID associated with the token + return dependencies_security.get_token_user_id(refresh_token) def validate_token_and_validate_admin_access( - token: str = Depends(oauth2_scheme), db: Session = Depends(dependencies_database.get_db) + token: str = Depends(oauth2_scheme), + db: Session = Depends(dependencies_database.get_db), ): # Validate the token expiration - schema_access_tokens.validate_token_expiration(db, token) + dependencies_security.validate_token_expiration(token) # Check if the token has admin access - schema_access_tokens.validate_token_admin_access(token) + dependencies_security.validate_token_admin_access(token) def validate_token_and_if_user_id_equals_token_user_id_if_not_validate_admin_access( @@ -39,7 +102,7 @@ def validate_token_and_if_user_id_equals_token_user_id_if_not_validate_admin_acc db: Session = Depends(dependencies_database.get_db), ): # Validate the token expiration - schema_access_tokens.validate_token_expiration(db, token) + dependencies_security.validate_token_expiration(token) # Check if user_id higher than 0 if not (int(user_id) > 0): @@ -49,9 +112,9 @@ def validate_token_and_if_user_id_equals_token_user_id_if_not_validate_admin_acc ) # Check if token id is different from user id. If yes, check if the token has admin access - if user_id != schema_access_tokens.get_token_user_id(token): + if user_id != dependencies_security.get_token_user_id(token): # Check if the token has admin access - schema_access_tokens.validate_token_admin_access(token) + dependencies_security.validate_token_admin_access(token) def validate_token_and_if_user_id_equals_token_user_attributtes_id_if_not_validate_admin_access( @@ -72,7 +135,7 @@ def validate_token_and_if_user_id_equals_token_user_attributtes_password_id_if_n def validate_token_user_id_admin_access(db, token, user_id): # Validate the token expiration - schema_access_tokens.validate_token_expiration(db, token) + dependencies_security.validate_token_expiration(token) # Check if user_id higher than 0 if not (int(user_id) > 0): @@ -82,6 +145,26 @@ def validate_token_user_id_admin_access(db, token, user_id): ) # Check if token id is different from user id. If yes, check if the token has admin access - if user_id != schema_access_tokens.get_token_user_id(token): + if user_id != dependencies_security.get_token_user_id(token): # Check if the token has admin access - schema_access_tokens.validate_token_admin_access(token) \ No newline at end of file + dependencies_security.validate_token_admin_access(token) + + +def check_scopes( + security_scopes: SecurityScopes, + # scopes: Annotated[list[str], Depends(dependencies_security.get_token_scopes)], + access_token: Annotated[str, Depends(validate_token_and_return_access_token)], +): + # Get the scopes from the token + scopes = dependencies_security.get_token_scopes(access_token) + + # Check if the token has the required scopes + for scope in security_scopes.scopes: + if scope not in scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Unauthorized Access - Not enough permissions - scope={security_scopes.scopes}", + headers={ + "WWW-Authenticate": f'Bearer scope="{security_scopes.scopes}"' + }, + ) diff --git a/backend/main.py b/backend/main.py index b00b8ad8c..6b2ccf657 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,7 +27,6 @@ from routers import ( router_strava, ) from constants import API_VERSION -from schemas import schema_access_tokens from database import SessionLocal from processors import strava_processor, strava_activity_processor @@ -102,6 +101,7 @@ required_env_vars = [ "SECRET_KEY", "ALGORITHM", "ACCESS_TOKEN_EXPIRE_MINUTES", + "REFRESH_TOKEN_EXPIRE_DAYS", "STRAVA_CLIENT_ID", "STRAVA_CLIENT_SECRET", "STRAVA_AUTH_CODE", diff --git a/backend/models.py b/backend/models.py index 5e17c461e..d783c246a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -90,9 +90,6 @@ class User(Base): Integer, nullable=False, comment="User type (one digit)(1 - user, 2 - admin)" ) photo_path = Column(String(length=250), nullable=True, comment="User photo path") - photo_path_aux = Column( - String(length=250), nullable=True, comment="Auxiliary photo path" - ) is_active = Column( Integer, nullable=False, comment="Is user active (1 - active, 2 - not active)" ) diff --git a/backend/routers/router_activities.py b/backend/routers/router_activities.py index c2cf0ef52..dc2772533 100644 --- a/backend/routers/router_activities.py +++ b/backend/routers/router_activities.py @@ -18,6 +18,7 @@ from dependencies import ( dependencies_activities, dependencies_gear, dependencies_global, + dependencies_security, ) from processors import gpx_processor, fit_processor @@ -45,7 +46,9 @@ async def read_activities_useractivities_week( ], token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), ], db: Session = Depends(dependencies_database.get_db), ): @@ -84,7 +87,9 @@ async def read_activities_useractivities_thisweek_distances( validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), ], db: Session = Depends(dependencies_database.get_db), ): @@ -125,7 +130,7 @@ async def read_activities_useractivities_thismonth_distances( validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id), ], db: Session = Depends(dependencies_database.get_db), ): @@ -165,7 +170,7 @@ async def read_activities_useractivities_thismonth_number( validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id), ], db: Session = Depends(dependencies_database.get_db), ): @@ -214,7 +219,9 @@ async def read_activities_gearactivities( db: Session = Depends(dependencies_database.get_db), ): # Get the activities for the gear - return crud_activities.get_user_activities_by_gear_id_and_user_id(user_id, gear_id, db) + return crud_activities.get_user_activities_by_gear_id_and_user_id( + user_id, gear_id, db + ) @router.get( @@ -225,7 +232,7 @@ async def read_activities_gearactivities( async def read_activities_useractivities_number( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Annotated[Callable, Depends(dependencies_session.validate_token)], + validate_token: Annotated[Callable, Depends(dependencies_security.validate_token_expiration)], db: Session = Depends(dependencies_database.get_db), ): # Get the number of activities for the user @@ -252,7 +259,7 @@ async def read_activities_useractivities_pagination( validate_pagination_values: Annotated[ Callable, Depends(dependencies_global.validate_pagination_values) ], - validate_token: Annotated[Callable, Depends(dependencies_session.validate_token)], + validate_token: Annotated[Callable, Depends(dependencies_security.validate_token_expiration)], db: Session = Depends(dependencies_database.get_db), ): # Get the activities for the user with pagination @@ -281,7 +288,7 @@ async def read_activities_followed_user_activities_pagination( validate_pagination_values: Annotated[ Callable, Depends(dependencies_global.validate_pagination_values) ], - validate_token: Annotated[Callable, Depends(dependencies_session.validate_token)], + validate_token: Annotated[Callable, Depends(dependencies_security.validate_token_expiration)], db: Session = Depends(dependencies_database.get_db), ): # Get the activities for the following users with pagination @@ -298,7 +305,7 @@ async def read_activities_followed_user_activities_pagination( async def read_activities_followed_useractivities_number( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Annotated[Callable, Depends(dependencies_session.validate_token)], + validate_token: Annotated[Callable, Depends(dependencies_security.validate_token_expiration)], db: Session = Depends(dependencies_database.get_db), ): # Get the number of activities for the following users @@ -324,12 +331,14 @@ async def read_activities_activity_from_id( ], token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id), ], db: Session = Depends(dependencies_database.get_db), ): # Get the activity from the database and return it - return crud_activities.get_activity_by_id_from_user_id_or_has_visibility(activity_id, token_user_id, db) + return crud_activities.get_activity_by_id_from_user_id_or_has_visibility( + activity_id, token_user_id, db + ) @router.get( @@ -341,7 +350,7 @@ async def read_activities_contain_name( name: str, token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id), ], db: Session = Depends(dependencies_database.get_db), ): @@ -358,7 +367,7 @@ async def read_activities_contain_name( async def create_activity_with_uploaded_file( token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id), ], file: UploadFile, db: Session = Depends(dependencies_database.get_db), @@ -425,7 +434,8 @@ async def create_activity_with_uploaded_file( ) from err -@router.put("/activities/{activity_id}/addgear/{gear_id}", +@router.put( + "/activities/{activity_id}/addgear/{gear_id}", tags=["activities"], ) async def activity_add_gear( @@ -435,7 +445,9 @@ async def activity_add_gear( ], gear_id: int, validate_gear_id: Annotated[Callable, Depends(dependencies_gear.validate_gear_id)], - token_user_id: Annotated[int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id)], + token_user_id: Annotated[ + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) + ], db: Session = Depends(dependencies_database.get_db), ): # Get the gear by user id and gear id @@ -447,9 +459,11 @@ async def activity_add_gear( status_code=status.HTTP_404_NOT_FOUND, detail=f"Gear ID {gear_id} for user {token_user_id} not found", ) - - # Get the activity by id from user id - activity = crud_activities.get_activity_by_id_from_user_id(activity_id, token_user_id, db) + + # Get the activity by id from user id + activity = crud_activities.get_activity_by_id_from_user_id( + activity_id, token_user_id, db + ) # Check if activity is None and raise an HTTPException with a 404 Not Found status code if it is if activity is None: @@ -460,11 +474,13 @@ async def activity_add_gear( # Add the gear to the activity crud_activities.add_gear_to_activity(activity_id, gear_id, db) - + # Return success message return {"detail": f"Gear ID {gear_id} added to activity successfully"} -@router.put("/activities/{activity_id}/deletegear", + +@router.put( + "/activities/{activity_id}/deletegear", tags=["activities"], ) async def delete_activity_gear( @@ -472,11 +488,15 @@ async def delete_activity_gear( validate_activity_id: Annotated[ Callable, Depends(dependencies_activities.validate_activity_id) ], - token_user_id: Annotated[int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id)], + token_user_id: Annotated[ + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) + ], db: Session = Depends(dependencies_database.get_db), ): - # Get the activity by id from user id - activity = crud_activities.get_activity_by_id_from_user_id(activity_id, token_user_id, db) + # Get the activity by id from user id + activity = crud_activities.get_activity_by_id_from_user_id( + activity_id, token_user_id, db + ) # Check if activity is None and raise an HTTPException with a 404 Not Found status code if it is if activity is None: @@ -487,11 +507,13 @@ async def delete_activity_gear( # Delete gear from the activity crud_activities.add_gear_to_activity(activity_id, None, db) - + # Return success message return {"detail": f"Gear ID {activity.gear_id} deleted from activity successfully"} -@router.delete("/activities/{activity_id}/delete", + +@router.delete( + "/activities/{activity_id}/delete", tags=["activities"], ) async def delete_activity( @@ -499,11 +521,15 @@ async def delete_activity( validate_activity_id: Annotated[ Callable, Depends(dependencies_activities.validate_activity_id) ], - token_user_id: Annotated[int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id)], + token_user_id: Annotated[ + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) + ], db: Session = Depends(dependencies_database.get_db), ): - # Get the activity by id from user id - activity = crud_activities.get_activity_by_id_from_user_id(activity_id, token_user_id, db) + # Get the activity by id from user id + activity = crud_activities.get_activity_by_id_from_user_id( + activity_id, token_user_id, db + ) # Check if activity is None and raise an HTTPException with a 404 Not Found status code if it is if activity is None: @@ -511,10 +537,9 @@ async def delete_activity( status_code=status.HTTP_404_NOT_FOUND, detail=f"Activity ID {activity_id} for user {token_user_id} not found", ) - + # Delete the activity crud_activities.delete_activity(activity_id, db) - + # Return success message return {"detail": f"Activity {activity_id} deleted successfully"} - \ No newline at end of file diff --git a/backend/routers/router_activity_streams.py b/backend/routers/router_activity_streams.py index b988c03af..3152b46a1 100644 --- a/backend/routers/router_activity_streams.py +++ b/backend/routers/router_activity_streams.py @@ -10,7 +10,7 @@ from schemas import schema_activity_streams from crud import crud_activity_streams from dependencies import ( dependencies_database, - dependencies_session, + dependencies_security, dependencies_activities, dependencies_activity_streams, ) @@ -36,7 +36,7 @@ async def read_activities_streams_for_activity_all( Callable, Depends(dependencies_activities.validate_activity_id) ], validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token) + Callable, Depends(dependencies_security.validate_token_expiration) ], db: Session = Depends(dependencies_database.get_db), ): @@ -59,7 +59,7 @@ async def read_activities_streams_for_activity_stream_type( Callable, Depends(dependencies_activity_streams.validate_activity_stream_type) ], validate_token: Annotated[ - Callable, Depends(dependencies_session.validate_token) + Callable, Depends(dependencies_security.validate_token_expiration) ], db: Session = Depends(dependencies_database.get_db), ): diff --git a/backend/routers/router_followers.py b/backend/routers/router_followers.py index d5ccf5c1f..3df4c361d 100644 --- a/backend/routers/router_followers.py +++ b/backend/routers/router_followers.py @@ -10,7 +10,7 @@ from schemas import schema_followers from crud import crud_followers from dependencies import ( dependencies_database, - dependencies_session, + dependencies_security, dependencies_users, ) @@ -32,7 +32,7 @@ logger = logging.getLogger("myLogger") async def get_user_follower_all( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followers @@ -47,7 +47,7 @@ async def get_user_follower_all( async def get_user_follower_count_all( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followers @@ -69,7 +69,7 @@ async def get_user_follower_count_all( async def get_user_follower_count( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followers @@ -91,7 +91,7 @@ async def get_user_follower_count( async def get_user_following_all( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followings @@ -106,7 +106,7 @@ async def get_user_following_all( async def get_user_following_count_all( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followings @@ -128,7 +128,7 @@ async def get_user_following_count_all( async def get_user_following_count( user_id: int, validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return followings @@ -154,7 +154,7 @@ async def read_followers_user_specific_user( validate_target_user_id: Annotated[ Callable, Depends(dependencies_users.validate_target_user_id) ], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Return the follower @@ -175,7 +175,7 @@ async def create_follow( validate_target_user_id: Annotated[ Callable, Depends(dependencies_users.validate_target_user_id) ], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Create the follower @@ -195,7 +195,7 @@ async def accept_follow( validate_target_user_id: Annotated[ Callable, Depends(dependencies_users.validate_target_user_id) ], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Accept the follower @@ -216,7 +216,7 @@ async def delete_follow( validate_target_user_id: Annotated[ Callable, Depends(dependencies_users.validate_target_user_id) ], - validate_token: Callable = Depends(dependencies_session.validate_token), + validate_token: Callable = Depends(dependencies_security.validate_token_expiration), db: Session = Depends(dependencies_database.get_db), ): # Delete the follower diff --git a/backend/routers/router_gear.py b/backend/routers/router_gear.py index db6665e90..57b893d32 100644 --- a/backend/routers/router_gear.py +++ b/backend/routers/router_gear.py @@ -34,7 +34,7 @@ async def read_gear_id( gear_id: int, validate_gear_id: Annotated[Callable, Depends(dependencies_gear.validate_gear_id)], user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Annotated[Session, Depends(dependencies_database.get_db)], ): @@ -54,7 +54,7 @@ async def read_gear_user_pagination( Callable, Depends(dependencies_global.validate_pagination_values) ], user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -71,7 +71,7 @@ async def read_gear_user_pagination( ) async def read_gear_user_number( user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -94,7 +94,7 @@ async def read_gear_user_number( async def read_gear_user_by_nickname( nickname: str, user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -111,7 +111,7 @@ async def read_gear_user_by_type( gear_type: int, validate_type: Annotated[Callable, Depends(dependencies_gear.validate_gear_type)], user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -127,7 +127,7 @@ async def read_gear_user_by_type( async def create_gear( gear: schema_gear.Gear, user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -144,7 +144,7 @@ async def edit_gear( validate_id: Annotated[Callable, Depends(dependencies_gear.validate_gear_id)], gear: schema_gear.Gear, token_user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -170,7 +170,7 @@ async def delete_user( gear_id: int, validate_id: Annotated[Callable, Depends(dependencies_gear.validate_gear_id)], token_user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): diff --git a/backend/routers/router_session.py b/backend/routers/router_session.py index e10381e90..dce2c6aec 100644 --- a/backend/routers/router_session.py +++ b/backend/routers/router_session.py @@ -1,26 +1,36 @@ import logging +import bcrypt -from datetime import datetime, timedelta -from typing import Annotated, Callable +from datetime import datetime, timedelta, timezone +from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status, Security -from fastapi.security import ( - OAuth2PasswordBearer, - OAuth2PasswordRequestForm, - SecurityScopes, +from fastapi import ( + APIRouter, + Depends, + HTTPException, + status, + Response, + Request, ) +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session -from crud import crud_user_integrations, crud_users -from schemas import schema_access_tokens, schema_users +from crud import crud_users +from schemas import schema_users from constants import ( USER_NOT_ACTIVE, REGULAR_ACCESS, REGULAR_ACCESS_SCOPES, ADMIN_ACCESS_SCOPES, SCOPES_DICT, + JWT_ACCESS_TOKEN_EXPIRE_MINUTES, + JWT_REFRESH_TOKEN_EXPIRE_DAYS, +) +from dependencies import ( + dependencies_database, + dependencies_session, + dependencies_security, ) -from dependencies import dependencies_database, dependencies_session # Define the OAuth2 scheme for handling bearer tokens oauth2_scheme = OAuth2PasswordBearer( @@ -35,60 +45,108 @@ router = APIRouter() logger = logging.getLogger("myLogger") +def hash_password(password: str): + # Hash the password and return it + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + + +def verify_password(plain_password: str, hashed_password: str): + # Check if the password is equal to the hashed password + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + def authenticate_user(username: str, password: str, db: Session): # Get the user from the database - user = crud_users.authenticate_user(username, password, db) + user = crud_users.authenticate_user(username, db) - # Check if the user exists and if the password is correct and if not return False + # Check if the user exists and if the hashed_password is correct and if not return False if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="Incorrect username", headers={"WWW-Authenticate": "Bearer"}, ) - # Return the user if the password is correct + if not verify_password(password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Return the user if the hashed_password is correct return user -def get_current_user(db: Session, user_id: int): - # Get the user from the database - user = crud_users.get_user_by_id(user_id, db) +def create_response_with_tokens(response: Response, user: schema_users.User): + # Check user access level and set scopes accordingly + if user.access_type == REGULAR_ACCESS: + scopes = REGULAR_ACCESS_SCOPES + else: + scopes = ADMIN_ACCESS_SCOPES - # If the user does not exist raise the exception - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials (user not found)", - headers={"WWW-Authenticate": "Bearer"}, - ) - - user_integrations = crud_user_integrations.get_user_integrations_by_user_id( - user.id, db + # Create the access and refresh tokens + access_token = dependencies_security.create_token( + data={ + "sub": user.username, + "scopes": scopes, + "id": user.id, + "access_type": user.access_type, + "exp": datetime.now(timezone.utc) + + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES), + }, ) - if user_integrations is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials (user integrations not found)", - headers={"WWW-Authenticate": "Bearer"}, - ) + refresh_token = dependencies_security.create_token( + data={ + "sub": user.username, + "scopes": "scopes", + "id": user.id, + "access_type": user.access_type, + "exp": datetime.now(timezone.utc) + + timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS), + }, + ) - if user_integrations.strava_token is None: - user.is_strava_linked = 0 - else: - user.is_strava_linked = 1 + # Set the cookies with the tokens + response.set_cookie( + key="endurain_access_token", + value=access_token, + expires=datetime.now(timezone.utc) + + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES), + httponly=True, + path="/", + secure=False, + samesite="None", + ) + response.set_cookie( + key="endurain_refresh_token", + value=refresh_token, + expires=datetime.now(timezone.utc) + + timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS), + httponly=True, + path="/", + secure=False, + samesite="None", + ) - # Return the user - return user + # Set the user id in a cookie + response.set_cookie( + key="endurain_logged_user_id", + value=user.id, + httponly=False, + ) + + # Return the response + return response -@router.post( - "/token", response_model=schema_access_tokens.AccessToken, tags=["session"] -) +@router.post("/token", tags=["session"]) async def login_for_access_token( + response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - do_not_expire: bool = False, db: Session = Depends(dependencies_database.get_db), ): user = authenticate_user(form_data.username, form_data.password, db) @@ -100,49 +158,33 @@ async def login_for_access_token( headers={"WWW-Authenticate": "Bearer"}, ) - expire = None - if do_not_expire: - expire = datetime.utcnow() + timedelta(days=90) + response = create_response_with_tokens(response, user) - if user.access_type == REGULAR_ACCESS: - scopes = REGULAR_ACCESS_SCOPES - else: - scopes = ADMIN_ACCESS_SCOPES - - access_token = schema_access_tokens.create_access_token( - db, - data={ - "sub": user.username, - "scopes": scopes, - "id": user.id, - "access_type": user.access_type, - }, - expires_delta=expire, - ) - - return schema_access_tokens.AccessToken( - access_token=access_token, token_type="bearer" - ) + return {"message": "Login successful"} -@router.get("/users/me", response_model=schema_users.UserMe, tags=["session"]) -async def read_users_me( - security_scopes: SecurityScopes, +@router.post("/refresh", tags=["session"]) +async def refresh_token( + response: Response, + request: Request, user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) - ], - check_scopes: Annotated[ - Callable, Security(schema_access_tokens.check_scopes, scopes=["users:read"]) + int, + Depends( + dependencies_session.validate_refresh_token_and_get_authenticated_user_id + ), ], db: Session = Depends(dependencies_database.get_db), ): - return get_current_user(db, user_id) + # get user + user = crud_users.get_user_by_id(user_id, db) + if user.is_active == USER_NOT_ACTIVE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user", + headers={"WWW-Authenticate": "Bearer"}, + ) -@router.get("/validate_token", tags=["session"]) -async def validate_token( - validate_token: Callable = Depends(dependencies_session.validate_token), - db: Session = Depends(dependencies_database.get_db), -): - # Return None if the token is valid - return None + response = create_response_with_tokens(response, user) + + return {"message": "Token refreshed successfully"} diff --git a/backend/routers/router_strava.py b/backend/routers/router_strava.py index 27577b220..77b1770fa 100644 --- a/backend/routers/router_strava.py +++ b/backend/routers/router_strava.py @@ -78,9 +78,7 @@ async def strava_link( # Redirect to the main page or any other desired page after processing redirect_url = ( - "https://" - + os.environ.get("FRONTEND_HOST") - + "/settings?stravaLinked=1" + "https://" + os.environ.get("FRONTEND_HOST") + "/settings?stravaLinked=1" ) # Return a RedirectResponse to the redirect URL @@ -104,7 +102,10 @@ async def strava_link( async def strava_retrieve_activities_days( days: int, token_user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), ], # db: Annotated[Session, Depends(dependencies_database.get_db)], background_tasks: BackgroundTasks, @@ -132,7 +133,10 @@ async def strava_retrieve_activities_days( async def strava_set_user_unique_state( state: str, user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), ], db: Annotated[Session, Depends(dependencies_database.get_db)], ): @@ -149,7 +153,7 @@ async def strava_set_user_unique_state( ) async def strava_unset_user_unique_state( user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Annotated[Session, Depends(dependencies_database.get_db)], ): @@ -163,7 +167,7 @@ async def strava_unset_user_unique_state( @router.delete("/strava/unlink", tags=["strava"]) async def strava_unlink( token_user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], db: Session = Depends(dependencies_database.get_db), ): @@ -183,7 +187,7 @@ async def strava_unlink( @router.get("/strava/gear", status_code=202, tags=["strava"]) async def strava_retrieve_gear( token_user_id: Annotated[ - int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) + int, Depends(dependencies_session.validate_access_token_and_get_authenticated_user_id) ], background_tasks: BackgroundTasks, ): diff --git a/backend/routers/router_users.py b/backend/routers/router_users.py index d6c43e633..ea8c16282 100644 --- a/backend/routers/router_users.py +++ b/backend/routers/router_users.py @@ -3,7 +3,7 @@ import logging from typing import Annotated, Callable -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, Security from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session @@ -16,6 +16,7 @@ from dependencies import ( dependencies_session, dependencies_global, dependencies_users, + dependencies_security, ) # Define the OAuth2 scheme for handling bearer tokens @@ -28,12 +29,65 @@ router = APIRouter() logger = logging.getLogger("myLogger") +@router.get("/users/me", response_model=schema_users.UserMe, tags=["users"]) +async def read_users_me( + token_user_id: Annotated[ + int, + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], +): + # Get the user from the database + user = crud_users.get_user_by_id(token_user_id, db) + + # If the user does not exist raise the exception + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials (user not found)", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_integrations = crud_user_integrations.get_user_integrations_by_user_id( + user.id, db + ) + + if user_integrations is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials (user integrations not found)", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if user_integrations.strava_token is None: + user.is_strava_linked = 0 + else: + user.is_strava_linked = 1 + + # Return the user + return user + + @router.get("/users/number", response_model=int, tags=["users"]) async def read_users_number( - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): return crud_users.get_users_number(db) @@ -49,10 +103,16 @@ async def read_users_all_pagination( validate_pagination_values: Annotated[ Callable, Depends(dependencies_global.validate_pagination_values) ], - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Get the users from the database with pagination return crud_users.get_users_with_pagination( @@ -67,10 +127,16 @@ async def read_users_all_pagination( ) async def read_users_contain_username( username: str, - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Get the users from the database by username return crud_users.get_user_if_contains_username(username=username, db=db) @@ -83,10 +149,16 @@ async def read_users_contain_username( ) async def read_users_username( username: str, - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Get the user from the database by username return crud_users.get_user_by_username(username=username, db=db) @@ -96,8 +168,16 @@ async def read_users_username( async def read_users_id( user_id: int, validate_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token: Callable = Depends(dependencies_session.validate_token), - db: Session = Depends(dependencies_database.get_db), + validate_access_token: Annotated[ + Callable, Depends(dependencies_session.validate_access_token) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): # Get the users from the database by id return crud_users.get_user_by_id(user_id=user_id, db=db) @@ -106,10 +186,16 @@ async def read_users_id( @router.get("/users/{username}/id", response_model=int, tags=["users"]) async def read_users_username_id( username: str, - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Get the users from the database by username return crud_users.get_user_id_by_username(username, db) @@ -119,37 +205,34 @@ async def read_users_username_id( async def read_users_id_photo_path( user_id: int, validate_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:read"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Get the photo_path from the database by id return crud_users.get_user_photo_path_by_id(user_id, db) -@router.get( - "/users/{user_id}/photo_path_aux", response_model=str | None, tags=["users"] -) -async def read_users_id_photo_path_aux( - user_id: int, - validate_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)], - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) - ], - db: Session = Depends(dependencies_database.get_db), -): - # Get the photo_path_aux from the database by id - return crud_users.get_user_photo_path_aux_by_id(user_id, db) - - @router.post("/users/create", response_model=int, status_code=201, tags=["users"]) async def create_user( user: schema_users.UserCreate, - validate_token_validate_admin_access: Annotated[ - Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) + validate_access_token_and_validate_admin_access: Annotated[ + Callable, Depends(dependencies_session.validate_access_token_and_validate_admin_access) + ], + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:write"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), ], - db: Session = Depends(dependencies_database.get_db), ): # Create the user in the database created_user = crud_users.create_user(user, db) @@ -171,15 +254,23 @@ async def upload_user_image( user_id: int, token_user_id: Annotated[ Callable, - Depends(dependencies_session.validate_token_and_get_authenticated_user_id), + Depends( + dependencies_session.validate_access_token_and_get_authenticated_user_id + ), ], file: UploadFile, - db: Session = Depends(dependencies_database.get_db), + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:edit"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): try: upload_dir = "user_images" os.makedirs(upload_dir, exist_ok=True) - + # Get file extension _, file_extension = os.path.splitext(file.filename) filename = f"{user_id}{file_extension}" @@ -188,13 +279,11 @@ async def upload_user_image( with open(file_path_to_save, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - + return crud_users.edit_user_photo_path(user_id, file_path_to_save, db) except Exception as err: # Log the exception - logger.error( - f"Error in upload_user_image: {err}", exc_info=True - ) + logger.error(f"Error in upload_user_image: {err}", exc_info=True) # Remove the file after processing if os.path.exists(file_path_to_save): @@ -216,7 +305,13 @@ async def edit_user( dependencies_session.validate_token_and_if_user_id_equals_token_user_attributtes_id_if_not_validate_admin_access ), ], - db: Session = Depends(dependencies_database.get_db), + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:edit"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): # Update the user in the database crud_users.edit_user(user_attributtes, db) @@ -234,7 +329,13 @@ async def edit_user_password( dependencies_session.validate_token_and_if_user_id_equals_token_user_attributtes_password_id_if_not_validate_admin_access ), ], - db: Session = Depends(dependencies_database.get_db), + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:edit"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): # Update the user password in the database crud_users.edit_user_password(user_attributtes.id, user_attributtes.password, db) @@ -252,7 +353,13 @@ async def delete_user_photo( dependencies_session.validate_token_and_if_user_id_equals_token_user_id_if_not_validate_admin_access ), ], - db: Session = Depends(dependencies_database.get_db), + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:edit"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): # Update the user photo_path in the database crud_users.delete_user_photo(user_id, db) @@ -268,7 +375,13 @@ async def delete_user( validate_token_validate_admin_access: Annotated[ Callable, Depends(dependencies_session.validate_token_and_validate_admin_access) ], - db: Session = Depends(dependencies_database.get_db), + check_scopes: Annotated[ + Callable, Security(dependencies_session.check_scopes, scopes=["users:write"]) + ], + db: Annotated[ + Session, + Depends(dependencies_database.get_db), + ], ): # Delete the user in the database crud_users.delete_user(user_id, db) diff --git a/backend/schemas/schema_access_tokens.py b/backend/schemas/schema_access_tokens.py deleted file mode 100644 index 5cab10052..000000000 --- a/backend/schemas/schema_access_tokens.py +++ /dev/null @@ -1,167 +0,0 @@ -import logging - -from pydantic import BaseModel -from datetime import datetime, timedelta, timezone -from fastapi import Depends, HTTPException, status, Security -from fastapi.security import OAuth2PasswordBearer, SecurityScopes - -# from jose import JWTError, jwt -from joserfc import jwt -from joserfc.jwk import OctKey -from sqlalchemy.orm import Session - -from constants import ( - JWT_EXPIRATION_IN_MINUTES, - JWT_ALGORITHM, - JWT_SECRET_KEY, - ADMIN_ACCESS, -) - - -class AccessToken(BaseModel): - """Access token schema""" - - access_token: str - token_type: str - - -class Token(BaseModel): - """Token schema""" - - user_id: int | None = None - expires_at: str | None = None - - -class TokenData(Token): - """Token data schema""" - - access_type: int | None = None - - -class CreateToken(Token): - """Create token schema""" - - token: str - - -# Define the OAuth2 scheme for handling bearer tokens -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - -# Define a loggger created on main.py -logger = logging.getLogger("myLogger") - - -def decode_token(token: str = Depends(oauth2_scheme)): - # Decode the token and return the payload - return jwt.decode(token, OctKey.import_key(JWT_SECRET_KEY)) - - -def validate_token_expiration(db: Session, token: str = Depends(oauth2_scheme)): - # Try to decode the token and check if it is expired - try: - # Decode the token - # Mark exp claim as required - claims_requests = jwt.JWTClaimsRegistry(exp={"essential": True}) - - # decodes the token - payload = decode_token(token) - - # Validate token exp - claims_requests.validate(payload.claims) - except Exception: - # Log the error and raise the exception - logger.info("Token expired during validation | Returning 401 response") - - # Raise an HTTPException with a 401 Unauthorized status code - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token no longer valid", - headers={"WWW-Authenticate": "Bearer"}, - ) - - -def get_token_user_id(token: str = Depends(oauth2_scheme)): - # Decode the token - payload = decode_token(token) - - # Get the user id from the payload - user_id = payload.claims["id"] - - if user_id is None: - # If the user id is None raise an HTTPException with a 401 Unauthorized status code - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Return the user id - return user_id - - -def get_token_access_type(token: str = Depends(oauth2_scheme)): - # Decode the token - payload = decode_token(token) - - # Get the admin access from the payload - access_type = payload.claims["access_type"] - - if access_type is None: - # If the access type is None raise an HTTPException with a 401 Unauthorized status code - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Return the access type - return access_type - - -def validate_token_admin_access(token: str = Depends(oauth2_scheme)): - if get_token_access_type(token) != ADMIN_ACCESS: - # Raise an HTTPException with a 403 Forbidden status code - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Unauthorized Access - Admin Access Required", - ) - - -def check_scopes(security_scopes: SecurityScopes, token: str = Security(oauth2_scheme)): - # Decode the token - payload = decode_token(token) - - # Get the scopes from the payload - scopes = payload.claims["scopes"] - - if not any(scope in scopes for scope in security_scopes.scopes): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Unauthorized Access - Not enough permissions", - headers={"WWW-Authenticate": f'Bearer scope="{security_scopes.scope_str}"'}, - ) - - -def create_access_token( - db: Session, data: dict, expires_delta: timedelta | None = None -): - # Create a copy of the data to encode - to_encode = data.copy() - - # If an expiration time is provided, calculate the expiration time - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta( - minutes=JWT_EXPIRATION_IN_MINUTES - ) - - # Add the expiration time to the data to encode - to_encode.update({"exp": expire}) - - # Encode the data and return the token - # encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) - encoded_jwt = jwt.encode({"alg": JWT_ALGORITHM}, to_encode, JWT_SECRET_KEY) - - # Return the token - return encoded_jwt diff --git a/backend/schemas/schema_users.py b/backend/schemas/schema_users.py index 6c10b0cec..fbcabc0c7 100644 --- a/backend/schemas/schema_users.py +++ b/backend/schemas/schema_users.py @@ -12,7 +12,6 @@ class User(BaseModel): gender: int access_type: int photo_path: str | None = None - photo_path_aux: str | None = None is_active: int class Config: diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2767c0b34..af29f2713 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -9,7 +9,7 @@ import UserView from '../views/UserView.vue' import SettingsView from '../views/SettingsView.vue'; import NotFoundView from '../views/NotFoundView.vue'; -import { auth } from '@/services/auth'; +//import { auth } from '@/services/auth'; //import { useAuthStore } from '@/stores/auth'; @@ -59,7 +59,7 @@ const router = createRouter({ ] }) -router.beforeEach((to, from, next) => { +/* router.beforeEach((to, from, next) => { const accessToken = localStorage.getItem('accessToken'); const tokenType = localStorage.getItem('tokenType'); @@ -79,6 +79,6 @@ router.beforeEach((to, from, next) => { } else { next(); } -}); +}); */ export default router; \ No newline at end of file diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index b1e23319a..f2a2ea08d 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -1,4 +1,4 @@ -import { fetchGetRequestTokenAsParameter, fetchPostFormUrlEncoded } from '@/utils/serviceUtils'; +import { fetchGetRequest, fetchPostFormUrlEncoded } from '@/utils/serviceUtils'; import { useUserStore } from '@/stores/user'; export const auth = { @@ -13,9 +13,7 @@ export const auth = { return exp > currentTime; }, - storeLoggedUser(token, userMe) { - localStorage.setItem('accessToken', token.access_token); - localStorage.setItem('tokenType', token.token_type); + storeLoggedUser(userMe) { localStorage.setItem('userMe', JSON.stringify(userMe)); }, removeLoggedUser() { @@ -28,6 +26,6 @@ export const auth = { return fetchPostFormUrlEncoded('token', formData); }, getUserMe(token) { - return fetchGetRequestTokenAsParameter('users/me', token); + return fetchGetRequest('users/me', token); }, }; \ No newline at end of file diff --git a/frontend/src/services/session.js b/frontend/src/services/session.js new file mode 100644 index 000000000..5116882d1 --- /dev/null +++ b/frontend/src/services/session.js @@ -0,0 +1,7 @@ +import { fetchGetRequest, fetchPostFormUrlEncoded } from '@/utils/serviceUtils'; + +export const session = { + getToken(formData) { + return fetchPostFormUrlEncoded('token', formData); + }, +}; \ No newline at end of file diff --git a/frontend/src/services/user.js b/frontend/src/services/user.js index bfef4f20d..88a6a588b 100644 --- a/frontend/src/services/user.js +++ b/frontend/src/services/user.js @@ -1,6 +1,9 @@ import { fetchGetRequest, fetchPostRequest, fetchPutRequest, fetchDeleteRequest, fetchPostFileRequest } from '@/utils/serviceUtils'; export const users = { + getUserMe() { + return fetchGetRequest('users/me'); + }, getUsersWithPagination(pageNumber, numRecords) { return fetchGetRequest(`users/all/page_number/${pageNumber}/num_records/${numRecords}`); }, diff --git a/frontend/src/stores/session.js b/frontend/src/stores/session.js new file mode 100644 index 000000000..14f33c88b --- /dev/null +++ b/frontend/src/stores/session.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; + +export const useSessionStore = defineStore('session', { + state: () => ({ + id: null, + name: '', + username: '', + email: '', + city: null, + birthdate: null, + preferred_language: '', + gender: null, + access_type: null, + photo_path: '', + photo_path_aux: null, + is_active: null, + is_strava_linked: null, + }), + actions: { + setUser(user) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.email = user.email; + this.city = user.city; + this.birthdate = user.birthdate; + this.preferred_language = user.preferred_language; + this.gender = user.gender; + this.access_type = user.access_type; + this.photo_path = user.photo_path; + this.photo_path_aux = user.photo_path_aux; + this.is_active = user.is_active; + this.is_strava_linked = user.is_strava_linked; + }, + resetUser() { + this.$reset(); + } + } +}); \ No newline at end of file diff --git a/frontend/src/utils/serviceUtils.js b/frontend/src/utils/serviceUtils.js index 1128ccad2..b6b32f67e 100644 --- a/frontend/src/utils/serviceUtils.js +++ b/frontend/src/utils/serviceUtils.js @@ -13,9 +13,9 @@ export async function fetchGetRequest(url) { // Send the GET request const response = await fetch(fullUrl, { method: 'GET', + credentials: 'include', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, }, }); // If the response status is not ok, throw an error @@ -96,10 +96,13 @@ export async function fetchPostFileRequest(url, formData) { export async function fetchPostFormUrlEncoded(url, formData) { // Create the full URL by combining the API URL with the provided URL const fullUrl = `${API_URL}${url}`; + // Ensure formData is a URLSearchParams object for URL-encoded data + const urlEncodedData = new URLSearchParams(formData); // Send the POST request const response = await fetch(fullUrl, { method: 'POST', - body: formData, + body: urlEncodedData, + credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 725f58a84..f6d06c8ef 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -21,11 +21,11 @@
-
+