Skip to content

Commit

Permalink
[137] Encrypt Connection details within the database
Browse files Browse the repository at this point in the history
- Implements #470
- Implements #469
  • Loading branch information
CollinHeist committed Jul 16, 2024
1 parent 230db63 commit 3475c08
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 33 deletions.
22 changes: 12 additions & 10 deletions app/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- 2c1f9a3de797 | Added TVDb as a valid Connection type
- 248e35b3e455 | Remove the Font.delete_missing column
- 0a5f4764cd10 | Remove the Episode.image_source_attempts column
- 84971838f3fc | Encrypt Connection URLs and API keys
"""

# this is the Alembic Config object, which provides
Expand Down Expand Up @@ -63,15 +64,15 @@


def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
"""
Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
This configures the context with just a URL and not an Engine,
though an Engine is acceptable here as well. By skipping the Engine
creation we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
Calls to context.execute() here emit the given string to the script
output.
"""

url = config.get_main_option("sqlalchemy.url")
Expand All @@ -92,10 +93,11 @@ def run_migrations_offline() -> None:


def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
"""
Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
In this scenario we need to create an Engine and associate a
connection with the context.
"""

connectable = engine_from_config(
Expand Down
76 changes: 76 additions & 0 deletions app/alembic/versions/84971838f3fc_encrypt_connection_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Encrypt Connection server URLs and API keys
Revision ID: 84971838f3fc
Revises: 0a5f4764cd10
Create Date: 2024-07-14 22:31:47.681441
"""

from alembic import op
import sqlalchemy as sa

from app.internal.auth import decrypt, encrypt
from modules.Debug import contextualize
from modules.Debug2 import logger

# Revision identifiers, used by Alembic.
revision = '84971838f3fc'
down_revision = '0a5f4764cd10'
branch_labels = None
depends_on = None

# Models necessary for data migration
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session

Base = declarative_base()

class Connection(Base):
__tablename__ = 'connection'

id = sa.Column(sa.Integer, primary_key=True, index=True)
api_key = sa.Column(sa.String, nullable=False)
url = sa.Column(sa.String, nullable=True)


def upgrade() -> None:
log = contextualize(logger)
log.debug(f'Upgrading SQL Schema to Version[{revision}]..')

# Perform data migration
session = Session(bind=op.get_bind())

# Encrypt all URLs and API keys in the database
for connection in session.query(Connection).all():
if connection.url:
connection.url = encrypt(connection.url)
log.debug(f'Encrypted URL for Connection[{connection.id}]')
if connection.api_key:
connection.api_key = encrypt(connection.api_key)
log.debug(f'Encrypted API key for Connection[{connection.id}]')

# Commit changes
session.commit()

log.debug(f'Upgraded SQL Schema to Version[{revision}]')


def downgrade() -> None:
log = contextualize(logger)
log.debug(f'Downgrading SQL Schema to Version[{down_revision}]..')

# Perform data migration
session = Session(bind=op.get_bind())

# Decrypt all URLs and API keys in the database
for connection in session.query(Connection).all():
if connection.url:
connection.url = decrypt(connection.url)
log.debug(f'Decrypted URL for Connection[{connection.id}]')
if connection.api_key:
connection.api_key = decrypt(connection.api_key)
log.debug(f'Decrypted API key for Connection[{connection.id}]')

# Commit changes
session.commit()

log.debug(f'Downgraded SQL Schema to Version[{down_revision}]')
72 changes: 65 additions & 7 deletions app/internal/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from base64 import urlsafe_b64encode
from cryptography.fernet import Fernet
from datetime import datetime, timedelta
from logging import getLogger, ERROR
from os import environ
from pathlib import Path
from secrets import token_hex
from typing import Literal, Optional, Union
from typing import Optional

from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
Expand All @@ -23,22 +27,28 @@


ALGORITHM = 'HS256'
__SECRET_KEY = '360f8406f24d5bdd0ff24693e71e025f'

"""File where the private key is stored"""
IS_DOCKER = environ.get('TCM_IS_DOCKER', 'false').lower() == 'true'
KEY_FILE = Path(__file__).parent.parent.parent / 'config' / '.key.txt'
if IS_DOCKER:
KEY_FILE = Path('/config/.key.txt')

