mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 13:45:25 +03:00
backend apis for auth
This commit is contained in:
parent
c708953342
commit
fca92811e7
@ -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 \
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
32
docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
Normal file
32
docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
Normal 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;
|
||||||
|
}
|
||||||
18
docker/main/rootfs/usr/local/nginx/conf/auth_request.conf
Normal file
18
docker/main/rootfs/usr/local/nginx/conf/auth_request.conf
Normal 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;
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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
|
||||||
|
```
|
||||||
|
|||||||
@ -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
139
frigate/api/auth.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user