diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..05963e5 --- /dev/null +++ b/example/README.md @@ -0,0 +1,23 @@ +# Example +This example provides an environment where you can test splash_auth with and OIDC auth provider of your choice. + +This has been tested with podman and podman-compose. It has not been tested with docker. + +##Services +### nginx +The services herin use `nginx` to handle proxying and authenticating. + +### splash_auth +Provides service side support for the OIDC Code flow + +### python_server +A simple python server, demonstrating that you can access it if you're logged in, and not if you're not. + + + +## Setup +1. Edit `/examples/.env`, adding `client_id` and `client_secret` for your provider. +2. Edit `users.yml` and `api_keys.yml` adding what you need. +3. cd in to the `exmaples` directory and type `podman-compose up -d` +4. Browse to localhost:8080 + \ No newline at end of file diff --git a/example/docker-compose.yml b/example/docker-compose.yml new file mode 100644 index 0000000..220372e --- /dev/null +++ b/example/docker-compose.yml @@ -0,0 +1,57 @@ +version: "3.3" +services: + python_server: + image: "python:3.11-slim-buster" + + expose: + - "8081" + command: "python -m http.server 4200" + + nginx: + container_name: nginx + image: nginx + ports: + - 127.0.0.1:8080:80 + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + #restart: unless-stopped + logging: + options: + max-size: "1m" + max-file: "3" + networks: + splash_auth_network: + + splash_auth: + container_name: splash_auth + #image: ghcr.io/als-computing/splash_auth:main + build: + context: .. + # command: sleep 99999 + command: uvicorn splash_auth.main:app --proxy-headers --host 0.0.0.0 --port 8000 --log-level=debug --use-colors --reload + environment: + - OAUTH_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} + - OAUTH_REDIRECT_URI=http://localhost:8080/oidc/auth/code + - OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token + - OUATH_JWKS_URI=https://www.googleapis.com/oauth2/v3/certs + - TOKEN_EXP_TIME=172400 + - JWT_SECRET=${JWT_SECRET} + - OUATH_SUCCESS_REDIRECT_URI=http://localhost:8080/ + - OUATH_FAIL_REDIRECT_URI=http://localhost:8080 + - HTTPX_LOG_LEVEL=trace + volumes: + - ../:/app + - ./users.yml:/app/users.yml + - ./api_keys.yml:/app/api_keys.yml + restart: unless-stopped + logging: + options: + max-size: "1m" + max-file: "3" + networks: + splash_auth_network: +networks: + splash_auth_network: + driver: bridge diff --git a/example/nginx/nginx.conf b/example/nginx/nginx.conf new file mode 100644 index 0000000..0f3dee7 --- /dev/null +++ b/example/nginx/nginx.conf @@ -0,0 +1,94 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + gzip on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + underscores_in_headers on; + + server{ + listen 80; + keepalive_timeout 70; + + # All HTTP traffic will be redirected to to the auth server + location / { + auth_request /oauth2/auth; + error_page 401 = /login; + proxy_pass http://python_server:4200; + proxy_buffer_size 8k; + error_page 401 = /oauth2/sign_in; + auth_request_set $user $upstream_http_x_auth_request_user; + auth_request_set $email $upstream_http_x_auth_request_email; + proxy_set_header X-User $user; + proxy_set_header X-Email $email; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Auth-Request-Redirect $request_uri; + auth_request_set $auth_cookie $upstream_http_set_cookie; + } + + # This is where the auth_request points, all messages needing auth go to the auth server + # The auth server returns a 200 if the user is authenticated, otherwise a 401 + location = /oauth2/auth { + proxy_pass http://splash_auth:8000/oauth2/auth; + proxy_buffer_size 8k; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header Content-Length ""; + proxy_set_header X-Auth-Request-Redirect $request_uri; + proxy_pass_request_body off; + } + + + # The login page is unprotected + location /login { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Auth-Request-Redirect $request_uri; + proxy_buffer_size 8k; + proxy_pass http://splash_auth:8000/login; + } + + + # For OIDC, the browser is redirected to the auth server to exchange a code + location = /oidc/auth/code { + proxy_pass http://splash_auth:8000/oidc/auth/code; + proxy_buffer_size 8k; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header Content-Length ""; + proxy_set_header X-Auth-Request-Redirect $request_uri; + proxy_pass_request_body off; + } + + } +} diff --git a/pyproject.toml b/pyproject.toml index 32d2f78..d2886a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,9 @@ dependencies = [ ] dev-dependencies = [ - "pytest" + "pytest", + "pre-commit", + "flake8" ] dynamic = ["version"] diff --git a/splash_auth/_tests/test_config.py b/splash_auth/_tests/test_config.py index 24c2b6b..6738ad8 100644 --- a/splash_auth/_tests/test_config.py +++ b/splash_auth/_tests/test_config.py @@ -1,8 +1,3 @@ -import os - - - - def test_config(monkeypatch): monkeypatch.setenv("JWT_SECRET", "secret") @@ -37,5 +32,3 @@ def test_config(monkeypatch): assert config.http_client_timeout_all == 1.0 assert config.http_client_timeout_connect == 4.0 assert config.http_client_timeout_pool == 10 - - diff --git a/splash_auth/_version.py b/splash_auth/_version.py index 1a62d42..7a7ae16 100644 --- a/splash_auth/_version.py +++ b/splash_auth/_version.py @@ -12,5 +12,5 @@ __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE -__version__ = version = '0.1.11.dev2+g8d3f690.d20231011' -__version_tuple__ = version_tuple = (0, 1, 11, 'dev2', 'g8d3f690.d20231011') +__version__ = version = '0.1.11.dev3+g10f69c2.d20240131' +__version_tuple__ = version_tuple = (0, 1, 11, 'dev3', 'g10f69c2.d20240131') diff --git a/splash_auth/config.py b/splash_auth/config.py index 3968550..4b1affc 100644 --- a/splash_auth/config.py +++ b/splash_auth/config.py @@ -5,7 +5,7 @@ JWT_SECRET = os.environ["JWT_SECRET"] -TOKEN_EXP_TIME = int(os.environ["TOKEN_EXP_TIME"]) +TOKEN_EXP_TIME = int(os.environ["TOKEN_EXP_TIME"], ) OAUTH_AUTH_ENDPOINT = os.environ["OAUTH_AUTH_ENDPOINT"] OAUTH_CLIENT_ID = os.environ["OAUTH_CLIENT_ID"] OAUTH_CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"] diff --git a/splash_auth/main.py b/splash_auth/main.py index 9da4593..eb3f21a 100644 --- a/splash_auth/main.py +++ b/splash_auth/main.py @@ -1,16 +1,14 @@ import logging -import os from enum import Enum -from typing import List, Optional, Union +from typing import Union import httpx from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse from fastapi.security import HTTPBearer from jose import jwt -from starlette.background import BackgroundTask from starlette.responses import StreamingResponse -from starlette.status import HTTP_403_FORBIDDEN, HTTP_502_BAD_GATEWAY +from starlette.status import HTTP_401_UNAUTHORIZED from .config import config from .oidc import oidc_router @@ -94,9 +92,9 @@ async def endpoint_login(redirect: Union[str, None] = None): @app.api_route( - "/{path:path}", methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS", "HEAD"] + "/oauth2/auth", methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS", "HEAD"] ) -async def endpoint_reverse_proxy( +def auth( request: Request, response: Response, als_token: Union[str, None] = Cookie(default=None), @@ -116,20 +114,24 @@ async def endpoint_reverse_proxy( user is redirected to the /login endpoint, which allows them to login. """ + logger.info(f"{request.method} - {request.url}") # check for api key in bearer if bearer: if bearer.credentials in users_db.api_keys: - return await _reverse_proxy(request) + response.status_code = 200 + return response else: logger.debug(f"bearer found, but unknown api_key {bearer.credentials}") raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) # check for cookie if not als_token: - return RedirectResponse("/login") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) # check if cookie's value is valid try: @@ -137,44 +139,10 @@ async def endpoint_reverse_proxy( except jwt.ExpiredSignatureError: # Signature has expired logger.debug("Signature expired in cookie") - return RedirectResponse("/login") - - response.status_code = 200 - try: - return await _reverse_proxy(request) - except Exception: - # a problem exists with the client not accepting new connections - # this is ugly, but we try and keep the service running by killing - # the client and starting fresh - logger.error("Exception from http client", exc_info=1) - global client - await client.aclose() - client = new_httpx_client() raise HTTPException( - status_code=HTTP_502_BAD_GATEWAY, detail="Excpetion talking to service" - ) - - -async def close(resp: StreamingResponse): - await resp.aclose() - - -async def _reverse_proxy( - request: Request, scopes: Optional[List[str]] = None -) -> StreamingResponse: - # # cheap and quick scope feature - # if scopes and request.method.lower() in sceope - global client - - url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) - rp_req = client.build_request( - request.method, url, headers=request.headers.raw, content=await request.body() - ) + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) - rp_resp = await client.send(rp_req, stream=True) - return StreamingResponse( - rp_resp.aiter_raw(), - status_code=rp_resp.status_code, - headers=rp_resp.headers, - background=BackgroundTask(close, rp_resp), - ) + response.status_code = 200 + response.content = "Authentication success" + return response diff --git a/splash_auth/oidc.py b/splash_auth/oidc.py index 6932cbc..e77fb5f 100644 --- a/splash_auth/oidc.py +++ b/splash_auth/oidc.py @@ -104,14 +104,6 @@ async def get_user_info(user_info_url, access_token): return response.json() -async def get_user_info(user_info_url, access_token): - """Unused but useful method for getting additional user information""" - response = httpx.get( - url=user_info_url, headers={"Authorization": "Bearer " + access_token} - ) - return response.json() - - @oidc_router.get("/auth/code") async def endpoint_validate_ouath_code(request: Request): """ @@ -147,7 +139,7 @@ async def endpoint_validate_ouath_code(request: Request): encoded_jwt = jwt.encode( { "email": id_claims["email"], - "exp": datetime.now(tz=timezone.utc) + timedelta(seconds=config.token_time), + "exp": datetime.now(tz=timezone.utc) + timedelta(seconds=config.token_exp_time), }, config.jwt_secret, algorithm="HS256", diff --git a/splash_auth/user_db.py b/splash_auth/user_db.py index c1f668e..ab87294 100644 --- a/splash_auth/user_db.py +++ b/splash_auth/user_db.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List +from typing import List import yaml