"""Only log passlib errors so that bcrypt.__version__ boot warning is ignored"""
getLogger('passlib').setLevel(ERROR)
oath2_scheme = OAuth2PasswordBearer(tokenUrl='/api/auth/authenticate')
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')


def generate_secret_key() -> str:
def generate_secret_key() -> bytes:
"""
Generate a new, random secret 16.
Generate a new, random secret.
Returns:
A 16-character random hexstring.
"""

return token_hex(16)
return urlsafe_b64encode(token_hex(16).encode())


def verify_password(plaintext: str, hashed: str) -> bool:
Expand All @@ -57,6 +67,26 @@ def verify_password(plaintext: str, hashed: str) -> bool:
return pwd_context.verify(plaintext, hashed)


def get_secret_key() -> bytes:
"""
Get the secret key for all encryption. This reads the local key file
if it exists, and generates a new one if it does not.
Returns:
Secret key (as a hexstring).
"""

# File exists, read
if KEY_FILE.exists():
return KEY_FILE.read_bytes()

# No file, generate and write new key
key = generate_secret_key()
KEY_FILE.write_bytes(key)
log.info(f'Generated encrpytion key - wrote to "{KEY_FILE}"')
return key


def get_password_hash(password: str) -> str:
"""
Hash the given plaintext password.
Expand Down Expand Up @@ -125,7 +155,7 @@ def get_current_user(

# Decode JWT, get encoded username
try:
payload = jwt.decode(token, __SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
username: str = payload.get('sub')
uid: str = payload.get('uid')
except JWTError as exc:
Expand Down Expand Up @@ -203,4 +233,32 @@ def create_access_token(
to_encode = data.copy()
to_encode.update({'exp': expires})

return jwt.encode(to_encode, __SECRET_KEY, algorithm=ALGORITHM)
return jwt.encode(to_encode, get_secret_key(), algorithm=ALGORITHM)


def encrypt(plaintext: str) -> str:
"""
Encrypt the given plaintext.
Args:
plaintext: Text to encrypt.
Returns:
Encrypted text.
"""

return Fernet(get_secret_key()).encrypt(plaintext.encode()).decode()


def decrypt(encrypted_text: str) -> str:
"""
Decrypt the given encrypted text into plaintext.
Args:
encrypted_text: Text to decrypt.
Returns:
Plain decrypted text.
"""

return Fernet(get_secret_key()).decrypt(encrypted_text).decode()
10 changes: 8 additions & 2 deletions app/internal/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
get_preferences, get_sonarr_interfaces, get_tmdb_interfaces,
get_tvdb_interfaces,
)
from app.internal.auth import encrypt
from app.models.connection import Connection
from app.models.preferences import Preferences
from app.schemas.base import UNSPECIFIED
Expand Down Expand Up @@ -57,7 +58,6 @@ def initialize_connections(
(get_tmdb_interfaces(), 'TMDb'),
(get_tvdb_interfaces(), 'TVDb'),
):

# Get all Connections of this interface type
connections: list[Connection] = db.query(Connection)\
.filter_by(interface_type=interface_type)\
Expand Down Expand Up @@ -115,6 +115,7 @@ def add_connection(

# Add to database
connection = Connection(**new_connection.dict())
connection.encrypt()
db.add(connection)
db.commit()

Expand Down Expand Up @@ -178,7 +179,10 @@ def update_connection(
for attr, value in update_object.dict(exclude_defaults=True).items():
if value != UNSPECIFIED and getattr(connection, attr) != value:
# Update Connection
setattr(connection, attr, value)
if attr in ('api_key', 'url'):
setattr(connection, attr, encrypt(value))
else:
setattr(connection, attr, value)
changed = True

# Update secrets, log change
Expand All @@ -190,6 +194,7 @@ def update_connection(
db.commit()
preferences = get_preferences()
if connection.enabled:
# Attempt to re-initialize Interface with new details
try:
interface_group.refresh(
interface_id, connection.interface_kwargs, log=log
Expand All @@ -200,6 +205,7 @@ def update_connection(
if interface_id not in preferences.invalid_connections:
preferences.invalid_connections.append(interface_id)
raise exc
# Connection is disabled, remove from IG
else:
interface_group.disable(interface_id)
if interface_id in preferences.invalid_connections:
Expand Down
Loading

0 comments on commit 3475c08

Please sign in to comment.