diff --git a/containers/auth-gateway/Dockerfile-auth-gateway b/containers/auth-gateway/Dockerfile-auth-gateway new file mode 100644 index 0000000..a9dc09f --- /dev/null +++ b/containers/auth-gateway/Dockerfile-auth-gateway @@ -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"] diff --git a/containers/auth-gateway/auth_gateway/__init__.py b/containers/auth-gateway/auth_gateway/__init__.py new file mode 100644 index 0000000..c670073 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/__init__.py @@ -0,0 +1 @@ +"""Auth gateway package.""" diff --git a/containers/auth-gateway/auth_gateway/config_loader.py b/containers/auth-gateway/auth_gateway/config_loader.py new file mode 100644 index 0000000..8a1a556 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/config_loader.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/main.py b/containers/auth-gateway/auth_gateway/main.py new file mode 100644 index 0000000..f911891 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/main.py @@ -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"} diff --git a/containers/auth-gateway/auth_gateway/models/__init__.py b/containers/auth-gateway/auth_gateway/models/__init__.py new file mode 100644 index 0000000..33e2869 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/__init__.py @@ -0,0 +1 @@ +"""Auth gateway data models.""" diff --git a/containers/auth-gateway/auth_gateway/models/applications.py b/containers/auth-gateway/auth_gateway/models/applications.py new file mode 100644 index 0000000..f0b63d1 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/applications.py @@ -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) diff --git a/containers/auth-gateway/auth_gateway/models/auth.py b/containers/auth-gateway/auth_gateway/models/auth.py new file mode 100644 index 0000000..fdc73b3 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/auth.py @@ -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) diff --git a/containers/auth-gateway/auth_gateway/models/routes.py b/containers/auth-gateway/auth_gateway/models/routes.py new file mode 100644 index 0000000..37527ca --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/routes.py @@ -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) diff --git a/containers/auth-gateway/auth_gateway/models/runtime.py b/containers/auth-gateway/auth_gateway/models/runtime.py new file mode 100644 index 0000000..169074a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/runtime.py @@ -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) diff --git a/containers/auth-gateway/auth_gateway/models/session.py b/containers/auth-gateway/auth_gateway/models/session.py new file mode 100644 index 0000000..66a7342 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/session.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/services/__init__.py b/containers/auth-gateway/auth_gateway/services/__init__.py new file mode 100644 index 0000000..e02cd2d --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/__init__.py @@ -0,0 +1 @@ +"""Auth gateway service layer.""" diff --git a/containers/auth-gateway/auth_gateway/services/oidc_service.py b/containers/auth-gateway/auth_gateway/services/oidc_service.py new file mode 100644 index 0000000..1a3d294 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/oidc_service.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/services/password_service.py b/containers/auth-gateway/auth_gateway/services/password_service.py new file mode 100644 index 0000000..da824ca --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/password_service.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/services/policy_engine.py b/containers/auth-gateway/auth_gateway/services/policy_engine.py new file mode 100644 index 0000000..0d85a55 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/policy_engine.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/services/resolver.py b/containers/auth-gateway/auth_gateway/services/resolver.py new file mode 100644 index 0000000..e410c17 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/resolver.py @@ -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, + ) diff --git a/containers/auth-gateway/auth_gateway/services/session_service.py b/containers/auth-gateway/auth_gateway/services/session_service.py new file mode 100644 index 0000000..9457194 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/session_service.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/services/totp_service.py b/containers/auth-gateway/auth_gateway/services/totp_service.py new file mode 100644 index 0000000..4e5701a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/totp_service.py @@ -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) diff --git a/containers/auth-gateway/auth_gateway/settings.py b/containers/auth-gateway/auth_gateway/settings.py new file mode 100644 index 0000000..9d0717d --- /dev/null +++ b/containers/auth-gateway/auth_gateway/settings.py @@ -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() diff --git a/containers/auth-gateway/auth_gateway/static/style.css b/containers/auth-gateway/auth_gateway/static/style.css new file mode 100644 index 0000000..e9394e8 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/static/style.css @@ -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; +} diff --git a/containers/auth-gateway/auth_gateway/storage/__init__.py b/containers/auth-gateway/auth_gateway/storage/__init__.py new file mode 100644 index 0000000..039d2e9 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/storage/__init__.py @@ -0,0 +1 @@ +"""Storage backends for auth gateway.""" diff --git a/containers/auth-gateway/auth_gateway/storage/sqlite.py b/containers/auth-gateway/auth_gateway/storage/sqlite.py new file mode 100644 index 0000000..1df4b45 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/storage/sqlite.py @@ -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() diff --git a/containers/auth-gateway/auth_gateway/templates/error.html b/containers/auth-gateway/auth_gateway/templates/error.html new file mode 100644 index 0000000..c8c538a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/templates/error.html @@ -0,0 +1,18 @@ + + + + + + {{ title }} + + + +
+
+

Auth Gateway

+

{{ title }}

+

{{ message }}

+
+
+ + diff --git a/containers/auth-gateway/auth_gateway/templates/login.html b/containers/auth-gateway/auth_gateway/templates/login.html new file mode 100644 index 0000000..29d193b --- /dev/null +++ b/containers/auth-gateway/auth_gateway/templates/login.html @@ -0,0 +1,29 @@ + + + + + + Sign in + + + +
+
+

Auth Gateway

+

Sign in to {{ application_name }}

+

Active policy: {{ policy_name }}

+
+ {% if "password" in methods %} + Continue with username and password + {% endif %} + {% if "oidc" in methods %} + Continue with OIDC + {% endif %} + {% if "totp" in methods %} + Continue with TOTP + {% endif %} +
+
+
+ + diff --git a/containers/auth-gateway/auth_gateway/templates/login_password.html b/containers/auth-gateway/auth_gateway/templates/login_password.html new file mode 100644 index 0000000..f4cba7a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/templates/login_password.html @@ -0,0 +1,33 @@ + + + + + + Password sign in + + + +
+
+

Auth Gateway

+

Sign in to {{ application_name }}

+

Enter your local username and password.

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + + +
+
+
+ + diff --git a/containers/auth-gateway/auth_gateway/templates/login_totp.html b/containers/auth-gateway/auth_gateway/templates/login_totp.html new file mode 100644 index 0000000..a651761 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/templates/login_totp.html @@ -0,0 +1,29 @@ + + + + + + TOTP verification + + + +
+
+

Auth Gateway

+

Verify access to {{ application_name }}

+

Enter the current code from your authenticator app.

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + +
+
+
+ + diff --git a/containers/auth-gateway/auth_gateway/web/__init__.py b/containers/auth-gateway/auth_gateway/web/__init__.py new file mode 100644 index 0000000..b2a0fc8 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/web/__init__.py @@ -0,0 +1 @@ +"""Web routes for auth gateway.""" diff --git a/containers/auth-gateway/auth_gateway/web/auth_routes.py b/containers/auth-gateway/auth_gateway/web/auth_routes.py new file mode 100644 index 0000000..cbfa8bf --- /dev/null +++ b/containers/auth-gateway/auth_gateway/web/auth_routes.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/web/dependencies.py b/containers/auth-gateway/auth_gateway/web/dependencies.py new file mode 100644 index 0000000..0df9eaa --- /dev/null +++ b/containers/auth-gateway/auth_gateway/web/dependencies.py @@ -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 diff --git a/containers/auth-gateway/auth_gateway/web/login_routes.py b/containers/auth-gateway/auth_gateway/web/login_routes.py new file mode 100644 index 0000000..f4e0094 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/web/login_routes.py @@ -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 diff --git a/containers/auth-gateway/entrypoint.sh b/containers/auth-gateway/entrypoint.sh new file mode 100644 index 0000000..d485250 --- /dev/null +++ b/containers/auth-gateway/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +exec uvicorn auth_gateway.main:app --host 0.0.0.0 --port 9091 diff --git a/containers/auth-gateway/requirements.txt b/containers/auth-gateway/requirements.txt new file mode 100644 index 0000000..6a2d5f8 --- /dev/null +++ b/containers/auth-gateway/requirements.txt @@ -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 diff --git a/containers/auth-gateway/tests/test_config_and_policy.py b/containers/auth-gateway/tests/test_config_and_policy.py new file mode 100644 index 0000000..4547d76 --- /dev/null +++ b/containers/auth-gateway/tests/test_config_and_policy.py @@ -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()