mirror of
https://github.com/eduardogsilva/wireguard_webadmin.git
synced 2026-03-17 22:36:17 +00:00
add initial implementation of auth gateway with models, routes, and session management
This commit is contained in:
18
containers/auth-gateway/Dockerfile-auth-gateway
Normal file
18
containers/auth-gateway/Dockerfile-auth-gateway
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY auth_gateway /app/auth_gateway
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
1
containers/auth-gateway/auth_gateway/__init__.py
Normal file
1
containers/auth-gateway/auth_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auth gateway package."""
|
||||
121
containers/auth-gateway/auth_gateway/config_loader.py
Normal file
121
containers/auth-gateway/auth_gateway/config_loader.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from auth_gateway.models.applications import ApplicationsFileModel
|
||||
from auth_gateway.models.auth import AuthPoliciesFileModel, OIDCMethodModel, PolicyModel
|
||||
from auth_gateway.models.routes import RoutesFileModel
|
||||
from auth_gateway.models.runtime import RuntimeConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _is_valid_provider_url(method_name: str, method: OIDCMethodModel) -> bool:
|
||||
if not method.provider:
|
||||
logger.warning("OIDC method '%s' has no provider URL configured — it will be unavailable.", method_name)
|
||||
return False
|
||||
parsed = urlparse(method.provider)
|
||||
hostname = parsed.hostname or ""
|
||||
if parsed.scheme not in {"http", "https"} or not hostname or ".." in hostname:
|
||||
logger.warning("OIDC method '%s' has an invalid provider URL ('%s') — it will be unavailable.", method_name, method.provider)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_applications(config_dir: Path) -> dict:
|
||||
combined = {}
|
||||
for filename in ("wireguard_webadmin.json", "applications.json"):
|
||||
payload = _load_json(config_dir / filename)
|
||||
if not payload:
|
||||
continue
|
||||
parsed = ApplicationsFileModel.model_validate(payload)
|
||||
for entry in parsed.entries:
|
||||
if entry.id in combined:
|
||||
raise ValueError(f"Duplicate application id detected: '{entry.id}'.")
|
||||
combined[entry.id] = entry
|
||||
return combined
|
||||
|
||||
|
||||
def load_runtime_config(config_dir: Path) -> RuntimeConfig:
|
||||
applications = _load_applications(config_dir)
|
||||
|
||||
routes_payload = _load_json(config_dir / "routes.json") or {}
|
||||
auth_payload = _load_json(config_dir / "auth_policies.json") or {}
|
||||
|
||||
routes = RoutesFileModel.model_validate(routes_payload)
|
||||
auth = AuthPoliciesFileModel.model_validate(auth_payload)
|
||||
|
||||
valid_auth_methods = {}
|
||||
for method_name, method in auth.auth_methods.items():
|
||||
if isinstance(method, OIDCMethodModel) and not _is_valid_provider_url(method_name, method):
|
||||
continue
|
||||
valid_auth_methods[method_name] = method
|
||||
auth.auth_methods = valid_auth_methods
|
||||
|
||||
for app_id in routes.entries:
|
||||
if app_id not in applications:
|
||||
raise ValueError(f"Routes reference unknown application '{app_id}'.")
|
||||
|
||||
for app_id, route_config in routes.entries.items():
|
||||
if route_config.default_policy and route_config.default_policy not in auth.policies:
|
||||
raise ValueError(f"Application '{app_id}' references unknown default policy '{route_config.default_policy}'.")
|
||||
for route in route_config.routes:
|
||||
if route.policy not in auth.policies:
|
||||
raise ValueError(f"Application '{app_id}' route '{route.path_prefix}' references unknown policy '{route.policy}'.")
|
||||
|
||||
for policy_name, policy in auth.policies.items():
|
||||
for group_name in policy.groups:
|
||||
if group_name not in auth.groups:
|
||||
raise ValueError(f"Policy '{policy_name}' references unknown group '{group_name}'.")
|
||||
for method_name in policy.methods:
|
||||
if method_name not in auth.auth_methods:
|
||||
logger.warning(
|
||||
"Policy '%s' references unavailable method '%s' — policy forced to deny.",
|
||||
policy_name, method_name,
|
||||
)
|
||||
auth.policies[policy_name] = PolicyModel(policy_type="deny")
|
||||
break
|
||||
|
||||
return RuntimeConfig(
|
||||
applications=applications,
|
||||
routes_by_app=routes.entries,
|
||||
auth_methods=auth.auth_methods,
|
||||
users=auth.users,
|
||||
groups=auth.groups,
|
||||
policies=auth.policies,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeConfigStore:
|
||||
def __init__(self, config_dir: Path):
|
||||
self.config_dir = config_dir
|
||||
self._runtime_config: RuntimeConfig | None = None
|
||||
self._mtimes: dict[str, int] = {}
|
||||
|
||||
def _current_mtimes(self) -> dict[str, int]:
|
||||
mtimes = {}
|
||||
for filename in (
|
||||
"wireguard_webadmin.json",
|
||||
"applications.json",
|
||||
"routes.json",
|
||||
"auth_policies.json",
|
||||
):
|
||||
path = self.config_dir / filename
|
||||
if path.exists():
|
||||
mtimes[filename] = path.stat().st_mtime_ns
|
||||
return mtimes
|
||||
|
||||
def get(self) -> RuntimeConfig:
|
||||
current_mtimes = self._current_mtimes()
|
||||
if self._runtime_config is None or current_mtimes != self._mtimes:
|
||||
self._runtime_config = load_runtime_config(self.config_dir)
|
||||
self._mtimes = current_mtimes
|
||||
return self._runtime_config
|
||||
37
containers/auth-gateway/auth_gateway/main.py
Normal file
37
containers/auth-gateway/auth_gateway/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from auth_gateway.config_loader import RuntimeConfigStore
|
||||
from auth_gateway.services.oidc_service import OIDCService
|
||||
from auth_gateway.services.session_service import SessionService
|
||||
from auth_gateway.settings import settings
|
||||
from auth_gateway.storage.sqlite import SQLiteStorage
|
||||
from auth_gateway.web.auth_routes import router as auth_router
|
||||
from auth_gateway.web.login_routes import router as login_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
storage = SQLiteStorage(settings.database_path)
|
||||
app.state.settings = settings
|
||||
app.state.config_store = RuntimeConfigStore(settings.config_dir)
|
||||
app.state.session_service = SessionService(storage, settings.session_default_minutes, settings.oidc_state_ttl_minutes)
|
||||
app.state.oidc_service = OIDCService()
|
||||
app.state.templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Auth Gateway", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(login_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {"status": "ok"}
|
||||
1
containers/auth-gateway/auth_gateway/models/__init__.py
Normal file
1
containers/auth-gateway/auth_gateway/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auth gateway data models."""
|
||||
20
containers/auth-gateway/auth_gateway/models/applications.py
Normal file
20
containers/auth-gateway/auth_gateway/models/applications.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StaticRouteModel(BaseModel):
|
||||
path_prefix: str
|
||||
root: str
|
||||
strip_prefix: str | None = None
|
||||
cache_control: str | None = None
|
||||
|
||||
|
||||
class ApplicationModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
hosts: list[str] = Field(default_factory=list)
|
||||
upstream: str
|
||||
static_routes: list[StaticRouteModel] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApplicationsFileModel(BaseModel):
|
||||
entries: list[ApplicationModel] = Field(default_factory=list)
|
||||
65
containers/auth-gateway/auth_gateway/models/auth.py
Normal file
65
containers/auth-gateway/auth_gateway/models/auth.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IPRuleModel(BaseModel):
|
||||
address: str
|
||||
prefix_length: int | None = None
|
||||
action: Literal["allow", "deny"]
|
||||
description: str | None = ""
|
||||
|
||||
|
||||
class TotpMethodModel(BaseModel):
|
||||
type: Literal["totp"]
|
||||
totp_secret: str | None = None
|
||||
session_expiration_minutes: int | None = None
|
||||
|
||||
|
||||
class LocalPasswordMethodModel(BaseModel):
|
||||
type: Literal["local_password"]
|
||||
session_expiration_minutes: int = 720
|
||||
|
||||
|
||||
class OIDCMethodModel(BaseModel):
|
||||
type: Literal["oidc"]
|
||||
provider: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
allowed_domains: list[str] = Field(default_factory=list)
|
||||
allowed_emails: list[str] = Field(default_factory=list)
|
||||
session_expiration_minutes: int = 720
|
||||
|
||||
|
||||
class IPAddressMethodModel(BaseModel):
|
||||
type: Literal["ip_address"]
|
||||
rules: list[IPRuleModel] = Field(default_factory=list)
|
||||
|
||||
|
||||
AuthMethodModel = Annotated[
|
||||
TotpMethodModel | LocalPasswordMethodModel | OIDCMethodModel | IPAddressMethodModel,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class UserModel(BaseModel):
|
||||
email: str | None = ""
|
||||
password_hash: str | None = None
|
||||
totp_secret: str | None = ""
|
||||
|
||||
|
||||
class GroupModel(BaseModel):
|
||||
users: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyModel(BaseModel):
|
||||
policy_type: Literal["bypass", "deny", "protected"]
|
||||
groups: list[str] = Field(default_factory=list)
|
||||
methods: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AuthPoliciesFileModel(BaseModel):
|
||||
auth_methods: dict[str, AuthMethodModel] = Field(default_factory=dict)
|
||||
groups: dict[str, GroupModel] = Field(default_factory=dict)
|
||||
users: dict[str, UserModel] = Field(default_factory=dict)
|
||||
policies: dict[str, PolicyModel] = Field(default_factory=dict)
|
||||
16
containers/auth-gateway/auth_gateway/models/routes.py
Normal file
16
containers/auth-gateway/auth_gateway/models/routes.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RoutePolicyBindingModel(BaseModel):
|
||||
id: str | None = None
|
||||
path_prefix: str
|
||||
policy: str
|
||||
|
||||
|
||||
class AppRoutesModel(BaseModel):
|
||||
routes: list[RoutePolicyBindingModel] = Field(default_factory=list)
|
||||
default_policy: str | None = None
|
||||
|
||||
|
||||
class RoutesFileModel(BaseModel):
|
||||
entries: dict[str, AppRoutesModel] = Field(default_factory=dict)
|
||||
13
containers/auth-gateway/auth_gateway/models/runtime.py
Normal file
13
containers/auth-gateway/auth_gateway/models/runtime.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from auth_gateway.models.applications import ApplicationModel
|
||||
from auth_gateway.models.auth import AuthMethodModel, GroupModel, PolicyModel, UserModel
|
||||
from auth_gateway.models.routes import AppRoutesModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RuntimeConfig(BaseModel):
|
||||
applications: dict[str, ApplicationModel] = Field(default_factory=dict)
|
||||
routes_by_app: dict[str, AppRoutesModel] = Field(default_factory=dict)
|
||||
auth_methods: dict[str, AuthMethodModel] = Field(default_factory=dict)
|
||||
users: dict[str, UserModel] = Field(default_factory=dict)
|
||||
groups: dict[str, GroupModel] = Field(default_factory=dict)
|
||||
policies: dict[str, PolicyModel] = Field(default_factory=dict)
|
||||
26
containers/auth-gateway/auth_gateway/models/session.py
Normal file
26
containers/auth-gateway/auth_gateway/models/session.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SessionRecord(BaseModel):
|
||||
session_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
subject: str | None = None
|
||||
groups: list[str] = Field(default_factory=list)
|
||||
auth_factors: list[str] = Field(default_factory=list)
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OIDCStateRecord(BaseModel):
|
||||
state: str
|
||||
nonce: str
|
||||
method_name: str
|
||||
host: str
|
||||
next_url: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
@@ -0,0 +1 @@
|
||||
"""Auth gateway service layer."""
|
||||
@@ -0,0 +1,59 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from auth_gateway.models.auth import OIDCMethodModel
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
class OIDCIdentity:
|
||||
subject: str | None
|
||||
email: str | None
|
||||
claims: dict
|
||||
|
||||
|
||||
class OIDCService:
|
||||
def __init__(self):
|
||||
self.oauth = OAuth()
|
||||
self._clients = {}
|
||||
|
||||
def _client(self, method_name: str, method: OIDCMethodModel):
|
||||
if method_name in self._clients:
|
||||
return self._clients[method_name]
|
||||
metadata_url = f"{method.provider.rstrip('/')}/.well-known/openid-configuration"
|
||||
client = self.oauth.register(
|
||||
name=f"oidc_{method_name}",
|
||||
client_id=method.client_id,
|
||||
client_secret=method.client_secret,
|
||||
server_metadata_url=metadata_url,
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
self._clients[method_name] = client
|
||||
return client
|
||||
|
||||
async def build_authorization_redirect(self, request, method_name: str, method: OIDCMethodModel, redirect_uri: str, state: str, nonce: str):
|
||||
client = self._client(method_name, method)
|
||||
return await client.authorize_redirect(request, redirect_uri, state=state, nonce=nonce)
|
||||
|
||||
async def finish_callback(self, request, method_name: str, method: OIDCMethodModel, nonce: str) -> OIDCIdentity:
|
||||
client = self._client(method_name, method)
|
||||
token = await client.authorize_access_token(request)
|
||||
claims = {}
|
||||
if "userinfo" in token and isinstance(token["userinfo"], dict):
|
||||
claims = token["userinfo"]
|
||||
elif "id_token" in token:
|
||||
claims = await client.parse_id_token(request, token, nonce=nonce)
|
||||
email = claims.get("email")
|
||||
subject = claims.get("sub")
|
||||
return OIDCIdentity(subject=subject, email=email, claims=dict(claims))
|
||||
|
||||
|
||||
def is_oidc_identity_allowed(method: OIDCMethodModel, email: str | None) -> bool:
|
||||
if not email:
|
||||
return not method.allowed_domains and not method.allowed_emails
|
||||
normalized_email = email.lower()
|
||||
normalized_domain = normalized_email.split("@", 1)[1] if "@" in normalized_email else ""
|
||||
allowed_emails = {item.lower() for item in method.allowed_emails}
|
||||
allowed_domains = {item.lower() for item in method.allowed_domains}
|
||||
if not allowed_emails and not allowed_domains:
|
||||
return True
|
||||
return normalized_email in allowed_emails or normalized_domain in allowed_domains
|
||||
@@ -0,0 +1,18 @@
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
from auth_gateway.models.auth import UserModel
|
||||
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def verify_user_password(username: str, password: str, users: dict[str, UserModel]) -> UserModel | None:
|
||||
user = users.get(username)
|
||||
if not user or not user.password_hash:
|
||||
return None
|
||||
try:
|
||||
password_hasher.verify(user.password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return None
|
||||
return user
|
||||
106
containers/auth-gateway/auth_gateway/services/policy_engine.py
Normal file
106
containers/auth-gateway/auth_gateway/services/policy_engine.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import ipaddress
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from auth_gateway.models.auth import (
|
||||
IPAddressMethodModel,
|
||||
IPRuleModel,
|
||||
LocalPasswordMethodModel,
|
||||
OIDCMethodModel,
|
||||
PolicyModel,
|
||||
TotpMethodModel,
|
||||
)
|
||||
from auth_gateway.models.runtime import RuntimeConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectivePolicy:
|
||||
name: str
|
||||
mode: str
|
||||
required_factors: list[str] = field(default_factory=list)
|
||||
allowed_users: set[str] = field(default_factory=set)
|
||||
allowed_groups: set[str] = field(default_factory=set)
|
||||
ip_method_names: list[str] = field(default_factory=list)
|
||||
totp_method_names: list[str] = field(default_factory=list)
|
||||
password_method_names: list[str] = field(default_factory=list)
|
||||
oidc_method_names: list[str] = field(default_factory=list)
|
||||
factor_expirations: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
def expand_policy_users(runtime_config: RuntimeConfig, policy: PolicyModel) -> set[str]:
|
||||
usernames: set[str] = set()
|
||||
for group_name in policy.groups:
|
||||
group = runtime_config.groups.get(group_name)
|
||||
if group:
|
||||
usernames.update(group.users)
|
||||
return usernames
|
||||
|
||||
|
||||
def build_effective_policy(runtime_config: RuntimeConfig, policy_name: str) -> EffectivePolicy | None:
|
||||
policy = runtime_config.policies.get(policy_name)
|
||||
if not policy:
|
||||
return None
|
||||
|
||||
effective = EffectivePolicy(
|
||||
name=policy_name,
|
||||
mode=policy.policy_type,
|
||||
allowed_users=expand_policy_users(runtime_config, policy),
|
||||
allowed_groups=set(policy.groups),
|
||||
)
|
||||
|
||||
if policy.policy_type != "protected":
|
||||
return effective
|
||||
|
||||
for method_name in policy.methods:
|
||||
method = runtime_config.auth_methods[method_name]
|
||||
if isinstance(method, IPAddressMethodModel):
|
||||
effective.ip_method_names.append(method_name)
|
||||
if "ip" not in effective.required_factors:
|
||||
effective.required_factors.append("ip")
|
||||
elif isinstance(method, LocalPasswordMethodModel):
|
||||
effective.password_method_names.append(method_name)
|
||||
effective.required_factors.append("password")
|
||||
effective.factor_expirations["password"] = method.session_expiration_minutes
|
||||
elif isinstance(method, TotpMethodModel):
|
||||
effective.totp_method_names.append(method_name)
|
||||
effective.required_factors.append("totp")
|
||||
effective.factor_expirations["totp"] = method.session_expiration_minutes or 720
|
||||
elif isinstance(method, OIDCMethodModel):
|
||||
effective.oidc_method_names.append(method_name)
|
||||
effective.required_factors.append("oidc")
|
||||
effective.factor_expirations["oidc"] = method.session_expiration_minutes
|
||||
|
||||
return effective
|
||||
|
||||
|
||||
def extract_client_ip(forwarded_for: str) -> str | None:
|
||||
if not forwarded_for:
|
||||
return None
|
||||
candidate = forwarded_for.split(",")[0].strip()
|
||||
try:
|
||||
return str(ipaddress.ip_address(candidate))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_ip_rules(client_ip: str | None, rules: list[IPRuleModel]) -> bool:
|
||||
if not client_ip:
|
||||
return False
|
||||
ip_value = ipaddress.ip_address(client_ip)
|
||||
for rule in rules:
|
||||
if rule.prefix_length is None:
|
||||
network = ipaddress.ip_network(f"{rule.address}/{'32' if ip_value.version == 4 else '128'}", strict=False)
|
||||
else:
|
||||
network = ipaddress.ip_network(f"{rule.address}/{rule.prefix_length}", strict=False)
|
||||
if ip_value in network:
|
||||
return rule.action == "allow"
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_ip_access(runtime_config: RuntimeConfig, effective_policy: EffectivePolicy, client_ip: str | None) -> bool:
|
||||
if not effective_policy.ip_method_names:
|
||||
return True
|
||||
for method_name in effective_policy.ip_method_names:
|
||||
method = runtime_config.auth_methods[method_name]
|
||||
if isinstance(method, IPAddressMethodModel) and evaluate_ip_rules(client_ip, method.rules):
|
||||
return True
|
||||
return False
|
||||
63
containers/auth-gateway/auth_gateway/services/resolver.py
Normal file
63
containers/auth-gateway/auth_gateway/services/resolver.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from auth_gateway.models.applications import ApplicationModel
|
||||
from auth_gateway.models.routes import AppRoutesModel, RoutePolicyBindingModel
|
||||
from auth_gateway.models.runtime import RuntimeConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
host: str
|
||||
path: str
|
||||
application: ApplicationModel
|
||||
route: RoutePolicyBindingModel | None
|
||||
policy_name: str
|
||||
|
||||
|
||||
def normalize_host(raw_host: str) -> str:
|
||||
return raw_host.split(":", 1)[0].strip().lower()
|
||||
|
||||
|
||||
def normalize_path(raw_uri: str) -> str:
|
||||
parsed = urlsplit(raw_uri or "/")
|
||||
path = parsed.path or "/"
|
||||
return path if path.startswith("/") else f"/{path}"
|
||||
|
||||
|
||||
def resolve_application(runtime_config: RuntimeConfig, host: str) -> ApplicationModel | None:
|
||||
normalized_host = normalize_host(host)
|
||||
for application in runtime_config.applications.values():
|
||||
if normalized_host in {candidate.lower() for candidate in application.hosts}:
|
||||
return application
|
||||
return None
|
||||
|
||||
|
||||
def resolve_route(runtime_config: RuntimeConfig, application_id: str, path: str) -> tuple[RoutePolicyBindingModel | None, str | None]:
|
||||
app_routes: AppRoutesModel | None = runtime_config.routes_by_app.get(application_id)
|
||||
if not app_routes:
|
||||
return None, None
|
||||
|
||||
normalized_path = normalize_path(path)
|
||||
sorted_routes = sorted(app_routes.routes, key=lambda route: len(route.path_prefix), reverse=True)
|
||||
for route in sorted_routes:
|
||||
route_prefix = normalize_path(route.path_prefix)
|
||||
if normalized_path.startswith(route_prefix):
|
||||
return route, route.policy
|
||||
return None, app_routes.default_policy
|
||||
|
||||
|
||||
def resolve_request_context(runtime_config: RuntimeConfig, host: str, path: str) -> RequestContext | None:
|
||||
application = resolve_application(runtime_config, host)
|
||||
if not application:
|
||||
return None
|
||||
route, policy_name = resolve_route(runtime_config, application.id, path)
|
||||
if not policy_name:
|
||||
return None
|
||||
return RequestContext(
|
||||
host=normalize_host(host),
|
||||
path=normalize_path(path),
|
||||
application=application,
|
||||
route=route,
|
||||
policy_name=policy_name,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from auth_gateway.models.session import OIDCStateRecord, SessionRecord
|
||||
from auth_gateway.storage.sqlite import SQLiteStorage
|
||||
|
||||
|
||||
class SessionService:
|
||||
def __init__(self, storage: SQLiteStorage, default_session_minutes: int, oidc_state_ttl_minutes: int):
|
||||
self.storage = storage
|
||||
self.default_session_minutes = default_session_minutes
|
||||
self.oidc_state_ttl_minutes = oidc_state_ttl_minutes
|
||||
|
||||
def get_session(self, session_id: str | None) -> SessionRecord | None:
|
||||
if not session_id:
|
||||
return None
|
||||
session = self.storage.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
if session.expires_at <= datetime.now(UTC):
|
||||
self.storage.delete_session(session_id)
|
||||
return None
|
||||
return session
|
||||
|
||||
def issue_session(
|
||||
self,
|
||||
existing_session: SessionRecord | None = None,
|
||||
*,
|
||||
username: str | None = None,
|
||||
email: str | None = None,
|
||||
subject: str | None = None,
|
||||
groups: list[str] | None = None,
|
||||
add_factors: list[str] | None = None,
|
||||
metadata: dict | None = None,
|
||||
expires_in_minutes: int | None = None,
|
||||
) -> SessionRecord:
|
||||
now = datetime.now(UTC)
|
||||
session = existing_session or SessionRecord(
|
||||
session_id=token_urlsafe(32),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
expires_at=now + timedelta(minutes=expires_in_minutes or self.default_session_minutes),
|
||||
)
|
||||
if username is not None:
|
||||
session.username = username
|
||||
if email is not None:
|
||||
session.email = email
|
||||
if subject is not None:
|
||||
session.subject = subject
|
||||
if groups is not None:
|
||||
session.groups = groups
|
||||
if metadata:
|
||||
session.metadata.update(metadata)
|
||||
if add_factors:
|
||||
merged_factors = set(session.auth_factors)
|
||||
merged_factors.update(add_factors)
|
||||
session.auth_factors = sorted(merged_factors)
|
||||
requested_expiry = now + timedelta(minutes=expires_in_minutes or self.default_session_minutes)
|
||||
session.expires_at = min(session.expires_at, requested_expiry) if existing_session else requested_expiry
|
||||
session.updated_at = now
|
||||
self.storage.save_session(session)
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str | None) -> None:
|
||||
if session_id:
|
||||
self.storage.delete_session(session_id)
|
||||
|
||||
def create_oidc_state(self, method_name: str, host: str, next_url: str) -> OIDCStateRecord:
|
||||
now = datetime.now(UTC)
|
||||
state = OIDCStateRecord(
|
||||
state=token_urlsafe(24),
|
||||
nonce=token_urlsafe(24),
|
||||
method_name=method_name,
|
||||
host=host,
|
||||
next_url=next_url,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(minutes=self.oidc_state_ttl_minutes),
|
||||
)
|
||||
self.storage.save_oidc_state(state)
|
||||
return state
|
||||
|
||||
def consume_oidc_state(self, state_value: str) -> OIDCStateRecord | None:
|
||||
oidc_state = self.storage.get_oidc_state(state_value)
|
||||
if not oidc_state:
|
||||
return None
|
||||
self.storage.delete_oidc_state(state_value)
|
||||
if oidc_state.expires_at <= datetime.now(UTC):
|
||||
return None
|
||||
return oidc_state
|
||||
@@ -0,0 +1,9 @@
|
||||
import pyotp
|
||||
|
||||
|
||||
def verify_totp(secret: str, token: str) -> bool:
|
||||
normalized_secret = secret.strip()
|
||||
normalized_token = token.strip().replace(" ", "")
|
||||
if not normalized_secret or not normalized_token:
|
||||
return False
|
||||
return pyotp.TOTP(normalized_secret).verify(normalized_token, valid_window=1)
|
||||
19
containers/auth-gateway/auth_gateway/settings.py
Normal file
19
containers/auth-gateway/auth_gateway/settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="AUTH_GATEWAY_", extra="ignore")
|
||||
|
||||
config_dir: Path = Field(default=Path("/caddy_json_export"))
|
||||
database_path: Path = Field(default=Path("/data/auth-gateway.sqlite3"))
|
||||
cookie_name: str = Field(default="auth_gateway_session")
|
||||
external_path: str = Field(default="/auth-gateway")
|
||||
secure_cookies: bool = Field(default=True)
|
||||
session_default_minutes: int = Field(default=720)
|
||||
oidc_state_ttl_minutes: int = Field(default=10)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
117
containers/auth-gateway/auth_gateway/static/style.css
Normal file
117
containers/auth-gateway/auth_gateway/static/style.css
Normal file
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f2efe7;
|
||||
--bg-accent: #ddd4c4;
|
||||
--card: rgba(255, 252, 246, 0.92);
|
||||
--ink: #1e1b18;
|
||||
--muted: #6a6258;
|
||||
--line: rgba(30, 27, 24, 0.12);
|
||||
--accent: #6b3f24;
|
||||
--accent-strong: #4c2714;
|
||||
--danger: #a0251d;
|
||||
--shadow: 0 30px 80px rgba(62, 40, 25, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(107, 63, 36, 0.14), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(93, 124, 96, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(100%, 440px);
|
||||
padding: 32px;
|
||||
border-radius: 24px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
font-size: clamp(1.9rem, 4vw, 2.3rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 13px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(30, 27, 24, 0.08);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 16px;
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
1
containers/auth-gateway/auth_gateway/storage/__init__.py
Normal file
1
containers/auth-gateway/auth_gateway/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Storage backends for auth gateway."""
|
||||
154
containers/auth-gateway/auth_gateway/storage/sqlite.py
Normal file
154
containers/auth-gateway/auth_gateway/storage/sqlite.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from auth_gateway.models.session import OIDCStateRecord, SessionRecord
|
||||
|
||||
|
||||
class SQLiteStorage:
|
||||
def __init__(self, database_path: Path):
|
||||
self.database_path = database_path
|
||||
self._lock = threading.Lock()
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.database_path, check_same_thread=False)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
subject TEXT,
|
||||
groups_json TEXT NOT NULL,
|
||||
auth_factors_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oidc_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
method_name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
next_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
@staticmethod
|
||||
def _to_iso(value: datetime) -> str:
|
||||
return value.astimezone(UTC).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _from_iso(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
def save_session(self, session: SessionRecord) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO sessions (
|
||||
session_id, created_at, updated_at, expires_at, username, email, subject,
|
||||
groups_json, auth_factors_json, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
session.session_id,
|
||||
self._to_iso(session.created_at),
|
||||
self._to_iso(session.updated_at),
|
||||
self._to_iso(session.expires_at),
|
||||
session.username,
|
||||
session.email,
|
||||
session.subject,
|
||||
json.dumps(session.groups),
|
||||
json.dumps(session.auth_factors),
|
||||
json.dumps(session.metadata),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get_session(self, session_id: str) -> SessionRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SessionRecord(
|
||||
session_id=row["session_id"],
|
||||
created_at=self._from_iso(row["created_at"]),
|
||||
updated_at=self._from_iso(row["updated_at"]),
|
||||
expires_at=self._from_iso(row["expires_at"]),
|
||||
username=row["username"],
|
||||
email=row["email"],
|
||||
subject=row["subject"],
|
||||
groups=json.loads(row["groups_json"]),
|
||||
auth_factors=json.loads(row["auth_factors_json"]),
|
||||
metadata=json.loads(row["metadata_json"]),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
||||
connection.commit()
|
||||
|
||||
def save_oidc_state(self, oidc_state: OIDCStateRecord) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO oidc_states (
|
||||
state, nonce, method_name, host, next_url, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
oidc_state.state,
|
||||
oidc_state.nonce,
|
||||
oidc_state.method_name,
|
||||
oidc_state.host,
|
||||
oidc_state.next_url,
|
||||
self._to_iso(oidc_state.created_at),
|
||||
self._to_iso(oidc_state.expires_at),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get_oidc_state(self, state: str) -> OIDCStateRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM oidc_states WHERE state = ?",
|
||||
(state,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return OIDCStateRecord(
|
||||
state=row["state"],
|
||||
nonce=row["nonce"],
|
||||
method_name=row["method_name"],
|
||||
host=row["host"],
|
||||
next_url=row["next_url"],
|
||||
created_at=self._from_iso(row["created_at"]),
|
||||
expires_at=self._from_iso(row["expires_at"]),
|
||||
)
|
||||
|
||||
def delete_oidc_state(self, state: str) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("DELETE FROM oidc_states WHERE state = ?", (state,))
|
||||
connection.commit()
|
||||
18
containers/auth-gateway/auth_gateway/templates/error.html
Normal file
18
containers/auth-gateway/auth_gateway/templates/error.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="{{ external_path }}/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Auth Gateway</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="muted">{{ message }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
29
containers/auth-gateway/auth_gateway/templates/login.html
Normal file
29
containers/auth-gateway/auth_gateway/templates/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in</title>
|
||||
<link rel="stylesheet" href="{{ external_path }}/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Auth Gateway</p>
|
||||
<h1>Sign in to {{ application_name }}</h1>
|
||||
<p class="muted">Active policy: {{ policy_name }}</p>
|
||||
<div class="stack">
|
||||
{% if "password" in methods %}
|
||||
<a class="button" href="{{ external_path }}/login/password?next={{ next | urlencode }}">Continue with username and password</a>
|
||||
{% endif %}
|
||||
{% if "oidc" in methods %}
|
||||
<a class="button secondary" href="{{ external_path }}/login/oidc/start?next={{ next | urlencode }}">Continue with OIDC</a>
|
||||
{% endif %}
|
||||
{% if "totp" in methods %}
|
||||
<a class="button secondary" href="{{ external_path }}/login/totp?next={{ next | urlencode }}">Continue with TOTP</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Password sign in</title>
|
||||
<link rel="stylesheet" href="{{ external_path }}/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Auth Gateway</p>
|
||||
<h1>Sign in to {{ application_name }}</h1>
|
||||
<p class="muted">Enter your local username and password.</p>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ external_path }}/login/password" class="stack">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="button" type="submit">Continue</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TOTP verification</title>
|
||||
<link rel="stylesheet" href="{{ external_path }}/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Auth Gateway</p>
|
||||
<h1>Verify access to {{ application_name }}</h1>
|
||||
<p class="muted">Enter the current code from your authenticator app.</p>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ external_path }}/login/totp" class="stack">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label class="field">
|
||||
<span>Verification code</span>
|
||||
<input type="text" name="token" inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</label>
|
||||
<button class="button" type="submit">Verify</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1
containers/auth-gateway/auth_gateway/web/__init__.py
Normal file
1
containers/auth-gateway/auth_gateway/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web routes for auth gateway."""
|
||||
63
containers/auth-gateway/auth_gateway/web/auth_routes.py
Normal file
63
containers/auth-gateway/auth_gateway/web/auth_routes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from auth_gateway.services.policy_engine import evaluate_ip_access, extract_client_ip
|
||||
from auth_gateway.services.resolver import resolve_request_context
|
||||
from auth_gateway.web.dependencies import (
|
||||
build_external_url,
|
||||
get_effective_policy,
|
||||
get_runtime_config,
|
||||
get_session,
|
||||
session_is_allowed,
|
||||
)
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/auth/check")
|
||||
async def auth_check(request: Request):
|
||||
runtime_config = get_runtime_config(request)
|
||||
forwarded_host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
|
||||
forwarded_uri = request.headers.get("x-forwarded-uri", "/")
|
||||
context = resolve_request_context(runtime_config, forwarded_host, forwarded_uri)
|
||||
if not context:
|
||||
return PlainTextResponse("Application was not found.", status_code=403)
|
||||
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
if effective_policy.mode == "deny":
|
||||
return PlainTextResponse("Access denied by policy.", status_code=403)
|
||||
if effective_policy.mode == "bypass":
|
||||
return PlainTextResponse("OK", status_code=200)
|
||||
|
||||
client_ip = extract_client_ip(request.headers.get("x-forwarded-for", ""))
|
||||
ip_allowed = evaluate_ip_access(runtime_config, effective_policy, client_ip)
|
||||
if effective_policy.ip_method_names and not ip_allowed:
|
||||
return PlainTextResponse("Access denied for this IP address.", status_code=403)
|
||||
|
||||
session = get_session(request)
|
||||
if not session_is_allowed(session, effective_policy):
|
||||
if session and session.username:
|
||||
return PlainTextResponse("Authenticated user is not allowed by this policy.", status_code=403)
|
||||
login_url = build_external_url(request, "/login", next=context.path)
|
||||
return RedirectResponse(login_url, status_code=302)
|
||||
|
||||
current_factors = set(session.auth_factors if session else [])
|
||||
if ip_allowed:
|
||||
current_factors.add("ip")
|
||||
|
||||
missing_factors = [factor for factor in effective_policy.required_factors if factor not in current_factors]
|
||||
if missing_factors:
|
||||
login_url = build_external_url(request, "/login", next=context.path)
|
||||
return RedirectResponse(login_url, status_code=302)
|
||||
|
||||
response = PlainTextResponse("OK", status_code=200)
|
||||
if session:
|
||||
if session.username:
|
||||
response.headers["X-Auth-User"] = session.username
|
||||
if session.email:
|
||||
response.headers["X-Auth-Email"] = session.email
|
||||
if session.groups:
|
||||
response.headers["X-Auth-Groups"] = ",".join(session.groups)
|
||||
if session.auth_factors:
|
||||
response.headers["X-Auth-Factors"] = ",".join(session.auth_factors)
|
||||
response.headers["X-Auth-Policy"] = effective_policy.name
|
||||
return response
|
||||
82
containers/auth-gateway/auth_gateway/web/dependencies.py
Normal file
82
containers/auth-gateway/auth_gateway/web/dependencies.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from auth_gateway.config_loader import RuntimeConfigStore
|
||||
from auth_gateway.models.auth import OIDCMethodModel, TotpMethodModel
|
||||
from auth_gateway.models.runtime import RuntimeConfig
|
||||
from auth_gateway.models.session import SessionRecord
|
||||
from auth_gateway.services.policy_engine import EffectivePolicy, build_effective_policy
|
||||
from auth_gateway.services.resolver import RequestContext, normalize_host, normalize_path, resolve_request_context
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
def get_runtime_config(request: Request) -> RuntimeConfig:
|
||||
store: RuntimeConfigStore = request.app.state.config_store
|
||||
return store.get()
|
||||
|
||||
|
||||
def get_session(request: Request) -> SessionRecord | None:
|
||||
session_service = request.app.state.session_service
|
||||
cookie_name = request.app.state.settings.cookie_name
|
||||
return session_service.get_session(request.cookies.get(cookie_name))
|
||||
|
||||
|
||||
def build_external_url(request: Request, path: str, **params: str) -> str:
|
||||
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||
host = request.headers.get("host", request.url.netloc)
|
||||
prefix = request.app.state.settings.external_path.rstrip("/")
|
||||
query = urlencode({key: value for key, value in params.items() if value is not None})
|
||||
base = f"{proto}://{host}{prefix}{path}"
|
||||
return f"{base}?{query}" if query else base
|
||||
|
||||
|
||||
def normalize_next_path(next_url: str | None) -> str:
|
||||
if not next_url:
|
||||
return "/"
|
||||
normalized = normalize_path(next_url)
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_context_from_request(request: Request, runtime_config: RuntimeConfig, next_url: str | None = None) -> RequestContext:
|
||||
host = normalize_host(request.headers.get("host", request.url.hostname or ""))
|
||||
path = normalize_next_path(next_url)
|
||||
context = resolve_request_context(runtime_config, host, path)
|
||||
if not context:
|
||||
raise HTTPException(status_code=403, detail="No matching application or policy.")
|
||||
return context
|
||||
|
||||
|
||||
def get_effective_policy(runtime_config: RuntimeConfig, policy_name: str) -> EffectivePolicy:
|
||||
effective_policy = build_effective_policy(runtime_config, policy_name)
|
||||
if not effective_policy:
|
||||
raise HTTPException(status_code=403, detail="Referenced policy was not found.")
|
||||
return effective_policy
|
||||
|
||||
|
||||
def session_is_allowed(session: SessionRecord | None, effective_policy: EffectivePolicy) -> bool:
|
||||
if not effective_policy.allowed_users:
|
||||
return True
|
||||
if not session or not session.username:
|
||||
return False
|
||||
return session.username in effective_policy.allowed_users
|
||||
|
||||
|
||||
def get_totp_method(runtime_config: RuntimeConfig, effective_policy: EffectivePolicy) -> tuple[str, TotpMethodModel] | tuple[None, None]:
|
||||
for method_name in effective_policy.totp_method_names:
|
||||
method = runtime_config.auth_methods[method_name]
|
||||
if isinstance(method, TotpMethodModel):
|
||||
return method_name, method
|
||||
return None, None
|
||||
|
||||
|
||||
def get_oidc_method(runtime_config: RuntimeConfig, effective_policy: EffectivePolicy) -> tuple[str, OIDCMethodModel] | tuple[None, None]:
|
||||
for method_name in effective_policy.oidc_method_names:
|
||||
method = runtime_config.auth_methods[method_name]
|
||||
if isinstance(method, OIDCMethodModel):
|
||||
return method_name, method
|
||||
return None, None
|
||||
|
||||
|
||||
def get_effective_expiration(request: Request, effective_policy: EffectivePolicy, factors: list[str]) -> int:
|
||||
settings = request.app.state.settings
|
||||
expirations = [effective_policy.factor_expirations.get(factor) for factor in factors if effective_policy.factor_expirations.get(factor)]
|
||||
return min(expirations) if expirations else settings.session_default_minutes
|
||||
245
containers/auth-gateway/auth_gateway/web/login_routes.py
Normal file
245
containers/auth-gateway/auth_gateway/web/login_routes.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from auth_gateway.models.auth import OIDCMethodModel
|
||||
from auth_gateway.services.oidc_service import is_oidc_identity_allowed
|
||||
from auth_gateway.services.password_service import verify_user_password
|
||||
from auth_gateway.services.policy_engine import evaluate_ip_access, extract_client_ip
|
||||
from auth_gateway.services.resolver import normalize_host
|
||||
from auth_gateway.services.totp_service import verify_totp
|
||||
from auth_gateway.web.dependencies import (
|
||||
build_external_url,
|
||||
get_effective_expiration,
|
||||
get_effective_policy,
|
||||
get_oidc_method,
|
||||
get_runtime_config,
|
||||
get_session,
|
||||
get_totp_method,
|
||||
resolve_context_from_request,
|
||||
session_is_allowed,
|
||||
)
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _render(request: Request, template_name: str, status_code: int = 200, **context):
|
||||
templates = request.app.state.templates
|
||||
base_context = {
|
||||
"request": request,
|
||||
"external_path": request.app.state.settings.external_path,
|
||||
}
|
||||
base_context.update(context)
|
||||
return templates.TemplateResponse(template_name, base_context, status_code=status_code)
|
||||
|
||||
|
||||
def _redirect_with_cookie(request: Request, destination: str, session) -> RedirectResponse:
|
||||
response = RedirectResponse(destination, status_code=303)
|
||||
response.set_cookie(
|
||||
key=request.app.state.settings.cookie_name,
|
||||
value=session.session_id,
|
||||
httponly=True,
|
||||
secure=request.app.state.settings.secure_cookies,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str = "/"):
|
||||
runtime_config = get_runtime_config(request)
|
||||
session = get_session(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
|
||||
if effective_policy.mode == "deny":
|
||||
return _render(request, "error.html", status_code=403, title="Access denied", message="This route is blocked by policy.")
|
||||
if effective_policy.mode == "bypass":
|
||||
return RedirectResponse(next, status_code=303)
|
||||
|
||||
if session and session_is_allowed(session, effective_policy):
|
||||
current_factors = set(session.auth_factors)
|
||||
if effective_policy.ip_method_names:
|
||||
client_ip = extract_client_ip(request.headers.get("x-forwarded-for", ""))
|
||||
if evaluate_ip_access(runtime_config, effective_policy, client_ip):
|
||||
current_factors.add("ip")
|
||||
missing_factors = [factor for factor in effective_policy.required_factors if factor not in current_factors]
|
||||
if not missing_factors:
|
||||
return RedirectResponse(next, status_code=303)
|
||||
if missing_factors == ["totp"]:
|
||||
return RedirectResponse(build_external_url(request, "/login/totp", next=next), status_code=303)
|
||||
if missing_factors == ["oidc"]:
|
||||
return RedirectResponse(build_external_url(request, "/login/oidc/start", next=next), status_code=303)
|
||||
|
||||
available_methods = []
|
||||
if effective_policy.password_method_names:
|
||||
available_methods.append("password")
|
||||
if effective_policy.oidc_method_names:
|
||||
available_methods.append("oidc")
|
||||
if effective_policy.totp_method_names and not effective_policy.password_method_names and not effective_policy.oidc_method_names:
|
||||
available_methods.append("totp")
|
||||
|
||||
if available_methods == ["password"]:
|
||||
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
||||
if available_methods == ["oidc"]:
|
||||
return RedirectResponse(build_external_url(request, "/login/oidc/start", next=next), status_code=303)
|
||||
if available_methods == ["totp"]:
|
||||
return RedirectResponse(build_external_url(request, "/login/totp", next=next), status_code=303)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"login.html",
|
||||
next=next,
|
||||
methods=available_methods,
|
||||
application_name=context.application.name,
|
||||
policy_name=context.policy_name,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login/password", response_class=HTMLResponse)
|
||||
async def login_password_page(request: Request, next: str = "/"):
|
||||
runtime_config = get_runtime_config(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
if not effective_policy.password_method_names:
|
||||
return _render(request, "error.html", status_code=400, title="Password login unavailable", message="The selected policy does not require a password step.")
|
||||
return _render(request, "login_password.html", next=next, application_name=context.application.name, error=None)
|
||||
|
||||
|
||||
@router.post("/login/password")
|
||||
async def login_password_submit(request: Request, next: str = Form("/"), username: str = Form(...), password: str = Form(...)):
|
||||
runtime_config = get_runtime_config(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
user = verify_user_password(username, password, runtime_config.users)
|
||||
|
||||
if not user:
|
||||
return _render(request, "login_password.html", status_code=401, next=next, application_name=context.application.name, error="Invalid username or password.")
|
||||
if effective_policy.allowed_users and username not in effective_policy.allowed_users:
|
||||
return _render(request, "login_password.html", status_code=403, next=next, application_name=context.application.name, error="This user is not allowed by the active policy.")
|
||||
|
||||
groups = [
|
||||
group_name
|
||||
for group_name, group in runtime_config.groups.items()
|
||||
if username in group.users
|
||||
]
|
||||
session_service = request.app.state.session_service
|
||||
session = session_service.issue_session(
|
||||
existing_session=get_session(request),
|
||||
username=username,
|
||||
email=user.email or None,
|
||||
groups=groups,
|
||||
add_factors=["password"],
|
||||
expires_in_minutes=get_effective_expiration(request, effective_policy, ["password"]),
|
||||
)
|
||||
|
||||
if effective_policy.totp_method_names:
|
||||
return _redirect_with_cookie(request, build_external_url(request, "/login/totp", next=next), session)
|
||||
return _redirect_with_cookie(request, next, session)
|
||||
|
||||
|
||||
@router.get("/login/totp", response_class=HTMLResponse)
|
||||
async def login_totp_page(request: Request, next: str = "/"):
|
||||
runtime_config = get_runtime_config(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
if not effective_policy.totp_method_names:
|
||||
return _render(request, "error.html", status_code=400, title="TOTP unavailable", message="The selected policy does not require a TOTP step.")
|
||||
if effective_policy.password_method_names:
|
||||
session = get_session(request)
|
||||
if not session or "password" not in session.auth_factors:
|
||||
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
||||
return _render(request, "login_totp.html", next=next, application_name=context.application.name, error=None)
|
||||
|
||||
|
||||
@router.post("/login/totp")
|
||||
async def login_totp_submit(request: Request, next: str = Form("/"), token: str = Form(...)):
|
||||
runtime_config = get_runtime_config(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
session = get_session(request)
|
||||
|
||||
if effective_policy.password_method_names and (not session or "password" not in session.auth_factors):
|
||||
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
||||
if effective_policy.oidc_method_names and (not session or "oidc" not in session.auth_factors):
|
||||
return RedirectResponse(build_external_url(request, "/login/oidc/start", next=next), status_code=303)
|
||||
|
||||
method_name, method = get_totp_method(runtime_config, effective_policy)
|
||||
if not method_name or not method:
|
||||
return _render(request, "error.html", status_code=400, title="TOTP configuration missing", message="No usable TOTP method is configured for this policy.")
|
||||
|
||||
secret = method.totp_secret or ""
|
||||
if session and session.username:
|
||||
user = runtime_config.users.get(session.username)
|
||||
if user and user.totp_secret:
|
||||
secret = user.totp_secret
|
||||
|
||||
if not verify_totp(secret, token):
|
||||
return _render(request, "login_totp.html", status_code=401, next=next, application_name=context.application.name, error="Invalid verification code.")
|
||||
|
||||
session_service = request.app.state.session_service
|
||||
refreshed_session = session_service.issue_session(
|
||||
existing_session=session,
|
||||
add_factors=["totp"],
|
||||
expires_in_minutes=get_effective_expiration(request, effective_policy, ["totp"]),
|
||||
)
|
||||
return _redirect_with_cookie(request, next, refreshed_session)
|
||||
|
||||
|
||||
@router.get("/login/oidc/start")
|
||||
async def login_oidc_start(request: Request, next: str = "/"):
|
||||
runtime_config = get_runtime_config(request)
|
||||
context = resolve_context_from_request(request, runtime_config, next)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
method_name, method = get_oidc_method(runtime_config, effective_policy)
|
||||
if not method_name or not method:
|
||||
return _render(request, "error.html", status_code=400, title="OIDC unavailable", message="The selected policy does not require OIDC.")
|
||||
|
||||
session_state = request.app.state.session_service.create_oidc_state(method_name, normalize_host(request.headers.get("host", "")), next)
|
||||
redirect_uri = build_external_url(request, "/login/oidc/callback")
|
||||
return await request.app.state.oidc_service.build_authorization_redirect(
|
||||
request,
|
||||
method_name,
|
||||
method,
|
||||
redirect_uri,
|
||||
session_state.state,
|
||||
session_state.nonce,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login/oidc/callback")
|
||||
async def login_oidc_callback(request: Request, state: str):
|
||||
runtime_config = get_runtime_config(request)
|
||||
oidc_state = request.app.state.session_service.consume_oidc_state(state)
|
||||
if not oidc_state:
|
||||
return _render(request, "error.html", status_code=400, title="Invalid OIDC state", message="The OIDC login state is missing or expired.")
|
||||
|
||||
context = resolve_context_from_request(request, runtime_config, oidc_state.next_url)
|
||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||
method = runtime_config.auth_methods.get(oidc_state.method_name)
|
||||
if not isinstance(method, OIDCMethodModel):
|
||||
return _render(request, "error.html", status_code=400, title="OIDC configuration missing", message="The referenced OIDC method is no longer available.")
|
||||
|
||||
identity = await request.app.state.oidc_service.finish_callback(request, oidc_state.method_name, method, oidc_state.nonce)
|
||||
if not is_oidc_identity_allowed(method, identity.email):
|
||||
return _render(request, "error.html", status_code=403, title="OIDC access denied", message="The authenticated OIDC identity is not allowed by the configured allowlists.")
|
||||
|
||||
session = request.app.state.session_service.issue_session(
|
||||
existing_session=get_session(request),
|
||||
email=identity.email,
|
||||
subject=identity.subject,
|
||||
metadata={"oidc_claims": identity.claims},
|
||||
add_factors=["oidc"],
|
||||
expires_in_minutes=get_effective_expiration(request, effective_policy, ["oidc"]),
|
||||
)
|
||||
if effective_policy.totp_method_names:
|
||||
return _redirect_with_cookie(request, build_external_url(request, "/login/totp", next=oidc_state.next_url), session)
|
||||
return _redirect_with_cookie(request, oidc_state.next_url, session)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, next: str = Form("/")):
|
||||
session_cookie = request.cookies.get(request.app.state.settings.cookie_name)
|
||||
request.app.state.session_service.delete_session(session_cookie)
|
||||
response = RedirectResponse(next or "/", status_code=303)
|
||||
response.delete_cookie(request.app.state.settings.cookie_name, path="/")
|
||||
return response
|
||||
5
containers/auth-gateway/entrypoint.sh
Normal file
5
containers/auth-gateway/entrypoint.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
exec uvicorn auth_gateway.main:app --host 0.0.0.0 --port 9091
|
||||
10
containers/auth-gateway/requirements.txt
Normal file
10
containers/auth-gateway/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic==2.11.7
|
||||
authlib==1.6.4
|
||||
httpx==0.28.1
|
||||
pyotp==2.9.0
|
||||
argon2-cffi==25.1.0
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
pydantic-settings==2.10.1
|
||||
63
containers/auth-gateway/tests/test_config_and_policy.py
Normal file
63
containers/auth-gateway/tests/test_config_and_policy.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pyotp
|
||||
from auth_gateway.config_loader import load_runtime_config
|
||||
from auth_gateway.services.policy_engine import build_effective_policy, evaluate_ip_rules
|
||||
from auth_gateway.services.resolver import resolve_request_context
|
||||
from auth_gateway.services.totp_service import verify_totp
|
||||
|
||||
|
||||
class AuthGatewayConfigTests(unittest.TestCase):
|
||||
def test_existing_config_loads_and_resolves_routes(self):
|
||||
config_dir = Path(__file__).resolve().parents[2] / "caddy" / "config_files"
|
||||
runtime_config = load_runtime_config(config_dir)
|
||||
|
||||
context = resolve_request_context(runtime_config, "app1-dev.local", "/admin/settings")
|
||||
self.assertIsNotNone(context)
|
||||
self.assertEqual(context.policy_name, "senha-totp")
|
||||
|
||||
context = resolve_request_context(runtime_config, "app1-dev.local", "/api/status")
|
||||
self.assertIsNotNone(context)
|
||||
self.assertEqual(context.policy_name, "ips-conhecidos")
|
||||
|
||||
policy = build_effective_policy(runtime_config, "senha-totp")
|
||||
self.assertIsNotNone(policy)
|
||||
self.assertEqual(policy.required_factors, ["totp", "password"])
|
||||
|
||||
def test_invalid_oidc_provider_fails_loudly(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
(tmp_path / "wireguard_webadmin.json").write_text(
|
||||
'{"entries":[{"id":"app","name":"app","hosts":["app.local"],"upstream":"http://app"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "auth_policies.json").write_text(
|
||||
'{"auth_methods":{"oidc":{"type":"oidc","provider":"https://bad..host","client_id":"a","client_secret":"b","allowed_domains":[],"allowed_emails":[]}},"policies":{"default":{"policy_type":"protected","groups":[],"methods":["oidc"]}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "routes.json").write_text(
|
||||
'{"entries":{"app":{"routes":[],"default_policy":"default"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
load_runtime_config(tmp_path)
|
||||
|
||||
def test_ip_rules_respect_json_order(self):
|
||||
config_dir = Path(__file__).resolve().parents[2] / "caddy" / "config_files"
|
||||
runtime_config = load_runtime_config(config_dir)
|
||||
method = runtime_config.auth_methods["iplist1"]
|
||||
self.assertTrue(evaluate_ip_rules("192.168.0.8", method.rules))
|
||||
self.assertTrue(evaluate_ip_rules("192.168.0.12", method.rules))
|
||||
self.assertFalse(evaluate_ip_rules("10.10.10.10", method.rules))
|
||||
|
||||
def test_totp_verification_accepts_valid_tokens(self):
|
||||
secret = "JBSWY3DPEHPK3PXP"
|
||||
token = pyotp.TOTP(secret).now()
|
||||
self.assertTrue(verify_totp(secret, token))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user