Skip to content

Commit

Permalink
[feat] Allow expiration of web tokens (#633)
Browse files Browse the repository at this point in the history
  • Loading branch information
aquamatthias authored Oct 8, 2024
1 parent dc47ff1 commit a3a6a8b
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 66 deletions.
16 changes: 6 additions & 10 deletions fixbackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,26 @@
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi_users.exceptions import FastAPIUsersException
from fixcloudutils.logging import setup_logger
from prometheus_client import Counter
from prometheus_fastapi_instrumentator import Instrumentator
from sqlalchemy import select
from starlette.exceptions import HTTPException

from fixbackend import config, dependencies
from fixbackend.auth.api_token_router import api_token_router
from fixbackend.customer_support.router import admin_console_router
from fixbackend.analytics.router import analytics_router
from fixbackend.app_dependencies import create_dependencies
from fixbackend.auth.api_token_router import api_token_router
from fixbackend.auth.auth_backend import cookie_transport
from fixbackend.auth.depedencies import refreshed_session_scope
from fixbackend.auth.oauth_router import github_client, google_client
from fixbackend.auth.router import auth_router
from fixbackend.auth.auth_router import auth_router
from fixbackend.auth.users_router import users_router
from fixbackend.billing.router import billing_info_router
from fixbackend.cloud_accounts.router import cloud_accounts_callback_router, cloud_accounts_router
from fixbackend.config import Config
from fixbackend.customer_support.router import admin_console_router
from fixbackend.dependencies import ServiceNames as SN, FixDependency, FixDependencies # noqa
from fixbackend.errors import ClientError, NotAllowed, ResourceNotFound, WrongState
from fixbackend.events.router import websocket_router
Expand All @@ -53,11 +55,7 @@
from fixbackend.notification.notification_router import notification_router, unsubscribe_router
from fixbackend.permissions.router import roles_router
from fixbackend.subscription.router import subscription_router
from fixbackend.types import Redis
from fixbackend.workspaces.router import workspaces_router
from prometheus_client import Counter
from fastapi_users.exceptions import FastAPIUsersException


log = logging.getLogger(__name__)
API_PREFIX = "/api"
Expand Down Expand Up @@ -301,9 +299,7 @@ async def hello() -> Response:

if cfg.args.mode == "app":
api_router = APIRouter(prefix=API_PREFIX)
api_router.include_router(
auth_router(cfg, google, github, deps.service(SN.temp_store_redis, Redis)), prefix="/auth", tags=["auth"]
)
api_router.include_router(auth_router(cfg, google, github, deps), prefix="/auth", tags=["auth"])
api_router.include_router(workspaces_router(), prefix="/workspaces", tags=["workspaces"])
api_router.include_router(cloud_accounts_router(deps), prefix="/workspaces", tags=["cloud_accounts"])
api_router.include_router(inventory_router(deps), prefix="/workspaces")
Expand Down
7 changes: 6 additions & 1 deletion fixbackend/auth/auth_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import time
from datetime import timedelta
from typing import Any, Dict, List, Optional

Expand Down Expand Up @@ -63,6 +63,10 @@ async def read_token(self, token: Optional[str], user_manager: BaseUserManager[U
try:
parsed_id = user_manager.parse_id(user_id)
user = await user_manager.get(parsed_id)
if amt := user.auth_min_time:
data_at = data.get("at")
if data_at is None or (data_at / 1000) < amt.timestamp():
return None
return user
except (exceptions.UserNotExists, exceptions.InvalidID):
return None
Expand All @@ -76,6 +80,7 @@ async def create_token(self, sub: str, token_origin: str, permissions: Dict[Work
"sub": sub,
"token_origin": token_origin,
"permissions": {str(ws): perms for ws, perms in permissions.items()},
"at": int(time.time() * 1000), # precision: milliseconds
}
if self.lifetime_seconds:
expire = utc() + timedelta(seconds=self.lifetime_seconds)
Expand Down
27 changes: 23 additions & 4 deletions fixbackend/auth/router.py → fixbackend/auth/auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import timedelta
from datetime import timedelta, datetime, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status, Form
from fastapi import Query
from fastapi_users.authentication import AuthenticationBackend, Strategy
from fastapi_users.exceptions import UserAlreadyExists, InvalidPasswordException, UserNotExists
from fastapi_users.router import ErrorCode
from fastapi_users.router.oauth import generate_state_token
from fixcloudutils.util import utc
from httpx_oauth.clients.google import GoogleOAuth2
from httpx_oauth.oauth2 import BaseOAuth2

Expand All @@ -39,7 +41,9 @@
OAuth2PasswordMFARequestForm,
)
from fixbackend.auth.user_manager import UserManagerDependency, UserManager, get_user_manager
from fixbackend.auth.user_repository import UserRepository
from fixbackend.config import Config
from fixbackend.dependencies import FixDependency, ServiceNames as SN
from fixbackend.ids import UserId
from fastapi_users import schemas
from disposable_email_domains import blocklist
Expand Down Expand Up @@ -77,12 +81,14 @@ async def get_associate_url(


def auth_router(
config: Config, google_client: GoogleOAuth2, github_client: GithubOauthClient, redis: Redis
config: Config,
google_client: GoogleOAuth2,
github_client: GithubOauthClient,
dependencies: FixDependency,
) -> APIRouter:
router = APIRouter()

redis = dependencies.service(SN.temp_store_redis, Redis)
login_rate_limiter = LoginRateLimiter(redis, limit=config.auth_rate_limit_per_minute, window=timedelta(minutes=1))

auth_backend = get_auth_backend(config)

router.include_router(
Expand Down Expand Up @@ -243,6 +249,19 @@ async def logout(

return await auth_backend.logout(strategy, user, token)

@router.put("/jwt/expire")
async def jwt_expire(
user: AuthenticatedUser,
expire_older_than: Optional[datetime] = Query(
default=None,
description="All tokens older than this timestamp get invalidated. "
"If no value is provided, the current time is assumed.",
),
) -> Response:
ts_utc = expire_older_than.astimezone(timezone.utc) if expire_older_than else utc()
await dependencies.service(SN.user_repo, UserRepository).update_partial(user.id, auth_min_time=ts_utc)
return Response(status_code=status.HTTP_204_NO_CONTENT)

@router.post("/register", status_code=status.HTTP_201_CREATED, name="register:register")
async def register(request: Request, user_create: UserCreate, user_manager: UserManagerDependency) -> UserRead:
try:
Expand Down
1 change: 1 addition & 0 deletions fixbackend/auth/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class User(UserOAuthProtocol[UserId, OAuthAccount]):
oauth_accounts: List[OAuthAccount]
roles: List[UserRole]
created_at: datetime
auth_min_time: Optional[datetime] = None


@frozen
Expand Down
16 changes: 2 additions & 14 deletions fixbackend/auth/models/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class User(SQLAlchemyBaseUserTableUUID, CreatedUpdatedMixin, Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)
last_active: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)
auth_min_time: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)

def to_model(self) -> models.User:
return models.User(
Expand All @@ -85,20 +86,7 @@ def to_model(self) -> models.User:
is_mfa_active=self.is_mfa_active,
roles=[role.to_model() for role in self.roles],
created_at=self.created_at,
)

@staticmethod
def from_model(user: models.User) -> "User":
return User(
id=user.id,
email=user.email,
hashed_password=user.hashed_password,
is_active=user.is_active,
is_superuser=user.is_superuser,
is_verified=user.is_verified,
otp_secret=user.otp_secret,
is_mfa_active=user.is_mfa_active,
oauth_accounts=[OAuthAccount.from_model(acc) for acc in user.oauth_accounts],
auth_min_time=self.auth_min_time,
)


Expand Down
11 changes: 9 additions & 2 deletions fixbackend/auth/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ async def create(self, create_dict: Dict[str, Any]) -> User:
return user.to_model()

async def update_partial(
self, uid: UserId, last_login: Optional[datetime] = None, last_active: Optional[datetime] = None
self,
uid: UserId,
last_login: Optional[datetime] = None,
last_active: Optional[datetime] = None,
auth_min_time: Optional[datetime] = None,
) -> None:
async with self.user_db() as db:
orm_user = await db.session.get(orm.User, uid)
Expand All @@ -122,6 +126,8 @@ async def update_partial(
orm_user.last_login = last_login
if last_active:
orm_user.last_active = last_active
if auth_min_time:
orm_user.auth_min_time = auth_min_time
await db.session.commit()

async def update(self, user: User, update_dict: Dict[str, Any]) -> User:
Expand All @@ -145,7 +151,8 @@ async def delete(self, user: User) -> None:
"""Delete a user."""

async with self.user_db() as db:
await db.delete(orm.User.from_model(user))
await db.session.execute(delete(orm.User).where(orm.User.id == user.id)) # type: ignore
await db.session.commit()

async def add_oauth_account(self, user: User, create_dict: Dict[str, Any]) -> User:
"""Create an OAuth account and add it to the user."""
Expand Down
18 changes: 18 additions & 0 deletions migrations/versions/2024-10-08T07:09:26Z_user_auth_min_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""user:
Introduce auth_min_time column to user table.
Create Date: 2024-10-08 07:09:26.627447+00:00
"""

from typing import Union

import sqlalchemy as sa
from alembic import op

from fixbackend.sqlalechemy_extensions import UTCDateTime

revision: str = "f5eaa189e1f2"
down_revision: Union[str, None] = "000dd4f966a4"


def upgrade() -> None:
op.add_column("user", sa.Column("auth_min_time", UTCDateTime(timezone=True), nullable=True))
Loading

0 comments on commit a3a6a8b

Please sign in to comment.