* 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:
Blake Blackshear 2024-05-18 11:36:13 -05:00 committed by GitHub
parent a70dd02788
commit 1133202cbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2541 additions and 833 deletions

View File

@ -10,10 +10,14 @@
"features": {
"ghcr.io/devcontainers/features/common-utils:1": {}
},
"forwardPorts": [5000, 5001, 5173, 8554, 8555],
"forwardPorts": [8080, 5000, 5001, 5173, 8554, 8555],
"portsAttributes": {
"8080": {
"label": "External NGINX",
"onAutoForward": "silent"
},
"5000": {
"label": "NGINX",
"label": "Internal NGINX",
"onAutoForward": "silent"
},
"5001": {

View File

@ -40,9 +40,9 @@ jobs:
node-version: 16.x
- run: npm install
working-directory: ./web
# - name: Lint
# run: npm run lint
# working-directory: ./web
- name: Lint
run: npm run lint
working-directory: ./web
web_test:
name: Web - Test

View File

@ -233,6 +233,8 @@ RUN apt-get update \
RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \
pip3 install -r requirements-dev.txt
HEALTHCHECK NONE
CMD ["sleep", "infinity"]

View File

@ -5,6 +5,8 @@ set -euxo pipefail
NGINX_VERSION="1.25.3"
VOD_MODULE_VERSION="1.31"
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
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
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
./configure --prefix=/usr/local/nginx \
--with-file-aio \
--with-http_sub_module \
--with-http_ssl_module \
--with-http_auth_request_module \
--with-http_realip_module \
--with-threads \
--add-module=../ngx_devel_kit \
--add-module=../nginx-set-misc-module \
--add-module=../nginx-vod-module \
--add-module=../nginx-secure-token-module \
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"

View File

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

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

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

View File

@ -62,6 +62,9 @@ http {
}
server {
# intended for external traffic, protected by auth
listen [::]:8080 ipv6only=off;
# intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off;
# vod settings
@ -95,7 +98,10 @@ http {
gzip on;
gzip_types application/vnd.apple.mpegurl;
include auth_location.conf;
location /vod/ {
include auth_request.conf;
aio threads;
vod hls;
@ -107,6 +113,7 @@ http {
}
location /stream/ {
include auth_request.conf;
add_header Cache-Control "no-store";
expires off;
@ -121,7 +128,7 @@ http {
}
location /clips/ {
include auth_request.conf;
types {
video/mp4 mp4;
image/jpeg jpg;
@ -137,6 +144,7 @@ http {
}
location /recordings/ {
include auth_request.conf;
types {
video/mp4 mp4;
}
@ -147,6 +155,7 @@ http {
}
location /exports/ {
include auth_request.conf;
types {
video/mp4 mp4;
}
@ -157,17 +166,20 @@ http {
}
location /ws {
include auth_request.conf;
proxy_pass http://mqtt_ws/;
include proxy.conf;
}
location /live/jsmpeg/ {
include auth_request.conf;
proxy_pass http://jsmpeg/;
include proxy.conf;
}
# frigate lovelace card uses this path
location /live/mse/api/ws {
include auth_request.conf;
limit_except GET {
deny all;
}
@ -176,6 +188,7 @@ http {
}
location /live/webrtc/api/ws {
include auth_request.conf;
limit_except GET {
deny all;
}
@ -185,6 +198,7 @@ http {
# pass through go2rtc player
location /live/webrtc/webrtc.html {
include auth_request.conf;
limit_except GET {
deny all;
}
@ -194,6 +208,7 @@ http {
# frontend uses this to fetch the version
location /api/go2rtc/api {
include auth_request.conf;
limit_except GET {
deny all;
}
@ -203,6 +218,7 @@ http {
# integration uses this to add webrtc candidate
location /api/go2rtc/webrtc {
include auth_request.conf;
limit_except POST {
deny all;
}
@ -211,12 +227,14 @@ http {
}
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ {
include auth_request.conf;
rewrite ^/api/(.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
location /api/ {
include auth_request.conf;
add_header Cache-Control "no-store";
expires off;
proxy_pass http://frigate_api/;
@ -231,12 +249,21 @@ http {
add_header X-Cache-Status $upstream_cache_status;
location /api/vod/ {
include auth_request.conf;
proxy_pass http://frigate_api/vod/;
include proxy.conf;
proxy_cache off;
}
location /api/login {
auth_request off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
location /api/stats {
include auth_request.conf;
access_log off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
@ -244,6 +271,7 @@ http {
}
location /api/version {
include auth_request.conf;
access_log off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
@ -252,6 +280,7 @@ http {
}
location / {
# do not require auth for static assets
add_header Cache-Control "no-store";
expires off;
@ -273,7 +302,7 @@ http {
sub_filter_once off;
root /opt/frigate/web;
try_files $uri $uri/ /index.html;
try_files $uri $uri.html $uri/ /index.html;
}
}
}

View File

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

View File

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

View 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.

View File

@ -25,7 +25,7 @@ cameras:
## 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

View File

@ -63,6 +63,45 @@ database:
# The path to store the SQLite DB (default: shown below)
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
model:
# Optional: path to the model (default: automatic based on detector)

View File

@ -11,7 +11,7 @@ Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.2) to provide
:::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.
:::

View File

@ -225,3 +225,13 @@ docker buildx create --name builder --driver docker-container --driver-opt netwo
docker buildx inspect builder --bootstrap
make push
```
## Other
### Nginx
When testing nginx config changes from within the dev container, the following command can be used to copy and reload the config for testing without rebuilding the container:
```console
sudo cp docker/main/rootfs/usr/local/nginx/conf/* /usr/local/nginx/conf/ && sudo /usr/local/nginx/sbin/nginx -s reload
```

View File

@ -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.
- `/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
Writing to a local disk or external USB drive:
@ -111,7 +122,8 @@ services:
tmpfs:
size: 1000000000
ports:
- "5000:5000"
- "8080:8080"
# - "5000:5000" # Internal unauthenticated access. Expose carefully.
- "8554:8554" # RTSP feeds
- "8555:8555/tcp" # WebRTC over tcp
- "8555:8555/udp" # WebRTC over udp
@ -133,7 +145,7 @@ docker run -d \
-v /path/to/your/config:/config \
-v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \
-p 5000:5000 \
-p 8080:8080 \
-p 8554:8554 \
-p 8555:8555/tcp \
-p 8555:8555/udp \
@ -300,7 +312,7 @@ docker run \
--network=bridge \
--privileged \
--workdir=/opt/frigate \
-p 5000:5000 \
-p 8080:8080 \
-p 8554:8554 \
-p 8555:8555 \
-p 8555:8555/udp \

View File

@ -117,7 +117,7 @@ services:
tmpfs:
size: 1000000000
ports:
- "5000:5000"
- "8080:8080"
- "8554:8554" # RTSP feeds
```
@ -137,7 +137,7 @@ cameras:
- 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

View File

@ -38,20 +38,20 @@ Here we access Frigate via https://cctv.mydomain.co.uk
ServerName cctv.mydomain.co.uk
ProxyPreserveHost On
ProxyPass "/" "http://frigatepi.local:5000/"
ProxyPassReverse "/" "http://frigatepi.local:5000/"
ProxyPass "/" "http://frigatepi.local:8080/"
ProxyPassReverse "/" "http://frigatepi.local:8080/"
ProxyPass /ws ws://frigatepi.local:5000/ws
ProxyPassReverse /ws ws://frigatepi.local:5000/ws
ProxyPass /ws ws://frigatepi.local:8080/ws
ProxyPassReverse /ws ws://frigatepi.local:8080/ws
ProxyPass /live/ ws://frigatepi.local:5000/live/
ProxyPassReverse /live/ ws://frigatepi.local:5000/live/
ProxyPass /live/ ws://frigatepi.local:8080/live/
ProxyPassReverse /live/ ws://frigatepi.local:8080/live/
RewriteEngine on
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]
RewriteRule /(.*) http://frigatepi.local:5000/$1 [P,L]
RewriteRule /(.*) http://frigatepi.local:8080/$1 [P,L]
</VirtualHost>
```
@ -101,7 +101,7 @@ This is set in `$server` and `$port` this should match your ports you have expos
server {
set $forward_scheme http;
set $server "192.168.100.2"; # FRIGATE SERVER LOCATION
set $port 5000;
set $port 8080;
listen 80;
listen 443 ssl http2;

View File

@ -164,7 +164,7 @@ Accepts the following query string parameters:
| `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) |
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]`

View File

@ -47,7 +47,55 @@ that card.
## 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 |
| ------------------------------ | -------------------------------------- |
@ -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 (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

View File

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

1692
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,19 +4,19 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"serve": "docusaurus serve --host 0.0.0.0",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "^3.2.1",
"@docusaurus/preset-classic": "^3.2.1",
"@docusaurus/theme-mermaid": "^3.2.1",
"@docusaurus/core": "^3.3.2",
"@docusaurus/preset-classic": "^3.3.2",
"@docusaurus/theme-mermaid": "^3.3.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.1.0",
@ -37,8 +37,8 @@
]
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.2.1",
"@docusaurus/types": "^3.2.1",
"@docusaurus/module-type-aliases": "^3.3.2",
"@docusaurus/types": "^3.3.2",
"@types/react": "^18.2.79"
},
"engines": {

View File

@ -50,6 +50,7 @@ module.exports = {
"configuration/stationary_objects",
],
"Extra Configuration": [
"configuration/authentication",
"configuration/hardware_acceleration",
"configuration/ffmpeg_presets",
"configuration/advanced",

View File

@ -13,13 +13,15 @@ from flask import Blueprint, Flask, current_app, jsonify, make_response, request
from markupsafe import escape
from peewee import operator
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.export import ExportBp
from frigate.api.media import MediaBp
from frigate.api.preview import PreviewBp
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.events.external import ExternalEventProcessor
from frigate.models import Event, Timeline
@ -44,6 +46,7 @@ bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
def create_app(
@ -83,6 +86,15 @@ def create_app(
app.plus_api = plus_api
app.camera_error_image = None
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)

353
frigate/api/auth.py Normal file
View 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})

View File

@ -3,6 +3,7 @@ import datetime
import logging
import multiprocessing as mp
import os
import secrets
import shutil
import signal
import sys
@ -19,13 +20,14 @@ from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import ValidationError
from frigate.api.app import create_app
from frigate.api.auth import hash_password
from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.detections_updater import DetectionProxy
from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator
from frigate.comms.mqtt import MqttClient
from frigate.comms.ws import WebSocketClient
from frigate.config import FrigateConfig
from frigate.config import AuthModeEnum, FrigateConfig
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
@ -49,6 +51,7 @@ from frigate.models import (
Regions,
ReviewSegment,
Timeline,
User,
)
from frigate.object_detection import ObjectDetectProcess
from frigate.object_processing import TrackedObjectProcessor
@ -338,6 +341,7 @@ class FrigateApp:
Regions,
ReviewSegment,
Timeline,
User,
]
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."
)
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:
parser = argparse.ArgumentParser(
prog="Frigate",
@ -664,6 +704,7 @@ class FrigateApp:
self.start_record_cleanup()
self.start_watchdog()
self.check_shm()
self.init_auth()
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
self.stop()

View File

@ -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):
amd_gpu_stats: bool = Field(default=True, title="Enable AMD 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):
mqtt: MqttConfig = Field(title="MQTT Configuration.")
mqtt: MqttConfig = Field(title="MQTT configuration.")
database: DatabaseConfig = Field(
default_factory=DatabaseConfig, title="Database configuration."
)
auth: AuthConfig = Field(default_factory=AuthConfig, title="Auth configuration.")
environment_vars: Dict[str, str] = Field(
default_factory=dict, title="Frigate environment variables."
)

View File

@ -91,3 +91,8 @@ AUTOTRACKING_MAX_MOVE_METRICS = 500
AUTOTRACKING_ZOOM_OUT_HYSTERESIS = 1.1
AUTOTRACKING_ZOOM_IN_HYSTERESIS = 0.95
AUTOTRACKING_ZOOM_EDGE_THRESHOLD = 0.05
# Auth
JWT_SECRET_ENV_VAR = "FRIGATE_JWT_SECRET"
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"

View File

@ -113,3 +113,8 @@ class RecordingsToDelete(Model): # type: ignore[misc]
class Meta:
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)

View 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

View File

@ -43,6 +43,14 @@ module.exports = {
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": "error",
"prettier/prettier": [
"warn",

36
web/login.html Normal file
View 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>

View File

@ -7,6 +7,7 @@
"dev": "vite --host",
"build": "tsc && vite build --base=/BASE_PATH/",
"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",
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
"test": "vitest",

View File

@ -24,6 +24,12 @@ export function ApiProvider({ children, options }: ApiProviderType) {
const [path, params] = Array.isArray(key) ? key : [key, undefined];
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,
}}
>
@ -40,6 +46,7 @@ function WsWithConfig({ children }: WsWithConfigType) {
return <WsProvider>{children}</WsProvider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useApiHost() {
return baseUrl;
}

View 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>
);
}

View File

@ -7,31 +7,82 @@ import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { isDesktop } from "react-device-detect";
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 = {
className?: string;
};
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 (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center",
isDesktop
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
: "text-secondary-foreground",
className,
)}
<div className={className}>
<Container>
<Trigger asChild>
<a href="#">
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"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>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>Account</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel>
Current User: {profile?.username || "anonymous"}
</DropdownMenuLabel>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" />
<span>Logout</span>
</a>
</MenuItem>
</div>
</Content>
</Container>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -80,7 +80,7 @@ export default function General() {
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<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>
</div>
</div>
@ -106,7 +106,7 @@ export default function General() {
</SelectGroup>
</SelectContent>
</Select>
<Separator className="flex my-2 bg-secondary" />
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Low Data Mode</div>

View File

@ -12,6 +12,7 @@ type ColorScheme =
| "theme-red"
| "theme-default";
// eslint-disable-next-line react-refresh/only-export-components
export const colorSchemes: ColorScheme[] = [
"theme-blue",
"theme-gold",
@ -25,6 +26,7 @@ export const colorSchemes: ColorScheme[] = [
];
// Helper function to generate friendly color scheme names
// eslint-disable-next-line react-refresh/only-export-components
export const friendlyColorSchemeName = (className: string): string => {
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
return words
@ -136,6 +138,7 @@ export function ThemeProvider({
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeProviderContext);

10
web/src/login.tsx Normal file
View 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>,
);

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

View File

@ -33,6 +33,7 @@ import { PolygonType } from "@/types/canvas";
import ObjectSettings from "@/components/settings/ObjectSettings";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import Authentication from "@/components/settings/Authentication";
export default function Settings() {
const settingsViews = [
@ -40,6 +41,7 @@ export default function Settings() {
"masks / zones",
"motion tuner",
"debug",
"authentication",
] as const;
type SettingsType = (typeof settingsViews)[number];
@ -169,6 +171,7 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "authentication" && <Authentication />}
</div>
{confirmationDialogOpen && (
<AlertDialog

3
web/src/types/user.ts Normal file
View File

@ -0,0 +1,3 @@
export type User = {
username: string;
};

View File

@ -1,9 +1,11 @@
/// <reference types="vitest" />
import path from "path";
import path, { resolve } from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = "localhost:5000";
// https://vitejs.dev/config/
export default defineConfig({
define: {
@ -12,29 +14,37 @@ export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:5000",
target: `http://${proxyHost}`,
ws: true,
},
"/vod": {
target: "http://localhost:5000",
target: `http://${proxyHost}`,
},
"/clips": {
target: "http://localhost:5000",
target: `http://${proxyHost}`,
},
"/exports": {
target: "http://localhost:5000",
target: `http://${proxyHost}`,
},
"/ws": {
target: "ws://localhost:5000",
target: `ws://${proxyHost}`,
ws: true,
},
"/live": {
target: "ws://localhost:5000",
target: `ws://${proxyHost}`,
changeOrigin: true,
ws: true,
},
},
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
login: resolve(__dirname, "login.html"),
},
},
},
plugins: [
react(),
monacoEditorPlugin.default({