From fca92811e7ce1d2a2f2f7cc646408055e8906faa Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Sun, 5 May 2024 10:01:34 -0500 Subject: [PATCH] backend apis for auth --- docker/main/build_nginx.sh | 1 + docker/main/requirements-wheels.txt | 1 + .../usr/local/nginx/conf/auth_location.conf | 32 ++++ .../usr/local/nginx/conf/auth_request.conf | 18 +++ .../rootfs/usr/local/nginx/conf/nginx.conf | 28 +++- .../rootfs/usr/local/nginx/conf/proxy.conf | 26 +++- docs/docs/development/contributing.md | 10 ++ frigate/api/app.py | 2 + frigate/api/auth.py | 139 ++++++++++++++++++ 9 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 docker/main/rootfs/usr/local/nginx/conf/auth_location.conf create mode 100644 docker/main/rootfs/usr/local/nginx/conf/auth_request.conf create mode 100644 frigate/api/auth.py diff --git a/docker/main/build_nginx.sh b/docker/main/build_nginx.sh index 849754d97..fbd591e4f 100755 --- a/docker/main/build_nginx.sh +++ b/docker/main/build_nginx.sh @@ -55,6 +55,7 @@ cd /tmp/nginx --with-file-aio \ --with-http_sub_module \ --with-http_ssl_module \ + --with-http_auth_request_module \ --with-threads \ --add-module=../nginx-vod-module \ --add-module=../nginx-secure-token-module \ diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 6831fc972..415de0336 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,6 +1,7 @@ click == 8.1.* Flask == 3.0.* imutils == 0.5.* +joserfc == 0.9.* markupsafe == 2.1.* matplotlib == 3.8.* mypy == 1.6.1 diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf new file mode 100644 index 000000000..ef20fa8f2 --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf @@ -0,0 +1,32 @@ +set $upstream_auth http://127.0.0.1:5001/auth; + +## Virtual endpoint created by nginx to forward auth requests. +location /auth { + ## Essential Proxy Configuration + internal; + proxy_pass $upstream_auth; + + ## Headers + ## The headers starting with X-* are required. + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Content-Length ""; + proxy_set_header Connection ""; + + ## Basic Proxy Configuration + proxy_pass_request_body off; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead + proxy_redirect http:// $scheme://; + proxy_http_version 1.1; + proxy_cache_bypass $cookie_session; + proxy_no_cache $cookie_session; + proxy_buffers 4 32k; + client_body_buffer_size 128k; + + ## Advanced Proxy Configuration + send_timeout 5m; + proxy_read_timeout 240; + proxy_send_timeout 240; + proxy_connect_timeout 240; +} \ No newline at end of file diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf new file mode 100644 index 000000000..5e4f2332b --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf @@ -0,0 +1,18 @@ +## Send a subrequest to verify if the user is authenticated and has permission to access the resource. +auth_request /auth; + +## Save the upstream metadata response headers from Authelia to variables. +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; + +## Inject the metadata response headers from the variables into the request made to the backend. +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Email $email; +proxy_set_header Remote-Name $name; + +## Refresh the cookie as needed +auth_request_set $auth_cookie $upstream_http_set_cookie; +add_header Set-Cookie $auth_cookie; diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 3ffd714fd..4a522a81d 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -95,7 +95,10 @@ http { gzip on; gzip_types application/vnd.apple.mpegurl; + include auth_location.conf; + location /vod/ { + include auth_request.conf; aio threads; vod hls; @@ -107,6 +110,7 @@ http { } location /stream/ { + include auth_request.conf; add_header Cache-Control "no-store"; expires off; @@ -121,7 +125,7 @@ http { } location /clips/ { - + include auth_request.conf; types { video/mp4 mp4; image/jpeg jpg; @@ -137,6 +141,7 @@ http { } location /recordings/ { + include auth_request.conf; types { video/mp4 mp4; } @@ -147,6 +152,7 @@ http { } location /exports/ { + include auth_request.conf; types { video/mp4 mp4; } @@ -157,17 +163,20 @@ http { } location /ws { + include auth_request.conf; proxy_pass http://mqtt_ws/; include proxy.conf; } location /live/jsmpeg/ { + include auth_request.conf; proxy_pass http://jsmpeg/; include proxy.conf; } # frigate lovelace card uses this path location /live/mse/api/ws { + include auth_request.conf; limit_except GET { deny all; } @@ -176,6 +185,7 @@ http { } location /live/webrtc/api/ws { + include auth_request.conf; limit_except GET { deny all; } @@ -185,6 +195,7 @@ http { # pass through go2rtc player location /live/webrtc/webrtc.html { + include auth_request.conf; limit_except GET { deny all; } @@ -194,6 +205,7 @@ http { # frontend uses this to fetch the version location /api/go2rtc/api { + include auth_request.conf; limit_except GET { deny all; } @@ -203,6 +215,7 @@ http { # integration uses this to add webrtc candidate location /api/go2rtc/webrtc { + include auth_request.conf; limit_except POST { deny all; } @@ -211,12 +224,14 @@ http { } location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { + include auth_request.conf; rewrite ^/api/(.*)$ $1 break; proxy_pass http://frigate_api; include proxy.conf; } location /api/ { + include auth_request.conf; add_header Cache-Control "no-store"; expires off; proxy_pass http://frigate_api/; @@ -231,12 +246,21 @@ http { add_header X-Cache-Status $upstream_cache_status; location /api/vod/ { + include auth_request.conf; proxy_pass http://frigate_api/vod/; include proxy.conf; proxy_cache off; } + location /api/login { + auth_request off; + rewrite ^/api(/.*)$ $1 break; + proxy_pass http://frigate_api; + include proxy.conf; + } + location /api/stats { + include auth_request.conf; access_log off; rewrite ^/api(/.*)$ $1 break; proxy_pass http://frigate_api; @@ -244,6 +268,7 @@ http { } location /api/version { + include auth_request.conf; access_log off; rewrite ^/api(/.*)$ $1 break; proxy_pass http://frigate_api; @@ -252,6 +277,7 @@ http { } location / { + # do not require auth for static assets add_header Cache-Control "no-store"; expires off; diff --git a/docker/main/rootfs/usr/local/nginx/conf/proxy.conf b/docker/main/rootfs/usr/local/nginx/conf/proxy.conf index 442c78718..7ea8ba84c 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/proxy.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/proxy.conf @@ -1,4 +1,26 @@ -proxy_http_version 1.1; +## Headers +proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; -proxy_set_header Host $host; \ No newline at end of file +proxy_set_header X-Original-URL $scheme://$http_host$request_uri; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $http_host; +proxy_set_header X-Forwarded-URI $request_uri; +proxy_set_header X-Forwarded-Ssl on; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Real-IP $remote_addr; + +## Basic Proxy Configuration +client_body_buffer_size 128k; +proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; ## Timeout if the real server is dead. +proxy_redirect http:// $scheme://; +proxy_http_version 1.1; +proxy_cache_bypass $cookie_session; +proxy_no_cache $cookie_session; +proxy_buffers 64 256k; + +## Advanced Proxy Configuration +send_timeout 5m; +proxy_read_timeout 360; +proxy_send_timeout 360; +proxy_connect_timeout 360; \ No newline at end of file diff --git a/docs/docs/development/contributing.md b/docs/docs/development/contributing.md index 45e19a1a3..a86b768ff 100644 --- a/docs/docs/development/contributing.md +++ b/docs/docs/development/contributing.md @@ -225,3 +225,13 @@ docker buildx create --name builder --driver docker-container --driver-opt netwo docker buildx inspect builder --bootstrap make push ``` + +## Other + +### Nginx + +When testing nginx config changes from within the dev container, the following command can be used to copy and reload the config for testing without rebuilding the container: + +```console +sudo cp docker/main/rootfs/usr/local/nginx/conf/* /usr/local/nginx/conf/ && sudo /usr/local/nginx/sbin/nginx -s reload +``` diff --git a/frigate/api/app.py b/frigate/api/app.py index bd184d07d..5f01cba0d 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -14,6 +14,7 @@ from markupsafe import escape from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase +from frigate.api.auth import AuthBp from frigate.api.event import EventBp from frigate.api.export import ExportBp from frigate.api.media import MediaBp @@ -44,6 +45,7 @@ bp.register_blueprint(ExportBp) bp.register_blueprint(MediaBp) bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) +bp.register_blueprint(AuthBp) def create_app( diff --git a/frigate/api/auth.py b/frigate/api/auth.py new file mode 100644 index 000000000..5e60a5b26 --- /dev/null +++ b/frigate/api/auth.py @@ -0,0 +1,139 @@ +"""Auth apis.""" + +import base64 +import hashlib +import logging +import secrets +import time +from datetime import datetime + +from flask import Blueprint, make_response, request +from joserfc import jwt + +logger = logging.getLogger(__name__) + +AuthBp = Blueprint("auth", __name__) + + +ALGORITHM = "pbkdf2_sha256" +JWT_COOKIE_NAME = "jwt.cookie" +JWT_SECRET = "secret" +JWT_REFRESH = 120 +JWT_SESSION_LENGTH = 300 + + +def hash_password(password, salt=None, iterations=260000): + if salt is None: + salt = secrets.token_hex(16) + assert salt and isinstance(salt, str) and "$" not in salt + assert isinstance(password, str) + pw_hash = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations + ) + b64_hash = base64.b64encode(pw_hash).decode("ascii").strip() + return "{}${}${}${}".format(ALGORITHM, iterations, salt, b64_hash) + + +def verify_password(password, password_hash): + if (password_hash or "").count("$") != 3: + return False + algorithm, iterations, salt, b64_hash = password_hash.split("$", 3) + iterations = int(iterations) + assert algorithm == ALGORITHM + compare_hash = hash_password(password, salt, iterations) + return secrets.compare_digest(password_hash, compare_hash) + + +def create_encoded_jwt(user, expiration, secret): + return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) + + +def set_jwt_cookie(response, cookie_name, encoded_jwt, expiration): + # TODO: ideally this would set secure as well, but that requires TLS + response.set_cookie(cookie_name, encoded_jwt, httponly=True, expires=expiration) + + +# TODO: +# - on startup, generate a signing secret for jwt if it doesn't exist and save as ".auth-token" in the config folder +# - add users to config +# - create login page +# - + + +@AuthBp.route("/auth") +def auth(): + jwt_source = None + encoded_token = None + if "authorization" in request.headers and request.headers[ + "authorization" + ].startswith("Bearer "): + jwt_source = "authorization" + logger.debug("Found authorization header") + encoded_token = request.headers["authorization"].replace("Bearer ", "") + elif JWT_COOKIE_NAME in request.cookies: + jwt_source = "cookie" + logger.debug("Found jwt cookie") + encoded_token = request.cookies[JWT_COOKIE_NAME] + + if encoded_token is None: + logger.debug("No jwt token found") + return make_response({}, 401) + + try: + response = make_response({}, 202) + + token = jwt.decode(encoded_token, JWT_SECRET) + if "sub" not in token.claims: + logger.debug("user not set in jwt token") + return make_response({}, 401) + if "exp" not in token.claims: + logger.debug("exp not set in jwt token") + return make_response({}, 401) + + user = token.claims.get("sub") + current_time = int(time.time()) + + # if the jwt is expired + expiration = int(token.claims.get("exp")) + logger.debug( + f"current time: {datetime.fromtimestamp(current_time).strftime('%c')}" + ) + logger.debug( + f"jwt expires at: {datetime.fromtimestamp(expiration).strftime('%c')}" + ) + logger.debug( + f"jwt refresh at: {datetime.fromtimestamp(expiration - JWT_REFRESH).strftime('%c')}" + ) + if expiration <= current_time: + logger.debug("jwt token expired") + return make_response({}, 401) + + # if the jwt cookie is expiring soon + elif jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time: + logger.debug("jwt token expiring soon, refreshing cookie") + new_expiration = current_time + JWT_SESSION_LENGTH + new_encoded_jwt = create_encoded_jwt(user, new_expiration, JWT_SECRET) + set_jwt_cookie(response, JWT_COOKIE_NAME, new_encoded_jwt, new_expiration) + + response.headers["remote-user"] = user + return response + except Exception as e: + logger.error(f"Error parsing jwt: {e}") + return make_response({}, 401) + + # grab the jwt token from the authorization header or a cookie + + +@AuthBp.route("/login", methods=["POST"]) +def login(): + password_hash = "pbkdf2_sha256$260000$2e68caab677bb466138d21d18ac94033$3VZhLOSiY9AqD2Y37DgVOerx4L2wi6nPyruoVXd06VQ=" + content = request.get_json() + user = content["user"] + password = content["password"] + if verify_password(password, password_hash): + expiration = int(time.time()) + JWT_SESSION_LENGTH + encoded_jwt = create_encoded_jwt(user, expiration, JWT_SECRET) + response = make_response({}, 200) + set_jwt_cookie(response, JWT_COOKIE_NAME, encoded_jwt, expiration) + return response + return make_response({"message": "Login failed"}, 400)