UI viewer role (#16978)

* db migration

* db model

* assign admin role on password reset

* add role to jwt and api responses

* don't restrict api access for admins yet

* use json response

* frontend auth context

* update auth form for profile endpoint

* add access denied page

* add protected routes

* auth hook

* dialogs

* user settings view

* restrict viewer access to settings

* restrict camera functions for viewer role

* add password dialog to account menu

* spacing tweak

* migrator default to admin

* escape quotes in migrator

* ui tweaks

* tweaks

* colors

* colors

* fix merge conflict

* fix icons

* add api layer enforcement

* ui tweaks

* fix error message

* debug

* clean up

* remove print

* guard apis for admin only

* fix tests

* fix review tests

* use correct error responses from api in toasts

* add role to account menu
This commit is contained in:
Josh Hawkins
2025-03-08 10:01:08 -06:00
committed by GitHub
parent 6f9d9cd5a8
commit 74ca009b0b
50 changed files with 1951 additions and 732 deletions
+4 -3
View File
@@ -22,6 +22,7 @@ from markupsafe import escape
from peewee import operator
from pydantic import ValidationError
from frigate.api.auth import require_role
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags
@@ -201,7 +202,7 @@ def config_raw():
)
@router.post("/config/save")
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode()
if not new_config:
@@ -326,7 +327,7 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
)
@router.put("/config/set")
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file()
@@ -542,7 +543,7 @@ async def logs(
)
@router.post("/restart")
@router.post("/restart", dependencies=[Depends(require_role(["admin"]))])
def restart():
try:
restart_frigate()
+148 -29
View File
@@ -11,8 +11,9 @@ import secrets
import time
from datetime import datetime
from pathlib import Path
from typing import List
from fastapi import APIRouter, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
from peewee import DoesNotExist
@@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import (
AppPostLoginBody,
AppPostUsersBody,
AppPutPasswordBody,
AppPutRoleBody,
)
from frigate.api.defs.tags import Tags
from frigate.config import AuthConfig, ProxyConfig
@@ -169,8 +171,10 @@ def verify_password(password, password_hash):
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 create_encoded_jwt(user, role, expiration, secret):
return jwt.encode(
{"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
@@ -184,7 +188,48 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec
)
# Endpoint for use with nginx auth_request
async def get_current_user(request: Request):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
encoded_token = request.cookies.get(JWT_COOKIE_NAME)
if not encoded_token:
return JSONResponse(content={"message": "No JWT token found"}, status_code=401)
try:
token = jwt.decode(encoded_token, request.app.jwt_token)
if "sub" not in token.claims or "role" not in token.claims:
return JSONResponse(
content={"message": "Invalid JWT token"}, status_code=401
)
return {"username": token.claims["sub"], "role": token.claims["role"]}
except Exception as e:
logger.error(f"Error parsing JWT: {e}")
return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401)
def require_role(required_roles: List[str]):
async def role_checker(request: Request):
# Get role from header (could be comma-separated)
role_header = request.headers.get("remote-role")
roles = [r.strip() for r in role_header.split(",")] if role_header else []
# Check if we have any roles
if not roles:
raise HTTPException(status_code=403, detail="Role not provided")
# Check if any role matches required_roles
if not any(role in required_roles for role in roles):
raise HTTPException(
status_code=403,
detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}",
)
# Return the first matching role
return next((role for role in roles if role in required_roles), roles[0])
return role_checker
# Endpoints
@router.get("/auth")
def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
@@ -195,6 +240,8 @@ def auth(request: Request):
# 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 int(request.headers.get("x-server-port", default=0)) == 5000:
success_response.headers["remote-user"] = "anonymous"
success_response.headers["remote-role"] = "admin"
return success_response
fail_response = Response("", status_code=401)
@@ -211,14 +258,18 @@ def auth(request: Request):
if not auth_config.enabled:
# pass the user header value from the upstream proxy if a mapping is specified
# or use anonymous if none are specified
if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get(
proxy_config.header_map.user,
default="anonymous",
)
success_response.headers["remote-user"] = upstream_user_header_value
else:
success_response.headers["remote-user"] = "anonymous"
user_header = proxy_config.header_map.user
role_header = proxy_config.header_map.get("role", "Remote-Role")
success_response.headers["remote-user"] = (
request.headers.get(user_header, default="anonymous")
if user_header
else "anonymous"
)
success_response.headers["remote-role"] = (
request.headers.get(role_header, default="viewer")
if role_header
else "viewer"
)
return success_response
# now apply authentication
@@ -251,11 +302,15 @@ def auth(request: Request):
if "sub" not in token.claims:
logger.debug("user not set in jwt token")
return fail_response
if "role" not in token.claims:
logger.debug("role 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")
role = token.claims.get("role")
current_time = int(time.time())
# if the jwt is expired
@@ -283,7 +338,7 @@ def auth(request: Request):
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt(
user, new_expiration, request.app.jwt_token
user, role, new_expiration, request.app.jwt_token
)
set_jwt_cookie(
success_response,
@@ -294,6 +349,7 @@ def auth(request: Request):
)
success_response.headers["remote-user"] = user
success_response.headers["remote-role"] = role
return success_response
except Exception as e:
logger.error(f"Error parsing jwt: {e}")
@@ -302,8 +358,16 @@ def auth(request: Request):
@router.get("/profile")
def profile(request: Request):
username = request.headers.get("remote-user")
return JSONResponse(content={"username": username})
username = request.headers.get("remote-user", "anonymous")
if username != "anonymous":
try:
user = User.get_by_id(username)
role = getattr(user, "role", "viewer")
except DoesNotExist:
role = "viewer" # Fallback if user deleted
else:
role = None
return JSONResponse(content={"username": username, "role": role})
@router.get("/logout")
@@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody):
password_hash = db_user.password_hash
if verify_password(password, password_hash):
role = getattr(db_user, "role", "viewer")
if role not in ["admin", "viewer"]:
role = "viewer" # Enforce valid roles
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200)
set_jwt_cookie(
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
@@ -343,25 +410,31 @@ def login(request: Request, body: AppPostLoginBody):
return JSONResponse(content={"message": "Login failed"}, status_code=401)
@router.get("/users")
@router.get("/users", dependencies=[Depends(require_role(["admin"]))])
def get_users():
exports = User.select(User.username).order_by(User.username).dicts().iterator()
exports = (
User.select(User.username, User.role).order_by(User.username).dicts().iterator()
)
return JSONResponse([e for e in exports])
@router.post("/users")
def create_user(request: Request, body: AppPostUsersBody):
@router.post("/users", dependencies=[Depends(require_role(["admin"]))])
def create_user(
request: Request,
body: AppPostUsersBody,
):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
if not re.match("^[A-Za-z0-9._]+$", body.username):
JSONResponse(content={"message": "Invalid username"}, status_code=400)
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
role = body.role if body.role in ["admin", "viewer"] else "viewer"
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert(
{
User.username: body.username,
User.password_hash: password_hash,
User.role: role,
User.notification_tokens: [],
}
).execute()
@@ -375,15 +448,61 @@ def delete_user(username: str):
@router.put("/users/{username}/password")
def update_password(request: Request, username: str, body: AppPutPasswordBody):
async def update_password(
request: Request,
username: str,
body: AppPutPasswordBody,
):
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
# auth failed
return current_user
current_username = current_user.get("username")
current_role = current_user.get("role")
# viewers can only change their own password
if current_role == "viewer" and current_username != username:
raise HTTPException(
status_code=403, detail="Viewers can only update their own password"
)
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.set_by_id(username, {User.password_hash: password_hash})
User.set_by_id(
username,
{
User.password_hash: password_hash,
},
)
return JSONResponse(content={"success": True})
@router.put(
"/users/{username}/role",
dependencies=[Depends(require_role(["admin"]))],
)
async def update_role(
request: Request,
username: str,
body: AppPutRoleBody,
):
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
# auth failed
return current_user
current_role = current_user.get("role")
# viewers can't change anyone's role
if current_role == "viewer":
raise HTTPException(
status_code=403, detail="Admin role is required to change user roles"
)
if username == "admin":
return JSONResponse(
content={"message": "Cannot modify admin user's role"}, status_code=403
)
if body.role not in ["admin", "viewer"]:
return JSONResponse(
content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400
)
User.set_by_id(username, {User.role: body.role})
return JSONResponse(content={"success": True})
+6 -5
View File
@@ -6,12 +6,13 @@ import random
import shutil
import string
from fastapi import APIRouter, Request, UploadFile
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext
@@ -44,7 +45,7 @@ def get_faces():
return JSONResponse(status_code=200, content=face_dict)
@router.post("/faces/reprocess")
@router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))])
def reclassify_face(request: Request, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
@@ -121,7 +122,7 @@ def train_face(request: Request, name: str, body: dict = None):
)
@router.post("/faces/{name}/create")
@router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))])
async def create_face(request: Request, name: str):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
@@ -138,7 +139,7 @@ async def create_face(request: Request, name: str):
)
@router.post("/faces/{name}/register")
@router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))])
async def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
@@ -154,7 +155,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
)
@router.post("/faces/{name}/delete")
@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))])
def deregister_faces(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
+7
View File
@@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel
@@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel):
class AppPostUsersBody(BaseModel):
username: str
password: str
role: Optional[str] = "viewer"
class AppPostLoginBody(BaseModel):
user: str
password: str
class AppPutRoleBody(BaseModel):
role: str
+46 -9
View File
@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.query.events_query_parameters import (
DEFAULT_TIME_RANGE,
EventsQueryParams,
@@ -708,7 +709,11 @@ def event(event_id: str):
return JSONResponse(content="Event not found", status_code=404)
@router.post("/events/{event_id}/retain", response_model=GenericResponse)
@router.post(
"/events/{event_id}/retain",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_retain(event_id: str):
try:
event = Event.get(Event.id == event_id)
@@ -928,7 +933,11 @@ def false_positive(request: Request, event_id: str):
)
@router.delete("/events/{event_id}/retain", response_model=GenericResponse)
@router.delete(
"/events/{event_id}/retain",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_retain(event_id: str):
try:
event = Event.get(Event.id == event_id)
@@ -947,7 +956,11 @@ def delete_retain(event_id: str):
)
@router.post("/events/{event_id}/sub_label", response_model=GenericResponse)
@router.post(
"/events/{event_id}/sub_label",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_sub_label(
request: Request,
event_id: str,
@@ -1022,7 +1035,11 @@ def set_sub_label(
)
@router.post("/events/{event_id}/description", response_model=GenericResponse)
@router.post(
"/events/{event_id}/description",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_description(
request: Request,
event_id: str,
@@ -1069,7 +1086,11 @@ def set_description(
)
@router.put("/events/{event_id}/description/regenerate", response_model=GenericResponse)
@router.put(
"/events/{event_id}/description/regenerate",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def regenerate_description(
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
):
@@ -1137,14 +1158,22 @@ def delete_single_event(event_id: str, request: Request) -> dict:
return {"success": True, "message": f"Event {event_id} deleted"}
@router.delete("/events/{event_id}", response_model=GenericResponse)
@router.delete(
"/events/{event_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_event(request: Request, event_id: str):
result = delete_single_event(event_id, request)
status_code = 200 if result["success"] else 404
return JSONResponse(content=result, status_code=status_code)
@router.delete("/events/", response_model=EventMultiDeleteResponse)
@router.delete(
"/events/",
response_model=EventMultiDeleteResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_events(request: Request, body: EventsDeleteBody):
if not body.event_ids:
return JSONResponse(
@@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody):
return JSONResponse(content=response, status_code=200)
@router.post("/events/{camera_name}/{label}/create", response_model=EventCreateResponse)
@router.post(
"/events/{camera_name}/{label}/create",
response_model=EventCreateResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def create_event(
request: Request,
camera_name: str,
@@ -1226,7 +1259,11 @@ def create_event(
)
@router.put("/events/{event_id}/end", response_model=GenericResponse)
@router.put(
"/events/{event_id}/end",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def end_event(request: Request, event_id: str, body: EventsEndBody):
try:
end_time = body.end_time or datetime.datetime.now().timestamp()
+6 -3
View File
@@ -6,11 +6,12 @@ import string
from pathlib import Path
import psutil
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
from frigate.api.defs.request.export_rename_body import ExportRenameBody
from frigate.api.defs.tags import Tags
@@ -130,7 +131,9 @@ def export_recording(
)
@router.patch("/export/{event_id}/rename")
@router.patch(
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
)
def export_rename(event_id: str, body: ExportRenameBody):
try:
export: Export = Export.get(Export.id == event_id)
@@ -158,7 +161,7 @@ def export_rename(event_id: str, body: ExportRenameBody):
)
@router.delete("/export/{event_id}")
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
def export_delete(event_id: str):
try:
export: Export = Export.get(Export.id == event_id)
+6 -1
View File
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.query.review_query_parameters import (
ReviewActivityMotionQueryParams,
ReviewQueryParams,
@@ -343,7 +344,11 @@ def set_multiple_reviewed(body: ReviewModifyMultipleBody):
)
@router.post("/reviews/delete", response_model=GenericResponse)
@router.post(
"/reviews/delete",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_reviews(body: ReviewModifyMultipleBody):
list_of_ids = body.ids
reviews = (