Files
endurain/backend/app/alembic/versions/v0_16_4_migration.py
João Vitória Silva f6e06fb3e6 Add session idle and absolute timeout enforcement
Implements optional session idle and absolute timeout logic, including new environment variables for configuration. Adds last_activity_at to sessions, enforces timeouts on token refresh, and introduces a scheduler job to clean up idle sessions. Also introduces progressive lockout for failed logins and updates documentation and examples accordingly.
2025-12-18 10:28:22 +00:00

188 lines
5.8 KiB
Python

"""v0.16.4 migration
Revision ID: ef6cd7775aa2
Revises: 2af2c0629b37
Create Date: 2025-12-16 12:47:18.298420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "ef6cd7775aa2"
down_revision: Union[str, None] = "2af2c0629b37"
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! ###
# Create oauth_states table
op.create_table(
"oauth_states",
sa.Column(
"id",
sa.String(length=64),
nullable=False,
comment="State parameter itself (secrets.token_urlsafe(32))",
),
sa.Column(
"idp_id", sa.Integer(), nullable=False, comment="Identity provider ID"
),
sa.Column(
"user_id", sa.Integer(), nullable=True, comment="User ID (for link mode)"
),
sa.Column(
"code_challenge",
sa.String(length=128),
nullable=True,
comment="Base64url-encoded SHA256(code_verifier)",
),
sa.Column(
"code_challenge_method",
sa.String(length=10),
nullable=True,
comment="PKCE method (only S256 supported)",
),
sa.Column(
"nonce",
sa.String(length=64),
nullable=False,
comment="OIDC nonce for ID token validation",
),
sa.Column(
"redirect_path",
sa.String(length=500),
nullable=True,
comment="Frontend path after login",
),
sa.Column(
"client_type",
sa.String(length=10),
nullable=False,
comment="Client type: web or mobile",
),
sa.Column(
"ip_address",
sa.String(length=45),
nullable=True,
comment="Client IP address (IPv6 max length)",
),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.text("now()"),
nullable=False,
comment="OAuth state creation timestamp",
),
sa.Column(
"expires_at",
sa.DateTime(),
nullable=False,
comment="Hard expiry at 10 minutes (cleanup marker)",
),
sa.Column(
"used",
sa.Boolean(),
nullable=False,
comment="True when state is consumed (prevents replay)",
),
sa.ForeignKeyConstraint(
["idp_id"],
["identity_providers.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_oauth_states_expires_at"), "oauth_states", ["expires_at"], unique=False
)
op.create_index(op.f("ix_oauth_states_id"), "oauth_states", ["id"], unique=False)
op.create_index(
op.f("ix_oauth_states_idp_id"), "oauth_states", ["idp_id"], unique=False
)
op.create_index(
op.f("ix_oauth_states_used"), "oauth_states", ["used"], unique=False
)
op.create_index(
op.f("ix_oauth_states_user_id"), "oauth_states", ["user_id"], unique=False
)
# Add oauth_state_id and tokens_exchanged to users_sessions table
op.add_column(
"users_sessions",
sa.Column(
"oauth_state_id",
sa.String(length=64),
nullable=True,
comment="Link to OAuth state for PKCE validation",
),
)
op.add_column(
"users_sessions",
sa.Column(
"tokens_exchanged",
sa.Boolean(),
nullable=True,
comment="Prevents duplicate token exchange for mobile",
),
)
op.execute(
"""
UPDATE users_sessions
SET tokens_exchanged = false
WHERE tokens_exchanged IS NULL;
"""
)
op.alter_column(
"users_sessions",
"tokens_exchanged",
nullable=False,
comment="Prevents duplicate token exchange for mobile",
existing_type=sa.Boolean(),
)
op.create_index(
op.f("ix_users_sessions_oauth_state_id"),
"users_sessions",
["oauth_state_id"],
unique=False,
)
op.create_foreign_key(
None, "users_sessions", "oauth_states", ["oauth_state_id"], ["id"]
)
# Add last_activity_at column with default value = created_at
op.add_column(
"users_sessions", sa.Column("last_activity_at", sa.DateTime(), nullable=True)
)
# Backfill existing sessions: set last_activity_at = created_at
op.execute(
"UPDATE users_sessions SET last_activity_at = created_at WHERE last_activity_at IS NULL"
)
# Make column non-nullable after backfill
op.alter_column("users_sessions", "last_activity_at", nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users_sessions", "last_activity_at")
op.drop_constraint(None, "users_sessions", type_="foreignkey")
op.drop_index(op.f("ix_users_sessions_oauth_state_id"), table_name="users_sessions")
op.drop_column("users_sessions", "tokens_exchanged")
op.drop_column("users_sessions", "oauth_state_id")
op.drop_index(op.f("ix_oauth_states_user_id"), table_name="oauth_states")
op.drop_index(op.f("ix_oauth_states_used"), table_name="oauth_states")
op.drop_index(op.f("ix_oauth_states_idp_id"), table_name="oauth_states")
op.drop_index(op.f("ix_oauth_states_id"), table_name="oauth_states")
op.drop_index(op.f("ix_oauth_states_expires_at"), table_name="oauth_states")
op.drop_table("oauth_states")
# ### end Alembic commands ###