Skip to content

Commit

Permalink
Serve HTML responses for failed login and session-expiry
Browse files Browse the repository at this point in the history
Update docstrings and runbook
  • Loading branch information
dormant-user committed Jan 23, 2024
1 parent 6322a8b commit 54e39cf
Show file tree
Hide file tree
Showing 16 changed files with 579 additions and 110 deletions.
54 changes: 44 additions & 10 deletions docs/genindex.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ <h2 id="E">E</h2>
<li><a href="index.html#pystream.models.config.EnvConfig">EnvConfig (class in pystream.models.config)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.Config">EnvConfig.Config (class in pystream.models.config)</a>
</li>
<li><a href="index.html#pystream.routers.basics.error">error() (in module pystream.routers.basics)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.Config.extra">extra (pystream.models.config.EnvConfig.Config attribute)</a>
</li>
Expand All @@ -109,6 +111,8 @@ <h2 id="E">E</h2>
<h2 id="F">F</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.authenticator.failed_auth_counter">failed_auth_counter() (in module pystream.models.authenticator)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.file_formats">file_formats (pystream.models.config.EnvConfig attribute)</a>
</li>
</ul></td>
Expand Down Expand Up @@ -152,6 +156,12 @@ <h2 id="H">H</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.EnvConfig.Config.hide_input_in_errors">hide_input_in_errors (pystream.models.config.EnvConfig.Config attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.Static.home_endpoint">home_endpoint (pystream.models.config.Static attribute)</a>
</li>
<li><a href="index.html#pystream.routers.auth.home_page">home_page() (in module pystream.routers.auth)</a>
</li>
</ul></td>
</tr></table>
Expand All @@ -165,24 +175,28 @@ <h2 id="I">I</h2>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.routers.auth.index">index() (in module pystream.routers.auth)</a>
</li>
<li><a href="index.html#pystream.models.config.Static.index_endpoint">index_endpoint (pystream.models.config.Static attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.Session.info">info (pystream.models.config.Session attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.Session.invalid">invalid (pystream.models.config.Session attribute)</a>
</li>
</ul></td>
</tr></table>

<h2 id="L">L</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.FileIO.list_files">list_files (pystream.models.config.FileIO attribute)</a>
<li><a href="index.html#pystream.models.config.FileIO.landing">landing (pystream.models.config.FileIO attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.FileIO.listing">listing (pystream.models.config.FileIO attribute)</a>
</li>
<li><a href="index.html#pystream.models.squire.log_connection">log_connection() (in module pystream.models.squire)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.routers.auth.login">login() (in module pystream.routers.auth)</a>
</li>
<li><a href="index.html#pystream.models.config.Static.login_endpoint">login_endpoint (pystream.models.config.Static attribute)</a>
</li>
<li><a href="index.html#pystream.routers.auth.logout">logout() (in module pystream.routers.auth)</a>
</li>
<li><a href="index.html#pystream.models.config.Static.logout_endpoint">logout_endpoint (pystream.models.config.Static attribute)</a>
Expand Down Expand Up @@ -348,10 +362,14 @@ <h2 id="R">R</h2>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.stream.range_requests_response">range_requests_response() (in module pystream.models.stream)</a>
</li>
<li><a href="index.html#pystream.models.squire.remove_thumbnail">remove_thumbnail() (in module pystream.models.squire)</a>
<li><a href="index.html#pystream.main.redirect_exception_handler">redirect_exception_handler() (in module pystream.main)</a>
</li>
<li><a href="index.html#pystream.models.config.RedirectException">RedirectException</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.squire.remove_thumbnail">remove_thumbnail() (in module pystream.models.squire)</a>
</li>
<li><a href="index.html#pystream.routers.basics.root">root() (in module pystream.routers.basics)</a>
</li>
<li><a href="index.html#pystream.logger.RootFilter">RootFilter (class in pystream.logger)</a>
Expand All @@ -362,18 +380,24 @@ <h2 id="R">R</h2>
<h2 id="S">S</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.EnvConfig.secret">secret (pystream.models.config.EnvConfig attribute)</a>
</li>
<li><a href="index.html#pystream.models.stream.send_bytes_range_requests">send_bytes_range_requests() (in module pystream.models.stream)</a>
</li>
<li><a href="index.html#pystream.models.config.Session">Session (class in pystream.models.config)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.session_duration">session_duration (pystream.models.config.EnvConfig attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.Static.session_token">session_token (pystream.models.config.Static attribute)</a>
</li>
<li><a href="index.html#pystream.main.shutdown_tasks">shutdown_tasks() (in module pystream.main)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.subtitles.srt_to_vtt">srt_to_vtt() (in module pystream.models.subtitles)</a>
</li>
<li><a href="index.html#pystream.main.start">start() (in module pystream.main)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.main.startup_tasks">startup_tasks() (in module pystream.main)</a>
</li>
<li><a href="index.html#pystream.models.config.Static">Static (class in pystream.models.config)</a>
Expand All @@ -390,10 +414,14 @@ <h2 id="S">S</h2>
<h2 id="T">T</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.Static.track">track (pystream.models.config.Static attribute)</a>
<li><a href="index.html#pystream.models.config.WebToken.timestamp">timestamp (pystream.models.config.WebToken attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.WebToken.token">token (pystream.models.config.WebToken attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.Static.track">track (pystream.models.config.Static attribute)</a>
</li>
<li><a href="index.html#pystream.routers.video.track_loader">track_loader() (in module pystream.routers.video)</a>
</li>
</ul></td>
Expand All @@ -410,7 +438,9 @@ <h2 id="U">U</h2>
<h2 id="V">V</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.authenticator.verify">verify() (in module pystream.models.authenticator)</a>
<li><a href="index.html#pystream.models.authenticator.verify_login">verify_login() (in module pystream.models.authenticator)</a>
</li>
<li><a href="index.html#pystream.models.authenticator.verify_token">verify_token() (in module pystream.models.authenticator)</a>
</li>
<li><a href="index.html#pystream.routers.video.video_endpoint">video_endpoint() (in module pystream.routers.video)</a>
</li>
Expand All @@ -421,6 +451,8 @@ <h2 id="V">V</h2>
<li><a href="index.html#pystream.models.config.EnvConfig.video_port">video_port (pystream.models.config.EnvConfig attribute)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.video_source">video_source (pystream.models.config.EnvConfig attribute)</a>
</li>
<li><a href="index.html#pystream.models.subtitles.vtt_to_srt">vtt_to_srt() (in module pystream.models.subtitles)</a>
</li>
</ul></td>
</tr></table>
Expand All @@ -432,6 +464,8 @@ <h2 id="W">W</h2>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="index.html#pystream.models.config.WebToken">WebToken (class in pystream.models.config)</a>
</li>
<li><a href="index.html#pystream.models.config.EnvConfig.workers">workers (pystream.models.config.EnvConfig attribute)</a>
</li>
</ul></td>
Expand Down
263 changes: 219 additions & 44 deletions docs/index.html

Large diffs are not rendered by default.

Binary file modified docs/objects.inv
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/searchindex.js

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions pystream/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os

import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from pystream.logger import logger
from pystream.models import config
Expand All @@ -14,13 +15,35 @@
app.include_router(video.router)


# Exception handler for RedirectException
@app.exception_handler(config.RedirectException)
async def redirect_exception_handler(request: Request, exception: config.RedirectException) -> JSONResponse:
"""Custom exception handler to handle redirect.
Args:
request: Takes the ``Request`` object as an argument.
exception: Takes the ``RedirectException`` object inherited from ``Exception`` as an argument.
Returns:
JSONResponse:
Returns the JSONResponse with content, status code and cookie.
"""
logger.info("Exception headers: %s", request.headers)
logger.info("Exception cookies: %s", request.cookies)
# fixme: Set conditional to return JSONResponse only if request.url.path matches config.static.login_endpoint
response = JSONResponse(content={"redirect_url": exception.location}, status_code=200)
if exception.detail:
response.set_cookie("detail", exception.detail.upper())
return response


async def startup_tasks() -> None:
"""Tasks that need to run during the API startup."""
logger.info('Setting CORS policy.')
origins = ["http://localhost.com", "https://localhost.com"]
origins.extend(config.env.website)
origins.extend(map((lambda x: x + '/*'), config.env.website))
app.add_middleware(CORSMiddleware, allow_origins=origins)
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"])


async def shutdown_tasks() -> None:
Expand Down
59 changes: 37 additions & 22 deletions pystream/models/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,41 @@
import time

import jwt
from fastapi import HTTPException, status
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import ValidationError

from pystream.logger import logger
from pystream.models import config


async def verify_login(credentials) -> JSONResponse:
async def failed_auth_counter(request: Request) -> None:
"""Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times.
Args:
request: Takes the ``Request`` object as an argument.
"""
try:
config.session.invalid[request.client.host] += 1
except KeyError:
config.session.invalid[request.client.host] = 1
logger.info(config.session.invalid[request.client.host])
if config.session.invalid[request.client.host] >= 3:
raise config.RedirectException(location="/error")


async def verify_login(request: Request) -> JSONResponse:
"""Verifies authentication.
Returns:
JSONResponse:
Returns JSON response with content and status code.
"""
decoded_auth = base64.b64decode(credentials).decode('utf-8')
decoded_auth = base64.b64decode(request.headers.get("authorization", "")).decode("utf-8")
auth = bytes(decoded_auth, "utf-8").decode(encoding="unicode_escape")
username, password = auth.split(':')
if not username or not password:
await failed_auth_counter(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Username and password is required to proceed.",
Expand All @@ -31,37 +48,35 @@ async def verify_login(credentials) -> JSONResponse:
password_validation = secrets.compare_digest(password, config.env.password.get_secret_value())

if username_validation and password_validation:
return JSONResponse(
content={
"authenticated": True
},
status_code=200,
)
config.session.invalid[request.client.host] = 0
return JSONResponse(content={"authenticated": True}, status_code=200)

logger.error("Incorrect username [%s] or password [%s]", username, password)
await failed_auth_counter(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers=None
)


async def verify_timestamp(timestamp: int):
# todo: include html files to specify this and redirect upon refresh or button click
if time.time() - timestamp > config.env.session_duration:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has timed out")
async def verify_token(token: str) -> None:
"""Decodes the JWT and validates the session token and expiration.
Args:
token: JSON web token.
async def verify_token(token: str):
go_home = HTTPException(status_code=status.HTTP_307_TEMPORARY_REDIRECT,
detail="Missing or invalid session token",
headers={"Location": "/"}) # redirect to root page for login
Raises:
RedirectException:
"""
if not token:
raise go_home
raise config.RedirectException(location="/error", detail="Invalid session token")
try:
decoded = config.WebToken(**jwt.decode(jwt=token, key=config.env.secret.get_secret_value(), algorithms="HS256"))
except jwt.InvalidSignatureError as error:
except (jwt.InvalidSignatureError, ValidationError) as error:
logger.error(error)
raise go_home
await verify_login(decoded.credentials)
await verify_timestamp(decoded.timestamp)
raise config.RedirectException(location="/error", detail="Invalid session token")
if not secrets.compare_digest(decoded.token, config.static.session_token):
raise config.RedirectException(location="/error", detail="Invalid session token")
if time.time() - decoded.timestamp > config.env.session_duration:
raise config.RedirectException(location="/error", detail="Session expired")
44 changes: 42 additions & 2 deletions pystream/models/config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import os
import pathlib
import random
import socket
import string
from ipaddress import IPv4Address
from typing import List, Sequence, Set, Union
from typing import List, Optional, Sequence, Set, Union

from pydantic import (BaseModel, DirectoryPath, Field, PositiveInt, SecretStr,
field_validator)
from pydantic_settings import BaseSettings

template_storage = os.path.join(pathlib.Path(__file__).parent.parent, "templates")


class EnvConfig(BaseSettings):
"""Configure all env vars and validate using ``pydantic`` to share across modules.
Expand Down Expand Up @@ -87,6 +91,10 @@ class Static(BaseModel):
streaming_endpoint: str = "/video"
chunk_size: PositiveInt = 1024 * 1024
deletions: Set[pathlib.PosixPath] = set()
# todo: Allow multiple users, and create multiple session tokens during startup
# Use a single session token, since currently this project only allows one username and password
# Random string ensures, that users are forced to login when the server restarts
session_token: str = "".join(random.choices(string.ascii_letters + string.digits, k=32))


class Session(BaseModel):
Expand All @@ -97,13 +105,45 @@ class Session(BaseModel):
"""

info: dict = {}
invalid: dict = {}


class WebToken(BaseModel):
credentials: str
"""Object to store and validate JWT objects.
>>> WebToken
"""

token: str
timestamp: int


class RedirectException(Exception):
"""Custom ``RedirectException`` raised within the API since HTTPException doesn't support returning HTML content.
>>> RedirectException
See Also:
- RedirectException allows the API to redirect on demand in cases where returning is not a solution.
- There are alternatives to raise HTML content as an exception but none work with our use-case with JavaScript.
- This way of exception handling comes handy for many unexpected scenarios.
References:
https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers
"""

def __init__(self, location: str, detail: Optional[str] = ""):
"""Instantiates the ``RedirectException`` object with the required parameters.
Args:
location: Location for redirect.
detail: Reason for redirect.
"""
self.location = location
self.detail = detail


env = EnvConfig
fileio = FileIO()
static = Static()
Expand Down
3 changes: 1 addition & 2 deletions pystream/models/squire.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
from pystream.logger import logger
from pystream.models import config


templates = Jinja2Templates(directory=os.path.join(pathlib.Path(__file__).parent.parent, "templates"))
templates = Jinja2Templates(directory=config.template_storage)


def log_connection(request: Request) -> None:
Expand Down
Loading

0 comments on commit 54e39cf

Please sign in to comment.