Encrypt Strava secrets with Fernet, update frontend logic

[backend] added new ENV variable "FERNET_KEY"
[backend] added new cryptography file to core logic to handle Fernet crypt
[backend] Strava secrets are now stored with encryption at rest using a fernet key
[frontend] Frontend no longer deletes Strava client id and secret after linking
This commit is contained in:
João Vitória Silva
2025-05-13 22:49:26 +01:00
parent bea69d56f6
commit 745433f012
8 changed files with 96 additions and 20 deletions

View File

@@ -22,6 +22,7 @@ def check_required_env_vars():
"DB_PASSWORD",
"DB_DATABASE",
"SECRET_KEY",
"FERNET_KEY",
"ALGORITHM",
"ACCESS_TOKEN_EXPIRE_MINUTES",
"REFRESH_TOKEN_EXPIRE_DAYS",

View File

@@ -0,0 +1,63 @@
import os
from cryptography.fernet import Fernet
from fastapi import HTTPException, status
import core.logger as core_logger
def create_fernet_cipher():
try:
# Get the key from environment variable and encode it to bytes
key = os.environ["FERNET_KEY"].encode()
return Fernet(key)
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in encrypt_token_fernet: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def encrypt_token_fernet(token: str) -> str:
try:
# Create a Fernet cipher
cipher = create_fernet_cipher()
# Encrypt the token
return cipher.encrypt(token.encode())
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in encrypt_token_fernet: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def decrypt_token_fernet(encrypted_token: str) -> str:
try:
# Create a Fernet cipher
cipher = create_fernet_cipher()
# Encrypt the token
return cipher.decrypt(encrypted_token).decode()
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in decrypt_token_fernet: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err

View File

@@ -18,6 +18,7 @@ import strava.activity_utils as strava_activity_utils
import strava.utils as strava_utils
import strava.schema as strava_schema
import core.cryptography as core_cryptography
import core.logger as core_logger
import core.database as core_database
@@ -64,8 +65,8 @@ async def strava_link(
# Exchange code for token
tokens = strava_client.exchange_code_for_token(
client_id=user_integrations.strava_client_id,
client_secret=user_integrations.strava_client_secret,
client_id=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_id),
client_secret=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_secret),
code=code,
)
@@ -81,9 +82,9 @@ async def strava_link(
f"Unable to link Strava account: {err}", "error", exc=err
)
# Clean up by setting Strava client to None
user_integrations_crud.set_user_strava_client(
user_integrations.user_id, None, None, db
# Clean up by setting Strava
user_integrations_crud.unlink_strava_account(
user_integrations.user_id, db
)
# Raise an HTTPException with appropriate status code

View File

@@ -1,9 +1,8 @@
import time
from datetime import datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from stravalib.client import Client
import core.cryptography as core_cryptography
import core.logger as core_logger
import activities.activity.schema as activities_schema
@@ -62,6 +61,16 @@ def create_strava_client(
) -> Client:
# Create a Strava client with the user's access token and return it
return Client(
access_token=user_integrations.strava_token,
refresh_token=user_integrations.strava_refresh_token,
access_token=(
core_cryptography.decrypt_token_fernet(user_integrations.strava_token)
if user_integrations.strava_token
else None
),
refresh_token=(
core_cryptography.decrypt_token_fernet(
user_integrations.strava_refresh_token
)
if user_integrations.strava_refresh_token
else None
),
)

View File

@@ -5,6 +5,7 @@ from datetime import datetime
import users.user_integrations.schema as user_integrations_schema
import users.user_integrations.models as user_integrations_models
import core.cryptography as core_cryptography
import core.logger as core_logger
@@ -44,7 +45,7 @@ def get_user_integrations_by_strava_state(strava_state: str, db: Session):
user_integrations = (
db.query(user_integrations_models.UsersIntegrations)
.filter(
user_integrations_models.UsersIntegrations.strava_state == strava_state
user_integrations_models.UsersIntegrations.strava_state == core_cryptography.encrypt_token_fernet(strava_state)
)
.first()
)
@@ -106,15 +107,13 @@ def link_strava_account(
):
try:
# Update the user integrations with the tokens
user_integrations.strava_token = tokens["access token"]
user_integrations.strava_refresh_token = tokens["refresh token"]
user_integrations.strava_token = core_cryptography.encrypt_token_fernet(tokens["access_token"])
user_integrations.strava_refresh_token = core_cryptography.encrypt_token_fernet(tokens["refresh_token"])
user_integrations.strava_token_expires_at = datetime.fromtimestamp(
tokens["expires_at"]
)
# Set the strava state, client ID and client Secret to None
user_integrations.strava_state = None
user_integrations.strava_client_id = None
# Set the strava state to None
user_integrations.strava_client_secret = None
# Commit the changes to the database
@@ -149,6 +148,8 @@ def unlink_strava_account(user_id: int, db: Session):
)
# Set the user integrations Strava tokens to None
user_integrations.strava_state = None
user_integrations.strava_client_id = None
user_integrations.strava_token = None
user_integrations.strava_refresh_token = None
user_integrations.strava_token_expires_at = None
@@ -186,8 +187,8 @@ def set_user_strava_client(user_id: int, id: int, secret: str, db: Session):
)
# Set the user Strava client id and secret
user_integrations.strava_client_id = id
user_integrations.strava_client_secret = secret
user_integrations.strava_client_id = core_cryptography.encrypt_token_fernet(id)
user_integrations.strava_client_secret = core_cryptography.encrypt_token_fernet(secret)
# Commit the changes to the database
db.commit()
@@ -224,7 +225,7 @@ def set_user_strava_state(user_id: int, state: str, db: Session):
)
# Set the user Strava state
user_integrations.strava_state = state
user_integrations.strava_state = core_cryptography.encrypt_token_fernet(state)
# Commit the changes to the database
db.commit()

View File

@@ -70,6 +70,7 @@ ENV UID=1000 \
DB_PASSWORD="changeme" \
DB_DATABASE="endurain" \
SECRET_KEY="changeme" \
FERNET_KEY="changeme" \
ALGORITHM="HS256" \
ACCESS_TOKEN_EXPIRE_MINUTES=15 \
REFRESH_TOKEN_EXPIRE_DAYS=7 \

View File

@@ -31,7 +31,8 @@ Environment variable | Default value | Optional | Notes |
| DB_USER | endurain | Yes | N/A |
| DB_PASSWORD | changeme | `No` | N/A |
| DB_DATABASE | endurain | Yes | N/A |
| SECRET_KEY | changeme | `No` | Run "openssl rand -hex 32" on a terminal to get a secret |
| SECRET_KEY | changeme | `No` | Run `openssl rand -hex 32` on a terminal to get a secret |
| FERNET_KEY | changeme | `No` | Run `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` on a terminal to get a secret or go to [https://fernetkeygen.com](https://fernetkeygen.com). Example output is `7NfMMRSCWcoNDSjqBX8WoYH9nTFk1VdQOdZY13po53Y=` |
| ALGORITHM | HS256 | Yes | Currently only HS256 is supported |
| ACCESS_TOKEN_EXPIRE_MINUTES | 15 | Yes | Time in minutes |
| REFRESH_TOKEN_EXPIRE_DAYS | 7 | Yes | Time in days |

View File

@@ -181,7 +181,6 @@ export default {
try {
await strava.setUniqueUserStateStravaLink(null);
await strava.setUserStravaClientSettings(null, null);
} catch (error) {
// If there is an error, show the error alert.
push.error(