mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-17 19:06:36 +01:00
Auth! (#11347)
* reload the window on 401 * backend apis for auth * add login page * re-enable web linter * fix login page routing * bypass csrf for internal auth endpoint * disable healthcheck in devcontainer target * include login page in vite build * redirect to login page on 401 * implement config for users and settings * implement JWT actual secret * add brute force protection on login * add support for redirecting from auth failures on api calls * return location for redirect * default cookie name should pass regex test * set hash iterations to current OWASP recommendation * move users to database instead of config * config option to reset admin password on startup * user management UI * check for deleted user on refresh * validate username and fixes * remove password constraint * cleanup * fix user check on refresh * web fixes * implement auth via new external port * use x-forwarded-for to rate limit login attempts by ip * implement logout and profile * fixes * lint fixes * add support for user passthru from upstream proxies * add support for specifying a logout url * add documentation * Update docs/docs/configuration/authentication.md Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com> * Update docs/docs/configuration/authentication.md Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com> --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
a70dd02788
commit
1133202cbd
@ -10,10 +10,14 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/common-utils:1": {}
|
"ghcr.io/devcontainers/features/common-utils:1": {}
|
||||||
},
|
},
|
||||||
"forwardPorts": [5000, 5001, 5173, 8554, 8555],
|
"forwardPorts": [8080, 5000, 5001, 5173, 8554, 8555],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
|
"8080": {
|
||||||
|
"label": "External NGINX",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
},
|
||||||
"5000": {
|
"5000": {
|
||||||
"label": "NGINX",
|
"label": "Internal NGINX",
|
||||||
"onAutoForward": "silent"
|
"onAutoForward": "silent"
|
||||||
},
|
},
|
||||||
"5001": {
|
"5001": {
|
||||||
|
6
.github/workflows/pull_request.yml
vendored
6
.github/workflows/pull_request.yml
vendored
@ -40,9 +40,9 @@ jobs:
|
|||||||
node-version: 16.x
|
node-version: 16.x
|
||||||
- run: npm install
|
- run: npm install
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
# - name: Lint
|
- name: Lint
|
||||||
# run: npm run lint
|
run: npm run lint
|
||||||
# working-directory: ./web
|
working-directory: ./web
|
||||||
|
|
||||||
web_test:
|
web_test:
|
||||||
name: Web - Test
|
name: Web - Test
|
||||||
|
@ -233,6 +233,8 @@ RUN apt-get update \
|
|||||||
RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \
|
RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \
|
||||||
pip3 install -r requirements-dev.txt
|
pip3 install -r requirements-dev.txt
|
||||||
|
|
||||||
|
HEALTHCHECK NONE
|
||||||
|
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ set -euxo pipefail
|
|||||||
NGINX_VERSION="1.25.3"
|
NGINX_VERSION="1.25.3"
|
||||||
VOD_MODULE_VERSION="1.31"
|
VOD_MODULE_VERSION="1.31"
|
||||||
SECURE_TOKEN_MODULE_VERSION="1.5"
|
SECURE_TOKEN_MODULE_VERSION="1.5"
|
||||||
|
SET_MISC_MODULE_VERSION="v0.33"
|
||||||
|
NGX_DEVEL_KIT_VERSION="v0.3.3"
|
||||||
|
|
||||||
cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list
|
cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list
|
||||||
sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list
|
sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list
|
||||||
@ -49,13 +51,27 @@ wget https://github.com/kaltura/nginx-secure-token-module/archive/refs/tags/${SE
|
|||||||
tar -zxf ${SECURE_TOKEN_MODULE_VERSION}.tar.gz -C /tmp/nginx-secure-token-module --strip-components=1
|
tar -zxf ${SECURE_TOKEN_MODULE_VERSION}.tar.gz -C /tmp/nginx-secure-token-module --strip-components=1
|
||||||
rm ${SECURE_TOKEN_MODULE_VERSION}.tar.gz
|
rm ${SECURE_TOKEN_MODULE_VERSION}.tar.gz
|
||||||
|
|
||||||
|
mkdir /tmp/ngx_devel_kit
|
||||||
|
wget https://github.com/vision5/ngx_devel_kit/archive/refs/tags/${NGX_DEVEL_KIT_VERSION}.tar.gz
|
||||||
|
tar -zxf ${NGX_DEVEL_KIT_VERSION}.tar.gz -C /tmp/ngx_devel_kit --strip-components=1
|
||||||
|
rm ${NGX_DEVEL_KIT_VERSION}.tar.gz
|
||||||
|
|
||||||
|
mkdir /tmp/nginx-set-misc-module
|
||||||
|
wget https://github.com/openresty/set-misc-nginx-module/archive/refs/tags/${SET_MISC_MODULE_VERSION}.tar.gz
|
||||||
|
tar -zxf ${SET_MISC_MODULE_VERSION}.tar.gz -C /tmp/nginx-set-misc-module --strip-components=1
|
||||||
|
rm ${SET_MISC_MODULE_VERSION}.tar.gz
|
||||||
|
|
||||||
cd /tmp/nginx
|
cd /tmp/nginx
|
||||||
|
|
||||||
./configure --prefix=/usr/local/nginx \
|
./configure --prefix=/usr/local/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-http_realip_module \
|
||||||
--with-threads \
|
--with-threads \
|
||||||
|
--add-module=../ngx_devel_kit \
|
||||||
|
--add-module=../nginx-set-misc-module \
|
||||||
--add-module=../nginx-vod-module \
|
--add-module=../nginx-vod-module \
|
||||||
--add-module=../nginx-secure-token-module \
|
--add-module=../nginx-secure-token-module \
|
||||||
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
|
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
click == 8.1.*
|
click == 8.1.*
|
||||||
Flask == 3.0.*
|
Flask == 3.0.*
|
||||||
|
Flask_Limiter == 3.6.*
|
||||||
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
|
||||||
|
43
docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
Normal file
43
docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# First strip out all the request headers
|
||||||
|
# Note: This is important to ensure that upgrade requests for secure
|
||||||
|
# websockets dont cause the backend to fail
|
||||||
|
proxy_pass_request_headers off;
|
||||||
|
# Pass info about the request
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||||
|
proxy_set_header X-Server-Port $server_port;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
# Pass along auth related info
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header X-CSRF-TOKEN "1";
|
||||||
|
|
||||||
|
# include headers from common auth proxies
|
||||||
|
include proxy_trusted_headers.conf;
|
||||||
|
|
||||||
|
## 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;
|
||||||
|
}
|
22
docker/main/rootfs/usr/local/nginx/conf/auth_request.conf
Normal file
22
docker/main/rootfs/usr/local/nginx/conf/auth_request.conf
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
## 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;
|
||||||
|
|
||||||
|
## Pass the location header back up if it exists
|
||||||
|
auth_request_set $redirection_url $upstream_http_location;
|
||||||
|
add_header Location $redirection_url;
|
@ -62,6 +62,9 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
# intended for external traffic, protected by auth
|
||||||
|
listen [::]:8080 ipv6only=off;
|
||||||
|
# intended for internal traffic, not protected by auth
|
||||||
listen [::]:5000 ipv6only=off;
|
listen [::]:5000 ipv6only=off;
|
||||||
|
|
||||||
# vod settings
|
# vod settings
|
||||||
@ -95,7 +98,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 +113,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 +128,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 +144,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /recordings/ {
|
location /recordings/ {
|
||||||
|
include auth_request.conf;
|
||||||
types {
|
types {
|
||||||
video/mp4 mp4;
|
video/mp4 mp4;
|
||||||
}
|
}
|
||||||
@ -147,6 +155,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /exports/ {
|
location /exports/ {
|
||||||
|
include auth_request.conf;
|
||||||
types {
|
types {
|
||||||
video/mp4 mp4;
|
video/mp4 mp4;
|
||||||
}
|
}
|
||||||
@ -157,17 +166,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 +188,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 +198,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 +208,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 +218,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 +227,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 +249,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 +271,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 +280,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;
|
||||||
|
|
||||||
@ -273,7 +302,7 @@ http {
|
|||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
|
|
||||||
root /opt/frigate/web;
|
root /opt/frigate/web;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri.html $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 $proxy_add_x_forwarded_for;
|
||||||
|
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;
|
@ -0,0 +1,22 @@
|
|||||||
|
# these headers will be copied to the /auth request and are available
|
||||||
|
# to be mapped in the config to Frigate's remote-user header
|
||||||
|
|
||||||
|
# List of headers sent by common authentication proxies:
|
||||||
|
# - Authelia
|
||||||
|
# - Traefik forward auth
|
||||||
|
# - oauth2_proxy
|
||||||
|
# - Authentik
|
||||||
|
|
||||||
|
proxy_set_header Remote-User $http_remote_user;
|
||||||
|
proxy_set_header Remote-Groups $http_remote_groups;
|
||||||
|
proxy_set_header Remote-Email $http_remote_email;
|
||||||
|
proxy_set_header Remote-Name $http_remote_name;
|
||||||
|
proxy_set_header X-Forwarded-User $http_x_forwarded_user;
|
||||||
|
proxy_set_header X-Forwarded-Groups $http_x_forwarded_groups;
|
||||||
|
proxy_set_header X-Forwarded-Email $http_x_forwarded_email;
|
||||||
|
proxy_set_header X-Forwarded-Preferred-Username $http_x_forwarded_preferred_username;
|
||||||
|
proxy_set_header X-authentik-username $http_x_authentik_username;
|
||||||
|
proxy_set_header X-authentik-groups $http_x_authentik_groups;
|
||||||
|
proxy_set_header X-authentik-email $http_x_authentik_email;
|
||||||
|
proxy_set_header X-authentik-name $http_x_authentik_name;
|
||||||
|
proxy_set_header X-authentik-uid $http_x_authentik_uid;
|
98
docs/docs/configuration/authentication.md
Normal file
98
docs/docs/configuration/authentication.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
id: authentication
|
||||||
|
title: Authentication
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
Frigate supports two modes for authentication
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `native` | (default) Use this mode if you don't implement authentication with a proxy in front of Frigate. |
|
||||||
|
| `proxy` | Use this mode if you have an existing proxy for authentication. Supports passing authenticated user downstream to Frigate for role-based authorization (future implementation). |
|
||||||
|
|
||||||
|
### Native mode
|
||||||
|
|
||||||
|
Frigate stores user information in its database. Password hashes are generated using industry standard PBKDF2-SHA256 with 600,000 iterations. Upon successful login, a JWT token is issued with an expiration date and set as a cookie. The cookie is refreshed as needed automatically. This JWT token can also be passed in the Authorization header as a bearer token.
|
||||||
|
|
||||||
|
Users are managed in the UI under Settings > Authentication.
|
||||||
|
|
||||||
|
#### Onboarding
|
||||||
|
|
||||||
|
On startup, an admin user and password are generated and printed in the logs. It is recommended to set a new password for the admin account after logging in for the first time under Settings > Authentication.
|
||||||
|
|
||||||
|
#### Resetting admin password
|
||||||
|
|
||||||
|
In the event that you are locked out of your instance, you can tell Frigate to reset the admin password and print it in the logs on next startup using the `reset_admin_password` setting in your config file.
|
||||||
|
|
||||||
|
#### Login failure rate limiting
|
||||||
|
|
||||||
|
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation).
|
||||||
|
|
||||||
|
For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than:
|
||||||
|
|
||||||
|
- 1 time per second
|
||||||
|
- 5 times per minute
|
||||||
|
- 20 times per hour
|
||||||
|
|
||||||
|
Restarting Frigate will reset the rate limits.
|
||||||
|
|
||||||
|
If you are running Frigate behind a proxy, you will want to set `trusted_proxies` or these rate limits will apply to the upstream proxy IP address. This means that a brute force attack will rate limit login attempts from other devices and could temporarily lock you out of your instance. In order to ensure rate limits only apply to the actual IP address where the requests are coming from, you will need to list the upstream networks that you want to trust. These trusted proxies are checked against the `X-Forwarded-For` header when looking for the IP address where the request originated.
|
||||||
|
|
||||||
|
If you are running a reverse proxy in the same docker compose file as Frigate, here is an example of how your auth config might look:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
mode: native
|
||||||
|
failed_login_rate_limit: "1/second;5/minute;20/hour"
|
||||||
|
trusted_proxies:
|
||||||
|
- 172.18.0.0/16 # <---- this is the subnet for the internal docker compose network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy mode
|
||||||
|
|
||||||
|
Proxy mode is designed to complement common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth.
|
||||||
|
|
||||||
|
#### Header mapping
|
||||||
|
|
||||||
|
If your proxy supports passing a header with the authenticated username, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` value. Header names are not case sensitive.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
...
|
||||||
|
header_map:
|
||||||
|
user: x-forwarded-user
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that only the following list of headers are permitted by default:
|
||||||
|
|
||||||
|
```
|
||||||
|
Remote-User
|
||||||
|
Remote-Groups
|
||||||
|
Remote-Email
|
||||||
|
Remote-Name
|
||||||
|
X-Forwarded-User
|
||||||
|
X-Forwarded-Groups
|
||||||
|
X-Forwarded-Email
|
||||||
|
X-Forwarded-Preferred-Username
|
||||||
|
X-authentik-username
|
||||||
|
X-authentik-groups
|
||||||
|
X-authentik-email
|
||||||
|
X-authentik-name
|
||||||
|
X-authentik-uid
|
||||||
|
```
|
||||||
|
|
||||||
|
If you would like to add more options, you can overwrite the default file with a docker bind mount at `/usr/local/nginx/conf/proxy_trusted_headers.conf`. Reference the source code for the default file formatting.
|
||||||
|
|
||||||
|
Future versions of Frigate may leverage group and role headers for authorization in Frigate as well.
|
||||||
|
|
||||||
|
#### Login page redirection
|
||||||
|
|
||||||
|
Frigate gracefully performs login page redirection that should work with most authentication proxies. If your reverse proxy returns a `Location` header on `401`, `302`, or `307` unauthorized responses, Frigate's frontend will automatically detect it and redirect to that URL.
|
||||||
|
|
||||||
|
#### Custom logout url
|
||||||
|
|
||||||
|
If your reverse proxy has a dedicated logout url, you can specify using the `logout_url` config option. This will update the link for the `Logout` link in the UI.
|
@ -25,7 +25,7 @@ cameras:
|
|||||||
|
|
||||||
## VSCode Configuration Schema
|
## VSCode Configuration Schema
|
||||||
|
|
||||||
VSCode supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VSCode and Frigate as an add-on, you should use `ccab4aaf-frigate` instead. Make sure to expose port `5000` for the Web Interface when accessing the config from VSCode on another machine.
|
VSCode supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VSCode and Frigate as an add-on, you should use `ccab4aaf-frigate` instead. Make sure to expose the internal unauthenticated port `5000` when accessing the config from VSCode on another machine.
|
||||||
|
|
||||||
## Environment Variable Substitution
|
## Environment Variable Substitution
|
||||||
|
|
||||||
|
@ -63,6 +63,45 @@ database:
|
|||||||
# The path to store the SQLite DB (default: shown below)
|
# The path to store the SQLite DB (default: shown below)
|
||||||
path: /config/frigate.db
|
path: /config/frigate.db
|
||||||
|
|
||||||
|
# Optional: Authentication configuration
|
||||||
|
auth:
|
||||||
|
# Optional: Authentication mode (default: shown below)
|
||||||
|
# Valid values are: native, proxy
|
||||||
|
mode: native
|
||||||
|
# Optional: Reset the admin user password on startup (default: shown below)
|
||||||
|
# New password is printed in the logs
|
||||||
|
reset_admin_password: False
|
||||||
|
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||||
|
cookie_name: frigate_token
|
||||||
|
# Optional: Session length in seconds (default: shown below)
|
||||||
|
session_length: 86400 # 24 hours
|
||||||
|
# Optional: Refresh time in seconds (default: shown below)
|
||||||
|
# When the session is going to expire in less time than this setting,
|
||||||
|
# it will be refreshed back to the session_length.
|
||||||
|
refresh_time: 43200 # 12 hours
|
||||||
|
# Optional: Mapping for headers from upstream proxies. Only used in proxy auth mode.
|
||||||
|
# NOTE: Many authentication proxies pass a header downstream with the authenticated
|
||||||
|
# user name. Not all values are supported. It must be a whitelisted header.
|
||||||
|
# See the docs for more info.
|
||||||
|
header_map:
|
||||||
|
user: x-forwarded-user
|
||||||
|
# Optional: Rate limiting for login failures to help prevent brute force
|
||||||
|
# login attacks (default: shown below)
|
||||||
|
# See the docs for more information on valid values
|
||||||
|
failed_login_rate_limit: None
|
||||||
|
# Optional: Trusted proxies for determining IP address to rate limit
|
||||||
|
# NOTE: This is only used for rate limiting login attempts and does not bypass
|
||||||
|
# authentication in any way
|
||||||
|
trusted_proxies: []
|
||||||
|
# Optional: Url for logging out a user. This only needs to be set if you are using
|
||||||
|
# proxy mode.
|
||||||
|
logout_url: /api/logout
|
||||||
|
# Optional: Number of hashing iterations for user passwords
|
||||||
|
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
||||||
|
# NOTE: changing this value will not automatically update password hashes, you
|
||||||
|
# will need to change each user password for it to apply
|
||||||
|
hash_iterations: 600000
|
||||||
|
|
||||||
# Optional: model modifications
|
# Optional: model modifications
|
||||||
model:
|
model:
|
||||||
# Optional: path to the model (default: automatic based on detector)
|
# Optional: path to the model (default: automatic based on detector)
|
||||||
|
@ -11,7 +11,7 @@ Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.2) to provide
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
You can access the go2rtc stream info at `http://frigate_ip:5000/api/go2rtc/streams` which can be helpful to debug as well as provide useful information about your camera streams.
|
You can access the go2rtc stream info at `http://frigate_ip:8080/api/go2rtc/streams` which can be helpful to debug as well as provide useful information about your camera streams.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
```
|
||||||
|
@ -28,6 +28,17 @@ Frigate uses the following locations for read/write operations in the container.
|
|||||||
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder. Segments generated via the `clip.mp4` endpoints are also concatenated and processed here. It is recommended to use a [`tmpfs`](https://docs.docker.com/storage/tmpfs/) mount for this.
|
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder. Segments generated via the `clip.mp4` endpoints are also concatenated and processed here. It is recommended to use a [`tmpfs`](https://docs.docker.com/storage/tmpfs/) mount for this.
|
||||||
- `/dev/shm`: Internal cache for raw decoded frames in shared memory. It is not recommended to modify this directory or map it with docker. The minimum size is impacted by the `shm-size` calculations below.
|
- `/dev/shm`: Internal cache for raw decoded frames in shared memory. It is not recommended to modify this directory or map it with docker. The minimum size is impacted by the `shm-size` calculations below.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
The following ports are used by Frigate and can be mapped via docker as required.
|
||||||
|
|
||||||
|
| Port | Description |
|
||||||
|
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `8080` | Authenticated UI and API access. Reverse proxies should use this port. |
|
||||||
|
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. |
|
||||||
|
| `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. |
|
||||||
|
| `8555` | WebRTC connections for low latency live views. |
|
||||||
|
|
||||||
#### Common docker compose storage configurations
|
#### Common docker compose storage configurations
|
||||||
|
|
||||||
Writing to a local disk or external USB drive:
|
Writing to a local disk or external USB drive:
|
||||||
@ -111,7 +122,8 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "8080:8080"
|
||||||
|
# - "5000:5000" # Internal unauthenticated access. Expose carefully.
|
||||||
- "8554:8554" # RTSP feeds
|
- "8554:8554" # RTSP feeds
|
||||||
- "8555:8555/tcp" # WebRTC over tcp
|
- "8555:8555/tcp" # WebRTC over tcp
|
||||||
- "8555:8555/udp" # WebRTC over udp
|
- "8555:8555/udp" # WebRTC over udp
|
||||||
@ -133,7 +145,7 @@ docker run -d \
|
|||||||
-v /path/to/your/config:/config \
|
-v /path/to/your/config:/config \
|
||||||
-v /etc/localtime:/etc/localtime:ro \
|
-v /etc/localtime:/etc/localtime:ro \
|
||||||
-e FRIGATE_RTSP_PASSWORD='password' \
|
-e FRIGATE_RTSP_PASSWORD='password' \
|
||||||
-p 5000:5000 \
|
-p 8080:8080 \
|
||||||
-p 8554:8554 \
|
-p 8554:8554 \
|
||||||
-p 8555:8555/tcp \
|
-p 8555:8555/tcp \
|
||||||
-p 8555:8555/udp \
|
-p 8555:8555/udp \
|
||||||
@ -300,7 +312,7 @@ docker run \
|
|||||||
--network=bridge \
|
--network=bridge \
|
||||||
--privileged \
|
--privileged \
|
||||||
--workdir=/opt/frigate \
|
--workdir=/opt/frigate \
|
||||||
-p 5000:5000 \
|
-p 8080:8080 \
|
||||||
-p 8554:8554 \
|
-p 8554:8554 \
|
||||||
-p 8555:8555 \
|
-p 8555:8555 \
|
||||||
-p 8555:8555/udp \
|
-p 8555:8555/udp \
|
||||||
|
@ -117,7 +117,7 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "8080:8080"
|
||||||
- "8554:8554" # RTSP feeds
|
- "8554:8554" # RTSP feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ cameras:
|
|||||||
- detect
|
- detect
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. Frigate should now be accessible at `server_ip:5000` and you can finish the configuration using the built-in configuration editor.
|
Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. On startup, an admin user and password will be created and outputted in the logs. You can see this by running `docker logs frigate`. Frigate should now be accessible at `server_ip:8080` where you can login with the `admin` user and finish the configuration using the built-in configuration editor.
|
||||||
|
|
||||||
## Configuring Frigate
|
## Configuring Frigate
|
||||||
|
|
||||||
|
@ -38,20 +38,20 @@ Here we access Frigate via https://cctv.mydomain.co.uk
|
|||||||
ServerName cctv.mydomain.co.uk
|
ServerName cctv.mydomain.co.uk
|
||||||
|
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
ProxyPass "/" "http://frigatepi.local:5000/"
|
ProxyPass "/" "http://frigatepi.local:8080/"
|
||||||
ProxyPassReverse "/" "http://frigatepi.local:5000/"
|
ProxyPassReverse "/" "http://frigatepi.local:8080/"
|
||||||
|
|
||||||
ProxyPass /ws ws://frigatepi.local:5000/ws
|
ProxyPass /ws ws://frigatepi.local:8080/ws
|
||||||
ProxyPassReverse /ws ws://frigatepi.local:5000/ws
|
ProxyPassReverse /ws ws://frigatepi.local:8080/ws
|
||||||
|
|
||||||
ProxyPass /live/ ws://frigatepi.local:5000/live/
|
ProxyPass /live/ ws://frigatepi.local:8080/live/
|
||||||
ProxyPassReverse /live/ ws://frigatepi.local:5000/live/
|
ProxyPassReverse /live/ ws://frigatepi.local:8080/live/
|
||||||
|
|
||||||
RewriteEngine on
|
RewriteEngine on
|
||||||
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||||
RewriteRule /(.*) ws://frigatepi.local:5000/$1 [P,L]
|
RewriteRule /(.*) ws://frigatepi.local:8080/$1 [P,L]
|
||||||
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
|
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
|
||||||
RewriteRule /(.*) http://frigatepi.local:5000/$1 [P,L]
|
RewriteRule /(.*) http://frigatepi.local:8080/$1 [P,L]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ This is set in `$server` and `$port` this should match your ports you have expos
|
|||||||
server {
|
server {
|
||||||
set $forward_scheme http;
|
set $forward_scheme http;
|
||||||
set $server "192.168.100.2"; # FRIGATE SERVER LOCATION
|
set $server "192.168.100.2"; # FRIGATE SERVER LOCATION
|
||||||
set $port 5000;
|
set $port 8080;
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
|
@ -164,7 +164,7 @@ Accepts the following query string parameters:
|
|||||||
| `motion` | int | Draw blue boxes for areas with detected motion (0 or 1) |
|
| `motion` | int | Draw blue boxes for areas with detected motion (0 or 1) |
|
||||||
| `regions` | int | Draw green boxes for areas where object detection was run (0 or 1) |
|
| `regions` | int | Draw green boxes for areas where object detection was run (0 or 1) |
|
||||||
|
|
||||||
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/api/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/api/back?fps=10` or both with `?fps=10&h=1000`.
|
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:8080/api/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:8080/api/back?fps=10` or both with `?fps=10&h=1000`.
|
||||||
|
|
||||||
### `GET /api/<camera_name>/latest.jpg[?h=300]`
|
### `GET /api/<camera_name>/latest.jpg[?h=300]`
|
||||||
|
|
||||||
|
@ -47,7 +47,55 @@ that card.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
When configuring the integration, you will be asked for the `URL` of your Frigate instance which is the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access Frigate directly from your network.
|
When configuring the integration, you will be asked for the `URL` of your Frigate instance which needs to be pointed at the internal unauthenticated port (`5000`) for your instance. This may look like `http://<host>:5000/`.
|
||||||
|
|
||||||
|
### Docker Compose Examples
|
||||||
|
|
||||||
|
If you are running Home Assistant Core and Frigate with Docker Compose on the same device, here are some examples.
|
||||||
|
|
||||||
|
#### Home Assistant running with host networking
|
||||||
|
|
||||||
|
It is not recommended to run Frigate in host networking mode. In this example, you would use `http://172.17.0.1:5000` when configuring the integration.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homeassistant:
|
||||||
|
container_name: hass
|
||||||
|
image: ghcr.io/home-assistant/home-assistant:stable
|
||||||
|
network_mode: host
|
||||||
|
...
|
||||||
|
|
||||||
|
frigate:
|
||||||
|
image: ghcr.io/blakeblackshear/frigate:stable
|
||||||
|
...
|
||||||
|
ports:
|
||||||
|
- "172.17.0.1:5000:5000"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Home Assistant _not_ running with host networking or in a separate compose file
|
||||||
|
|
||||||
|
In this example, you would use `http://frigate:5000` when configuring the integration. There is no need to map the port for the Frigate container.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homeassistant:
|
||||||
|
container_name: hass
|
||||||
|
image: ghcr.io/home-assistant/home-assistant:stable
|
||||||
|
# network_mode: host
|
||||||
|
...
|
||||||
|
|
||||||
|
frigate:
|
||||||
|
image: ghcr.io/blakeblackshear/frigate:stable
|
||||||
|
...
|
||||||
|
ports:
|
||||||
|
# - "172.17.0.1:5000:5000"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### HassOS Addon
|
||||||
|
|
||||||
|
If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access Frigate directly from your network.
|
||||||
|
|
||||||
| Addon Version | URL |
|
| Addon Version | URL |
|
||||||
| ------------------------------ | -------------------------------------- |
|
| ------------------------------ | -------------------------------------- |
|
||||||
@ -56,7 +104,37 @@ When configuring the integration, you will be asked for the `URL` of your Frigat
|
|||||||
| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` |
|
| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` |
|
||||||
| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
|
| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
|
||||||
|
|
||||||
<a name="options"></a>
|
### Frigate running on a separate machine
|
||||||
|
|
||||||
|
If you run Frigate on a separate device within your local network, Home Assistant will need access to port 5000.
|
||||||
|
|
||||||
|
#### Local network
|
||||||
|
|
||||||
|
Use `http://<frigate_device_ip>:5000` as the URL for the integration. If you want to protect access to port 5000, you can use firewall rules to limit access to the device running Home Assistant.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frigate:
|
||||||
|
image: ghcr.io/blakeblackshear/frigate:stable
|
||||||
|
...
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tailscale or other private networking
|
||||||
|
|
||||||
|
Use `http://<frigate_device_tailscale_ip>:5000` as the URL for the integration.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frigate:
|
||||||
|
image: ghcr.io/blakeblackshear/frigate:stable
|
||||||
|
...
|
||||||
|
ports:
|
||||||
|
- "<tailscale_ip>:5000:5000"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ title: Third Party Extensions
|
|||||||
---
|
---
|
||||||
|
|
||||||
Being open source, others have the possibility to modify and extend the rich functionality Frigate already offers.
|
Being open source, others have the possibility to modify and extend the rich functionality Frigate already offers.
|
||||||
This page is meant to be an overview over additions one can make to the home NVR setup. The list is not exhaustive and can be extended via PR to the Frigate docs.
|
This page is meant to be an overview over additions one can make to the home NVR setup. The list is not exhaustive and can be extended via PR to the Frigate docs. Most of these services are designed to interface with Frigate's unauthenticated api over port 5000.
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
|
|
||||||
|
1692
docs/package-lock.json
generated
1692
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,19 +4,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"start": "docusaurus start",
|
"start": "docusaurus start --host 0.0.0.0",
|
||||||
"build": "docusaurus build",
|
"build": "docusaurus build",
|
||||||
"swizzle": "docusaurus swizzle",
|
"swizzle": "docusaurus swizzle",
|
||||||
"deploy": "docusaurus deploy",
|
"deploy": "docusaurus deploy",
|
||||||
"clear": "docusaurus clear",
|
"clear": "docusaurus clear",
|
||||||
"serve": "docusaurus serve",
|
"serve": "docusaurus serve --host 0.0.0.0",
|
||||||
"write-translations": "docusaurus write-translations",
|
"write-translations": "docusaurus write-translations",
|
||||||
"write-heading-ids": "docusaurus write-heading-ids"
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "^3.2.1",
|
"@docusaurus/core": "^3.3.2",
|
||||||
"@docusaurus/preset-classic": "^3.2.1",
|
"@docusaurus/preset-classic": "^3.3.2",
|
||||||
"@docusaurus/theme-mermaid": "^3.2.1",
|
"@docusaurus/theme-mermaid": "^3.3.2",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"prism-react-renderer": "^2.1.0",
|
"prism-react-renderer": "^2.1.0",
|
||||||
@ -37,8 +37,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.2.1",
|
"@docusaurus/module-type-aliases": "^3.3.2",
|
||||||
"@docusaurus/types": "^3.2.1",
|
"@docusaurus/types": "^3.3.2",
|
||||||
"@types/react": "^18.2.79"
|
"@types/react": "^18.2.79"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -50,6 +50,7 @@ module.exports = {
|
|||||||
"configuration/stationary_objects",
|
"configuration/stationary_objects",
|
||||||
],
|
],
|
||||||
"Extra Configuration": [
|
"Extra Configuration": [
|
||||||
|
"configuration/authentication",
|
||||||
"configuration/hardware_acceleration",
|
"configuration/hardware_acceleration",
|
||||||
"configuration/ffmpeg_presets",
|
"configuration/ffmpeg_presets",
|
||||||
"configuration/advanced",
|
"configuration/advanced",
|
||||||
|
@ -13,13 +13,15 @@ from flask import Blueprint, Flask, current_app, jsonify, make_response, request
|
|||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from peewee import operator
|
from peewee import operator
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
from frigate.api.auth import AuthBp, get_jwt_secret, limiter
|
||||||
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
|
||||||
from frigate.api.preview import PreviewBp
|
from frigate.api.preview import PreviewBp
|
||||||
from frigate.api.review import ReviewBp
|
from frigate.api.review import ReviewBp
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import AuthModeEnum, FrigateConfig
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.events.external import ExternalEventProcessor
|
from frigate.events.external import ExternalEventProcessor
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
@ -44,6 +46,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(
|
||||||
@ -83,6 +86,15 @@ def create_app(
|
|||||||
app.plus_api = plus_api
|
app.plus_api = plus_api
|
||||||
app.camera_error_image = None
|
app.camera_error_image = None
|
||||||
app.stats_emitter = stats_emitter
|
app.stats_emitter = stats_emitter
|
||||||
|
app.jwt_token = (
|
||||||
|
get_jwt_secret() if frigate_config.auth.mode == AuthModeEnum.native else None
|
||||||
|
)
|
||||||
|
# update the request_address with the x-forwarded-for header from nginx
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
|
||||||
|
# initialize the rate limiter for the login endpoint
|
||||||
|
limiter.init_app(app)
|
||||||
|
if frigate_config.auth.failed_login_rate_limit is None:
|
||||||
|
limiter.enabled = False
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
353
frigate/api/auth.py
Normal file
353
frigate/api/auth.py
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
"""Auth apis."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, make_response, redirect, request
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from joserfc import jwt
|
||||||
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
|
from frigate.config import AuthConfig, AuthModeEnum
|
||||||
|
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||||
|
from frigate.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AuthBp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_remote_addr():
|
||||||
|
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
||||||
|
logger.debug(f"IP Route: {[r for r in route]}")
|
||||||
|
trusted_proxies = []
|
||||||
|
for proxy in current_app.frigate_config.auth.trusted_proxies:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(proxy)
|
||||||
|
except ValueError:
|
||||||
|
logger.warn(f"Unable to parse trusted network: {proxy}")
|
||||||
|
trusted_proxies.append(network)
|
||||||
|
|
||||||
|
# return the first remote address that is not trusted
|
||||||
|
for addr in route:
|
||||||
|
ip = ipaddress.ip_address(addr.strip())
|
||||||
|
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||||
|
trusted = False
|
||||||
|
for trusted_proxy in trusted_proxies:
|
||||||
|
logger.debug(
|
||||||
|
f"Checking against trusted proxy: {trusted_proxy} (v{trusted_proxy.version})"
|
||||||
|
)
|
||||||
|
if trusted_proxy.version == 4:
|
||||||
|
ipv4 = ip.ipv4_mapped if ip.version == 6 else ip
|
||||||
|
if ipv4 in trusted_proxy:
|
||||||
|
trusted = True
|
||||||
|
logger.debug(f"Trusted: {str(ip)} by {str(trusted_proxy)}")
|
||||||
|
break
|
||||||
|
elif trusted_proxy.version == 6 and ip.version == 6:
|
||||||
|
if ip in trusted_proxy:
|
||||||
|
trusted = True
|
||||||
|
logger.debug(f"Trusted: {str(ip)} by {str(trusted_proxy)}")
|
||||||
|
break
|
||||||
|
if trusted:
|
||||||
|
logger.debug(f"{ip} is trusted")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.debug(f"First untrusted IP: {str(ip)}")
|
||||||
|
return str(ip)
|
||||||
|
|
||||||
|
# if there wasn't anything in the route, just return the default
|
||||||
|
return request.remote_addr or "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
get_remote_addr,
|
||||||
|
storage_uri="memory://",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limit():
|
||||||
|
return current_app.frigate_config.auth.failed_login_rate_limit
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_secret() -> str:
|
||||||
|
jwt_secret = None
|
||||||
|
# check env var
|
||||||
|
if JWT_SECRET_ENV_VAR in os.environ:
|
||||||
|
logger.debug(
|
||||||
|
f"Using jwt secret from {JWT_SECRET_ENV_VAR} environment variable."
|
||||||
|
)
|
||||||
|
jwt_secret = os.environ.get(JWT_SECRET_ENV_VAR)
|
||||||
|
# check docker secrets
|
||||||
|
elif (
|
||||||
|
os.path.isdir("/run/secrets")
|
||||||
|
and os.access("/run/secrets", os.R_OK)
|
||||||
|
and JWT_SECRET_ENV_VAR in os.listdir("/run/secrets")
|
||||||
|
):
|
||||||
|
logger.debug(f"Using jwt secret from {JWT_SECRET_ENV_VAR} docker secret file.")
|
||||||
|
jwt_secret = Path(os.path.join("/run/secrets", JWT_SECRET_ENV_VAR)).read_text()
|
||||||
|
# check for the addon options file
|
||||||
|
elif os.path.isfile("/data/options.json"):
|
||||||
|
with open("/data/options.json") as f:
|
||||||
|
raw_options = f.read()
|
||||||
|
logger.debug("Using jwt secret from Home Assistant addon options file.")
|
||||||
|
options = json.loads(raw_options)
|
||||||
|
jwt_secret = options.get("jwt_secret")
|
||||||
|
|
||||||
|
if jwt_secret is None:
|
||||||
|
jwt_secret_file = os.path.join(CONFIG_DIR, ".jwt_secret")
|
||||||
|
# check .jwt_secrets file
|
||||||
|
if not os.path.isfile(jwt_secret_file):
|
||||||
|
logger.debug(
|
||||||
|
"No jwt secret found. Generating one and storing in .jwt_secret file in config directory."
|
||||||
|
)
|
||||||
|
jwt_secret = secrets.token_hex(64)
|
||||||
|
try:
|
||||||
|
with open(jwt_secret_file, "w") as f:
|
||||||
|
f.write(str(jwt_secret))
|
||||||
|
except Exception:
|
||||||
|
logger.warn(
|
||||||
|
"Unable to write jwt token file to config directory. A new jwt token will be created at each startup."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("Using jwt secret from .jwt_secret file in config directory.")
|
||||||
|
with open(jwt_secret_file) as f:
|
||||||
|
try:
|
||||||
|
jwt_secret = f.readline()
|
||||||
|
except Exception:
|
||||||
|
logger.warn(
|
||||||
|
"Unable to read jwt token from .jwt_secret file in config directory. A new jwt token will be created at each startup."
|
||||||
|
)
|
||||||
|
jwt_secret = secrets.token_hex(64)
|
||||||
|
|
||||||
|
if len(jwt_secret) < 64:
|
||||||
|
logger.warn("JWT Secret is recommended to be 64 characters or more")
|
||||||
|
|
||||||
|
return jwt_secret
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password, salt=None, iterations=600000):
|
||||||
|
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(PASSWORD_HASH_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 == PASSWORD_HASH_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, secure=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoint for use with nginx auth_request
|
||||||
|
@AuthBp.route("/auth")
|
||||||
|
def auth():
|
||||||
|
success_response = make_response({}, 202)
|
||||||
|
|
||||||
|
# dont require auth if the request is on the internal port
|
||||||
|
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
||||||
|
if request.headers.get("x-server-port", 0, type=int) == 5000:
|
||||||
|
return success_response
|
||||||
|
|
||||||
|
# if proxy auth mode
|
||||||
|
if current_app.frigate_config.auth.mode == AuthModeEnum.proxy:
|
||||||
|
# pass the user header value from the upstream proxy if a mapping is specified
|
||||||
|
# or use anonymous if none are specified
|
||||||
|
if current_app.frigate_config.auth.header_map.user is not None:
|
||||||
|
upstream_user_header_value = request.headers.get(
|
||||||
|
current_app.frigate_config.auth.header_map.user,
|
||||||
|
type=str,
|
||||||
|
default="anonymous",
|
||||||
|
)
|
||||||
|
success_response.headers["remote-user"] = upstream_user_header_value
|
||||||
|
else:
|
||||||
|
success_response.headers["remote-user"] = "anonymous"
|
||||||
|
return success_response
|
||||||
|
|
||||||
|
fail_response = make_response({}, 401)
|
||||||
|
fail_response.headers["location"] = "/login"
|
||||||
|
|
||||||
|
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
|
||||||
|
JWT_REFRESH = current_app.frigate_config.auth.refresh_time
|
||||||
|
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
|
||||||
|
|
||||||
|
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 fail_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = jwt.decode(encoded_token, current_app.jwt_token)
|
||||||
|
if "sub" not in token.claims:
|
||||||
|
logger.debug("user not set in jwt token")
|
||||||
|
return fail_response
|
||||||
|
if "exp" not in token.claims:
|
||||||
|
logger.debug("exp not set in jwt token")
|
||||||
|
return fail_response
|
||||||
|
|
||||||
|
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 fail_response
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
# ensure the user hasn't been deleted
|
||||||
|
try:
|
||||||
|
User.get_by_id(user)
|
||||||
|
except DoesNotExist:
|
||||||
|
return fail_response
|
||||||
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
|
user, new_expiration, current_app.jwt_token
|
||||||
|
)
|
||||||
|
set_jwt_cookie(
|
||||||
|
success_response, JWT_COOKIE_NAME, new_encoded_jwt, new_expiration
|
||||||
|
)
|
||||||
|
|
||||||
|
success_response.headers["remote-user"] = user
|
||||||
|
return success_response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing jwt: {e}")
|
||||||
|
return fail_response
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/profile")
|
||||||
|
def profile():
|
||||||
|
username = request.headers.get("remote-user", type=str)
|
||||||
|
return jsonify({"username": username})
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/logout")
|
||||||
|
def logout():
|
||||||
|
auth_config: AuthConfig = current_app.frigate_config.auth
|
||||||
|
response = make_response(redirect("/login", code=303))
|
||||||
|
response.delete_cookie(auth_config.cookie_name)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/login", methods=["POST"])
|
||||||
|
@limiter.limit(get_rate_limit, deduct_when=lambda response: response.status_code == 400)
|
||||||
|
def login():
|
||||||
|
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
|
||||||
|
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
|
||||||
|
content = request.get_json()
|
||||||
|
user = content["user"]
|
||||||
|
password = content["password"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_user: User = User.get_by_id(user)
|
||||||
|
except DoesNotExist:
|
||||||
|
return make_response({"message": "Login failed"}, 400)
|
||||||
|
|
||||||
|
password_hash = db_user.password_hash
|
||||||
|
if verify_password(password, password_hash):
|
||||||
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
|
encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token)
|
||||||
|
response = make_response({}, 200)
|
||||||
|
set_jwt_cookie(response, JWT_COOKIE_NAME, encoded_jwt, expiration)
|
||||||
|
return response
|
||||||
|
return make_response({"message": "Login failed"}, 400)
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/users")
|
||||||
|
def get_users():
|
||||||
|
exports = User.select(User.username).order_by(User.username).dicts().iterator()
|
||||||
|
return jsonify([e for e in exports])
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/users", methods=["POST"])
|
||||||
|
def create_user():
|
||||||
|
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
|
request_data = request.get_json()
|
||||||
|
|
||||||
|
if not re.match("^[A-Za-z0-9._]+$", request_data.get("username", "")):
|
||||||
|
make_response({"message": "Invalid username"}, 400)
|
||||||
|
|
||||||
|
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
|
||||||
|
|
||||||
|
User.insert(
|
||||||
|
{
|
||||||
|
User.username: request_data["username"],
|
||||||
|
User.password_hash: password_hash,
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
return jsonify({"username": request_data["username"]})
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/users/<username>", methods=["DELETE"])
|
||||||
|
def delete_user(username: str):
|
||||||
|
User.delete_by_id(username)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@AuthBp.route("/users/<username>/password", methods=["PUT"])
|
||||||
|
def update_password(username: str):
|
||||||
|
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
|
request_data = request.get_json()
|
||||||
|
|
||||||
|
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
|
||||||
|
|
||||||
|
User.set_by_id(
|
||||||
|
username,
|
||||||
|
{
|
||||||
|
User.password_hash: password_hash,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonify({"success": True})
|
@ -3,6 +3,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
@ -19,13 +20,14 @@ from playhouse.sqliteq import SqliteQueueDatabase
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.api.app import create_app
|
from frigate.api.app import create_app
|
||||||
|
from frigate.api.auth import hash_password
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
from frigate.comms.detections_updater import DetectionProxy
|
from frigate.comms.detections_updater import DetectionProxy
|
||||||
from frigate.comms.dispatcher import Communicator, Dispatcher
|
from frigate.comms.dispatcher import Communicator, Dispatcher
|
||||||
from frigate.comms.inter_process import InterProcessCommunicator
|
from frigate.comms.inter_process import InterProcessCommunicator
|
||||||
from frigate.comms.mqtt import MqttClient
|
from frigate.comms.mqtt import MqttClient
|
||||||
from frigate.comms.ws import WebSocketClient
|
from frigate.comms.ws import WebSocketClient
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import AuthModeEnum, FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
@ -49,6 +51,7 @@ from frigate.models import (
|
|||||||
Regions,
|
Regions,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
Timeline,
|
Timeline,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from frigate.object_detection import ObjectDetectProcess
|
from frigate.object_detection import ObjectDetectProcess
|
||||||
from frigate.object_processing import TrackedObjectProcessor
|
from frigate.object_processing import TrackedObjectProcessor
|
||||||
@ -338,6 +341,7 @@ class FrigateApp:
|
|||||||
Regions,
|
Regions,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
Timeline,
|
Timeline,
|
||||||
|
User,
|
||||||
]
|
]
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
|
|
||||||
@ -587,6 +591,42 @@ class FrigateApp:
|
|||||||
f"The current SHM size of {available_shm}MB is too small, recommend increasing it to at least {min_req_shm}MB."
|
f"The current SHM size of {available_shm}MB is too small, recommend increasing it to at least {min_req_shm}MB."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def init_auth(self) -> None:
|
||||||
|
if self.config.auth.mode == AuthModeEnum.native:
|
||||||
|
if User.select().count() == 0:
|
||||||
|
password = secrets.token_hex(16)
|
||||||
|
password_hash = hash_password(
|
||||||
|
password, iterations=self.config.auth.hash_iterations
|
||||||
|
)
|
||||||
|
User.insert(
|
||||||
|
{
|
||||||
|
User.username: "admin",
|
||||||
|
User.password_hash: password_hash,
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("*** Auth is enabled, but no users exist. ***")
|
||||||
|
logger.info("*** Created a default user: ***")
|
||||||
|
logger.info("*** User: admin ***")
|
||||||
|
logger.info(f"*** Password: {password} ***")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
elif self.config.auth.reset_admin_password:
|
||||||
|
password = secrets.token_hex(16)
|
||||||
|
password_hash = hash_password(
|
||||||
|
password, iterations=self.config.auth.hash_iterations
|
||||||
|
)
|
||||||
|
User.replace(username="admin", password_hash=password_hash).execute()
|
||||||
|
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("*** Reset admin password set in the config. ***")
|
||||||
|
logger.info(f"*** Password: {password} ***")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
logger.info("********************************************************")
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="Frigate",
|
prog="Frigate",
|
||||||
@ -664,6 +704,7 @@ class FrigateApp:
|
|||||||
self.start_record_cleanup()
|
self.start_record_cleanup()
|
||||||
self.start_watchdog()
|
self.start_watchdog()
|
||||||
self.check_shm()
|
self.check_shm()
|
||||||
|
self.init_auth()
|
||||||
|
|
||||||
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
|
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
@ -116,6 +116,52 @@ class UIConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthModeEnum(str, Enum):
|
||||||
|
native = "native"
|
||||||
|
proxy = "proxy"
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderMappingConfig(FrigateBaseModel):
|
||||||
|
user: str = Field(
|
||||||
|
default=None, title="Header name from upstream proxy to identify user."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfig(FrigateBaseModel):
|
||||||
|
mode: AuthModeEnum = Field(default=AuthModeEnum.native, title="Authentication mode")
|
||||||
|
reset_admin_password: bool = Field(
|
||||||
|
default=False, title="Reset the admin password on startup"
|
||||||
|
)
|
||||||
|
cookie_name: str = Field(
|
||||||
|
default="frigate_token", title="Name for jwt token cookie", pattern=r"^[a-z]_*$"
|
||||||
|
)
|
||||||
|
session_length: int = Field(
|
||||||
|
default=86400, title="Session length for jwt session tokens", ge=60
|
||||||
|
)
|
||||||
|
refresh_time: int = Field(
|
||||||
|
default=43200,
|
||||||
|
title="Refresh the session if it is going to expire in this many seconds",
|
||||||
|
ge=30,
|
||||||
|
)
|
||||||
|
header_map: HeaderMappingConfig = Field(
|
||||||
|
default_factory=HeaderMappingConfig,
|
||||||
|
title="Header mapping definitions for proxy auth mode.",
|
||||||
|
)
|
||||||
|
failed_login_rate_limit: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
title="Rate limits for failed login attempts.",
|
||||||
|
)
|
||||||
|
trusted_proxies: List[str] = Field(
|
||||||
|
default=[],
|
||||||
|
title="Trusted proxies for determining IP address to rate limit",
|
||||||
|
)
|
||||||
|
logout_url: Optional[str] = Field(
|
||||||
|
default=None, title="Redirect url for logging out in proxy mode."
|
||||||
|
)
|
||||||
|
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
||||||
|
hash_iterations: int = Field(default=600000, title="Password hash iterations")
|
||||||
|
|
||||||
|
|
||||||
class StatsConfig(FrigateBaseModel):
|
class StatsConfig(FrigateBaseModel):
|
||||||
amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.")
|
amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.")
|
||||||
intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.")
|
intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.")
|
||||||
@ -1245,10 +1291,11 @@ def verify_motion_and_detect(camera_config: CameraConfig) -> ValueError | None:
|
|||||||
|
|
||||||
|
|
||||||
class FrigateConfig(FrigateBaseModel):
|
class FrigateConfig(FrigateBaseModel):
|
||||||
mqtt: MqttConfig = Field(title="MQTT Configuration.")
|
mqtt: MqttConfig = Field(title="MQTT configuration.")
|
||||||
database: DatabaseConfig = Field(
|
database: DatabaseConfig = Field(
|
||||||
default_factory=DatabaseConfig, title="Database configuration."
|
default_factory=DatabaseConfig, title="Database configuration."
|
||||||
)
|
)
|
||||||
|
auth: AuthConfig = Field(default_factory=AuthConfig, title="Auth configuration.")
|
||||||
environment_vars: Dict[str, str] = Field(
|
environment_vars: Dict[str, str] = Field(
|
||||||
default_factory=dict, title="Frigate environment variables."
|
default_factory=dict, title="Frigate environment variables."
|
||||||
)
|
)
|
||||||
|
@ -91,3 +91,8 @@ AUTOTRACKING_MAX_MOVE_METRICS = 500
|
|||||||
AUTOTRACKING_ZOOM_OUT_HYSTERESIS = 1.1
|
AUTOTRACKING_ZOOM_OUT_HYSTERESIS = 1.1
|
||||||
AUTOTRACKING_ZOOM_IN_HYSTERESIS = 0.95
|
AUTOTRACKING_ZOOM_IN_HYSTERESIS = 0.95
|
||||||
AUTOTRACKING_ZOOM_EDGE_THRESHOLD = 0.05
|
AUTOTRACKING_ZOOM_EDGE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
|
||||||
|
JWT_SECRET_ENV_VAR = "FRIGATE_JWT_SECRET"
|
||||||
|
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||||
|
@ -113,3 +113,8 @@ class RecordingsToDelete(Model): # type: ignore[misc]
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
temporary = True
|
temporary = True
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model): # type: ignore[misc]
|
||||||
|
username = CharField(null=False, primary_key=True, max_length=30)
|
||||||
|
password_hash = CharField(null=False, max_length=120)
|
||||||
|
36
migrations/025_create_user_table.py
Normal file
36
migrations/025_create_user_table.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Peewee migrations -- 025_create_user_table.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql(
|
||||||
|
'CREATE TABLE IF NOT EXISTS "user" ("username" VARCHAR(30) NOT NULL PRIMARY KEY, "password_hash" VARCHAR(120) NOT NULL)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
pass
|
@ -43,6 +43,14 @@ module.exports = {
|
|||||||
"error",
|
"error",
|
||||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
"no-console": "error",
|
"no-console": "error",
|
||||||
"prettier/prettier": [
|
"prettier/prettier": [
|
||||||
"warn",
|
"warn",
|
||||||
|
36
web/login.html
Normal file
36
web/login.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/images/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frigate</title>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/images/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/images/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/images/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials" />
|
||||||
|
<link rel="mask-icon" href="/images/favicon.svg" color="#3b82f7" />
|
||||||
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<script type="module" src="/src/login.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -7,6 +7,7 @@
|
|||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
||||||
|
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
@ -24,6 +24,12 @@ export function ApiProvider({ children, options }: ApiProviderType) {
|
|||||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||||
return axios.get(path, { params }).then((res) => res.data);
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
},
|
},
|
||||||
|
onError: (error, _key) => {
|
||||||
|
if ([401, 302, 307].includes(error.response.status)) {
|
||||||
|
window.location.href =
|
||||||
|
error.response.headers.get("location") ?? "login";
|
||||||
|
}
|
||||||
|
},
|
||||||
...options,
|
...options,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -40,6 +46,7 @@ function WsWithConfig({ children }: WsWithConfigType) {
|
|||||||
return <WsProvider>{children}</WsProvider>;
|
return <WsProvider>{children}</WsProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function useApiHost() {
|
export function useApiHost() {
|
||||||
return baseUrl;
|
return baseUrl;
|
||||||
}
|
}
|
||||||
|
132
web/src/components/auth/AuthForm.tsx
Normal file
132
web/src/components/auth/AuthForm.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
user: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
"/api/login",
|
||||||
|
{
|
||||||
|
user: values.user,
|
||||||
|
password: values.password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"X-CSRF-TOKEN": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (err.response?.status === 429) {
|
||||||
|
toast.error("Exceeded rate limit. Try again later.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} else if (err.response?.status === 400) {
|
||||||
|
toast.error("Login failed", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Unknown error. Check logs.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("Unknown error. Check console logs.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-1"
|
||||||
|
>
|
||||||
|
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -7,31 +7,82 @@ import { cn } from "@/lib/utils";
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { VscAccount } from "react-icons/vsc";
|
import { VscAccount } from "react-icons/vsc";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
|
import { DialogClose } from "../ui/dialog";
|
||||||
|
import { LuLogOut } from "react-icons/lu";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
type AccountSettingsProps = {
|
type AccountSettingsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
export default function AccountSettings({ className }: AccountSettingsProps) {
|
export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||||
|
const { data: profile } = useSWR("profile");
|
||||||
|
const { data: config } = useSWR("config");
|
||||||
|
const logoutUrl = config?.auth.logout_url || "/api/logout";
|
||||||
|
|
||||||
|
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||||
|
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
|
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||||
|
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<div className={className}>
|
||||||
<TooltipTrigger asChild>
|
<Container>
|
||||||
<div
|
<Trigger asChild>
|
||||||
className={cn(
|
<a href="#">
|
||||||
"flex flex-col items-center justify-center",
|
<Tooltip>
|
||||||
isDesktop
|
<TooltipTrigger asChild>
|
||||||
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
|
<div
|
||||||
: "text-secondary-foreground",
|
className={cn(
|
||||||
className,
|
"flex flex-col items-center justify-center",
|
||||||
)}
|
isDesktop
|
||||||
|
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
|
||||||
|
: "text-secondary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<VscAccount className="size-5 md:m-[6px]" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Account</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
</a>
|
||||||
|
</Trigger>
|
||||||
|
<Content
|
||||||
|
className={
|
||||||
|
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<VscAccount className="size-5 md:m-[6px]" />
|
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||||
</div>
|
<DropdownMenuLabel>
|
||||||
</TooltipTrigger>
|
Current User: {profile?.username || "anonymous"}
|
||||||
<TooltipPortal>
|
</DropdownMenuLabel>
|
||||||
<TooltipContent side="right">
|
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||||
<p>Account</p>
|
<MenuItem
|
||||||
</TooltipContent>
|
className={
|
||||||
</TooltipPortal>
|
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||||
</Tooltip>
|
}
|
||||||
|
>
|
||||||
|
<a className="flex" href={logoutUrl}>
|
||||||
|
<LuLogOut className="mr-2 size-4" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</a>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
111
web/src/components/overlay/CreateUserDialog.tsx
Normal file
111
web/src/components/overlay/CreateUserDialog.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../ui/form";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
|
||||||
|
type CreateUserOverlayProps = {
|
||||||
|
show: boolean;
|
||||||
|
onCreate: (user: string, password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
export default function CreateUserDialog({
|
||||||
|
show,
|
||||||
|
onCreate,
|
||||||
|
onCancel,
|
||||||
|
}: CreateUserOverlayProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
user: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(/^[A-Za-z0-9._]+$/, {
|
||||||
|
message: "Username may only include letters, numbers, . or _",
|
||||||
|
}),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await onCreate(values.user, values.password);
|
||||||
|
form.reset();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="select" disabled={isLoading}>
|
||||||
|
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
40
web/src/components/overlay/DeleteUserDialog.tsx
Normal file
40
web/src/components/overlay/DeleteUserDialog.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
|
||||||
|
type SetPasswordProps = {
|
||||||
|
show: boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
export default function DeleteUserDialog({
|
||||||
|
show,
|
||||||
|
onDelete,
|
||||||
|
onCancel,
|
||||||
|
}: SetPasswordProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>Are you sure?</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
51
web/src/components/overlay/SetPasswordDialog.tsx
Normal file
51
web/src/components/overlay/SetPasswordDialog.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
|
||||||
|
type SetPasswordProps = {
|
||||||
|
show: boolean;
|
||||||
|
onSave: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
export default function SetPasswordDialog({
|
||||||
|
show,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: SetPasswordProps) {
|
||||||
|
const [password, setPassword] = useState<string>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set Password</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
variant="select"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onSave(password!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
161
web/src/components/settings/Authentication.tsx
Normal file
161
web/src/components/settings/Authentication.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Heading from "../ui/heading";
|
||||||
|
import { User } from "@/types/user";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||||
|
import axios from "axios";
|
||||||
|
import CreateUserDialog from "../overlay/CreateUserDialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import DeleteUserDialog from "../overlay/DeleteUserDialog";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
|
||||||
|
export default function Authentication() {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
|
||||||
|
|
||||||
|
const [showSetPassword, setShowSetPassword] = useState(false);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Authentication Settings - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSavePassword = useCallback((user: string, password: string) => {
|
||||||
|
axios
|
||||||
|
.put(`users/${user}/password`, {
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
setShowSetPassword(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((_error) => {
|
||||||
|
toast.error("Error setting password", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCreate = async (user: string, password: string) => {
|
||||||
|
try {
|
||||||
|
await axios.post("users", {
|
||||||
|
username: user,
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
setShowCreate(false);
|
||||||
|
mutateUsers((users) => {
|
||||||
|
users?.push({ username: user });
|
||||||
|
return users;
|
||||||
|
}, false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error creating user. Check server logs.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (user: string) => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`users/${user}`);
|
||||||
|
setShowDelete(false);
|
||||||
|
mutateUsers((users) => {
|
||||||
|
return users?.filter((u) => {
|
||||||
|
return u.username !== user;
|
||||||
|
});
|
||||||
|
}, false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting user. Check server logs.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config || !users) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
|
<Heading as="h3" className="my-2">
|
||||||
|
Users
|
||||||
|
</Heading>
|
||||||
|
<div className="flex flex-row items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreate(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{users.map((u) => (
|
||||||
|
<Card key={u.username} className="mb-1 p-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="ml-3 flex flex-none shrink overflow-hidden text-ellipsis align-middle text-lg">
|
||||||
|
{u.username}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 justify-end space-x-2 ">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSetPassword(true);
|
||||||
|
setSelectedUser(u.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDelete(true);
|
||||||
|
setSelectedUser(u.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SetPasswordDialog
|
||||||
|
show={showSetPassword}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowSetPassword(false);
|
||||||
|
}}
|
||||||
|
onSave={(password) => {
|
||||||
|
onSavePassword(selectedUser!, password);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteUserDialog
|
||||||
|
show={showDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowDelete(false);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
onDelete(selectedUser!);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUserDialog
|
||||||
|
show={showCreate}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -80,7 +80,7 @@ export default function General() {
|
|||||||
<div className="mt-2 space-y-6">
|
<div className="mt-2 space-y-6">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-md">Default Playback Rate</div>
|
<div className="text-md">Default Playback Rate</div>
|
||||||
<div className="text-sm text-muted-foreground my-2">
|
<div className="my-2 text-sm text-muted-foreground">
|
||||||
<p>Default playback rate for recordings playback.</p>
|
<p>Default playback rate for recordings playback.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -106,7 +106,7 @@ export default function General() {
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Separator className="flex my-2 bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
<div className="mt-2 space-y-6">
|
<div className="mt-2 space-y-6">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-md">Low Data Mode</div>
|
<div className="text-md">Low Data Mode</div>
|
||||||
|
@ -12,6 +12,7 @@ type ColorScheme =
|
|||||||
| "theme-red"
|
| "theme-red"
|
||||||
| "theme-default";
|
| "theme-default";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const colorSchemes: ColorScheme[] = [
|
export const colorSchemes: ColorScheme[] = [
|
||||||
"theme-blue",
|
"theme-blue",
|
||||||
"theme-gold",
|
"theme-gold",
|
||||||
@ -25,6 +26,7 @@ export const colorSchemes: ColorScheme[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to generate friendly color scheme names
|
// Helper function to generate friendly color scheme names
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const friendlyColorSchemeName = (className: string): string => {
|
export const friendlyColorSchemeName = (className: string): string => {
|
||||||
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
|
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
|
||||||
return words
|
return words
|
||||||
@ -136,6 +138,7 @@ export function ThemeProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeProviderContext);
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
|
10
web/src/login.tsx
Normal file
10
web/src/login.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import LoginPage from "@/pages/LoginPage.tsx";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<LoginPage />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
22
web/src/pages/LoginPage.tsx
Normal file
22
web/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { UserAuthForm } from "@/components/auth/AuthForm";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { ThemeProvider } from "@/context/theme-provider";
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||||
|
<div className="size-full overflow-hidden">
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<Logo className="mb-6 h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<UserAuthForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
@ -33,6 +33,7 @@ import { PolygonType } from "@/types/canvas";
|
|||||||
import ObjectSettings from "@/components/settings/ObjectSettings";
|
import ObjectSettings from "@/components/settings/ObjectSettings";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
|
import Authentication from "@/components/settings/Authentication";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const settingsViews = [
|
const settingsViews = [
|
||||||
@ -40,6 +41,7 @@ export default function Settings() {
|
|||||||
"masks / zones",
|
"masks / zones",
|
||||||
"motion tuner",
|
"motion tuner",
|
||||||
"debug",
|
"debug",
|
||||||
|
"authentication",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SettingsType = (typeof settingsViews)[number];
|
type SettingsType = (typeof settingsViews)[number];
|
||||||
@ -169,6 +171,7 @@ export default function Settings() {
|
|||||||
setUnsavedChanges={setUnsavedChanges}
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page == "authentication" && <Authentication />}
|
||||||
</div>
|
</div>
|
||||||
{confirmationDialogOpen && (
|
{confirmationDialogOpen && (
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
3
web/src/types/user.ts
Normal file
3
web/src/types/user.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type User = {
|
||||||
|
username: string;
|
||||||
|
};
|
@ -1,9 +1,11 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import path from "path";
|
import path, { resolve } from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import monacoEditorPlugin from "vite-plugin-monaco-editor";
|
import monacoEditorPlugin from "vite-plugin-monaco-editor";
|
||||||
|
|
||||||
|
const proxyHost = "localhost:5000";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: {
|
define: {
|
||||||
@ -12,29 +14,37 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:5000",
|
target: `http://${proxyHost}`,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
"/vod": {
|
"/vod": {
|
||||||
target: "http://localhost:5000",
|
target: `http://${proxyHost}`,
|
||||||
},
|
},
|
||||||
"/clips": {
|
"/clips": {
|
||||||
target: "http://localhost:5000",
|
target: `http://${proxyHost}`,
|
||||||
},
|
},
|
||||||
"/exports": {
|
"/exports": {
|
||||||
target: "http://localhost:5000",
|
target: `http://${proxyHost}`,
|
||||||
},
|
},
|
||||||
"/ws": {
|
"/ws": {
|
||||||
target: "ws://localhost:5000",
|
target: `ws://${proxyHost}`,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
"/live": {
|
"/live": {
|
||||||
target: "ws://localhost:5000",
|
target: `ws://${proxyHost}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "index.html"),
|
||||||
|
login: resolve(__dirname, "login.html"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
monacoEditorPlugin.default({
|
monacoEditorPlugin.default({
|
||||||
|
Loading…
Reference in New Issue
Block a user