backend apis for auth

This commit is contained in:
Blake Blackshear 2024-05-05 10:01:34 -05:00
parent c708953342
commit fca92811e7
9 changed files with 254 additions and 3 deletions

View File

@ -55,6 +55,7 @@ cd /tmp/nginx
--with-file-aio \ --with-file-aio \
--with-http_sub_module \ --with-http_sub_module \
--with-http_ssl_module \ --with-http_ssl_module \
--with-http_auth_request_module \
--with-threads \ --with-threads \
--add-module=../nginx-vod-module \ --add-module=../nginx-vod-module \
--add-module=../nginx-secure-token-module \ --add-module=../nginx-secure-token-module \

View File

@ -1,6 +1,7 @@
click == 8.1.* click == 8.1.*
Flask == 3.0.* Flask == 3.0.*
imutils == 0.5.* imutils == 0.5.*
joserfc == 0.9.*
markupsafe == 2.1.* markupsafe == 2.1.*
matplotlib == 3.8.* matplotlib == 3.8.*
mypy == 1.6.1 mypy == 1.6.1

View File

@ -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;
}

View File

@ -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;

View File

@ -95,7 +95,10 @@ http {
gzip on; gzip on;
gzip_types application/vnd.apple.mpegurl; gzip_types application/vnd.apple.mpegurl;
include auth_location.conf;
location /vod/ { location /vod/ {
include auth_request.conf;
aio threads; aio threads;
vod hls; vod hls;
@ -107,6 +110,7 @@ http {
} }
location /stream/ { location /stream/ {
include auth_request.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
@ -121,7 +125,7 @@ http {
} }
location /clips/ { location /clips/ {
include auth_request.conf;
types { types {
video/mp4 mp4; video/mp4 mp4;
image/jpeg jpg; image/jpeg jpg;
@ -137,6 +141,7 @@ http {
} }
location /recordings/ { location /recordings/ {
include auth_request.conf;
types { types {
video/mp4 mp4; video/mp4 mp4;
} }
@ -147,6 +152,7 @@ http {
} }
location /exports/ { location /exports/ {
include auth_request.conf;
types { types {
video/mp4 mp4; video/mp4 mp4;
} }
@ -157,17 +163,20 @@ http {
} }
location /ws { location /ws {
include auth_request.conf;
proxy_pass http://mqtt_ws/; proxy_pass http://mqtt_ws/;
include proxy.conf; include proxy.conf;
} }
location /live/jsmpeg/ { location /live/jsmpeg/ {
include auth_request.conf;
proxy_pass http://jsmpeg/; proxy_pass http://jsmpeg/;
include proxy.conf; include proxy.conf;
} }
# frigate lovelace card uses this path # frigate lovelace card uses this path
location /live/mse/api/ws { location /live/mse/api/ws {
include auth_request.conf;
limit_except GET { limit_except GET {
deny all; deny all;
} }
@ -176,6 +185,7 @@ http {
} }
location /live/webrtc/api/ws { location /live/webrtc/api/ws {
include auth_request.conf;
limit_except GET { limit_except GET {
deny all; deny all;
} }
@ -185,6 +195,7 @@ http {
# pass through go2rtc player # pass through go2rtc player
location /live/webrtc/webrtc.html { location /live/webrtc/webrtc.html {
include auth_request.conf;
limit_except GET { limit_except GET {
deny all; deny all;
} }
@ -194,6 +205,7 @@ http {
# frontend uses this to fetch the version # frontend uses this to fetch the version
location /api/go2rtc/api { location /api/go2rtc/api {
include auth_request.conf;
limit_except GET { limit_except GET {
deny all; deny all;
} }
@ -203,6 +215,7 @@ http {
# integration uses this to add webrtc candidate # integration uses this to add webrtc candidate
location /api/go2rtc/webrtc { location /api/go2rtc/webrtc {
include auth_request.conf;
limit_except POST { limit_except POST {
deny all; deny all;
} }
@ -211,12 +224,14 @@ http {
} }
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ {
include auth_request.conf;
rewrite ^/api/(.*)$ $1 break; rewrite ^/api/(.*)$ $1 break;
proxy_pass http://frigate_api; proxy_pass http://frigate_api;
include proxy.conf; include proxy.conf;
} }
location /api/ { location /api/ {
include auth_request.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
@ -231,12 +246,21 @@ http {
add_header X-Cache-Status $upstream_cache_status; add_header X-Cache-Status $upstream_cache_status;
location /api/vod/ { location /api/vod/ {
include auth_request.conf;
proxy_pass http://frigate_api/vod/; proxy_pass http://frigate_api/vod/;
include proxy.conf; include proxy.conf;
proxy_cache off; proxy_cache off;
} }
location /api/login {
auth_request off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
location /api/stats { location /api/stats {
include auth_request.conf;
access_log off; access_log off;
rewrite ^/api(/.*)$ $1 break; rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api; proxy_pass http://frigate_api;
@ -244,6 +268,7 @@ http {
} }
location /api/version { location /api/version {
include auth_request.conf;
access_log off; access_log off;
rewrite ^/api(/.*)$ $1 break; rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api; proxy_pass http://frigate_api;
@ -252,6 +277,7 @@ http {
} }
location / { location / {
# do not require auth for static assets
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;

View File

@ -1,4 +1,26 @@
proxy_http_version 1.1; ## Headers
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
proxy_set_header Host $host; 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;

View File

@ -225,3 +225,13 @@ docker buildx create --name builder --driver docker-container --driver-opt netwo
docker buildx inspect builder --bootstrap docker buildx inspect builder --bootstrap
make push 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
```

View File

@ -14,6 +14,7 @@ from markupsafe import escape
from peewee import operator from peewee import operator
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.auth import AuthBp
from frigate.api.event import EventBp from frigate.api.event import EventBp
from frigate.api.export import ExportBp from frigate.api.export import ExportBp
from frigate.api.media import MediaBp from frigate.api.media import MediaBp
@ -44,6 +45,7 @@ bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp) bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp) bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp) bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
def create_app( def create_app(

139
frigate/api/auth.py Normal file
View File

@ -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)