mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 00:14:41 +03:00
* take the jwt role from the user and revoke stale sessions /auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at. * set the secure cookie flag when the request arrived over tls The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate. * regenerate config schema mock data * revoke tokens issued in the same second as a password change /auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes. * note that a proxy on the tls port must serve https to browsers * reject users whose role was removed from the config A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role. * floor password change time to match iat The password change time was rounded up while the token iat was rounded down, so a login in the same second as a password change got a token that /auth rejected as revoked. Both are floored now, which leaves a sub-second window where a token issued just before the change stays valid. * set password_changed_at on user creation A recreated username had no password change cutoff, so an unexpired token from the deleted account passed /auth with the new account's role. Creating a user now sets the cutoff to the creation time.
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
import ipaddress
|
|
|
|
from pydantic import Field, field_validator, model_validator
|
|
|
|
from .base import FrigateBaseModel
|
|
|
|
__all__ = ["AuthConfig"]
|
|
|
|
|
|
class AuthConfig(FrigateBaseModel):
|
|
enabled: bool = Field(
|
|
default=True,
|
|
title="Enable authentication",
|
|
description="Enable native authentication for the Frigate UI.",
|
|
)
|
|
reset_admin_password: bool = Field(
|
|
default=False,
|
|
title="Reset admin password",
|
|
description="If true, reset the admin user's password on startup and print the new password in logs.",
|
|
)
|
|
cookie_name: str = Field(
|
|
default="frigate_token",
|
|
title="JWT cookie name",
|
|
description="Name of the cookie used to store the JWT token for native authentication.",
|
|
pattern=r"^[a-z_]+$",
|
|
)
|
|
cookie_secure: bool = Field(
|
|
default=False,
|
|
title="Secure cookie flag",
|
|
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
|
|
)
|
|
session_length: int = Field(
|
|
default=86400,
|
|
title="Session length",
|
|
description="Session duration in seconds for JWT-based sessions.",
|
|
ge=60,
|
|
)
|
|
refresh_time: int = Field(
|
|
default=1800,
|
|
title="Session refresh window",
|
|
description="When a session is within this many seconds of expiring, refresh it back to full length.",
|
|
ge=30,
|
|
)
|
|
failed_login_rate_limit: str | None = Field(
|
|
default=None,
|
|
title="Failed login limits",
|
|
description="Rate limiting rules for failed login attempts to reduce brute-force attacks.",
|
|
)
|
|
trusted_proxies: list[str] = Field(
|
|
default=[],
|
|
title="Trusted proxies",
|
|
description="List of trusted proxy IPs used when determining client IP for rate limiting.",
|
|
)
|
|
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
|
hash_iterations: int = Field(
|
|
default=600000,
|
|
title="Hash iterations",
|
|
description="Number of PBKDF2-SHA256 iterations to use when hashing user passwords.",
|
|
)
|
|
roles: dict[str, list[str]] = Field(
|
|
default_factory=dict,
|
|
title="Role mappings",
|
|
description="Map roles to camera lists. An empty list grants access to all cameras for the role.",
|
|
)
|
|
admin_first_time_login: bool | None = Field(
|
|
default=False,
|
|
title="First-time admin flag",
|
|
description=(
|
|
"When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. "
|
|
),
|
|
)
|
|
|
|
@field_validator("trusted_proxies")
|
|
@classmethod
|
|
def validate_trusted_proxies(cls, v: list[str]) -> list[str]:
|
|
for proxy in v:
|
|
ipaddress.ip_network(proxy, strict=False)
|
|
|
|
return v
|
|
|
|
@field_validator("roles")
|
|
@classmethod
|
|
def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]:
|
|
# Ensure role names are valid (alphanumeric with underscores)
|
|
for role in v.keys():
|
|
if not role.replace("_", "").isalnum():
|
|
raise ValueError(
|
|
f"Invalid role name '{role}'. Must be alphanumeric with underscores."
|
|
)
|
|
|
|
# 'none' is the deny sentinel for proxy.default_role, where it is matched
|
|
# case-insensitively, so every casing of it has to be reserved here
|
|
used_reserved = sorted(
|
|
r for r in v if r in ("admin", "viewer") or r.lower() == "none"
|
|
)
|
|
if used_reserved:
|
|
raise ValueError(
|
|
f"Reserved role name(s) {', '.join(used_reserved)} cannot be used as custom roles."
|
|
)
|
|
|
|
# Ensure no role has an empty camera list
|
|
for role, allowed_cameras in v.items():
|
|
if not allowed_cameras:
|
|
raise ValueError(
|
|
f"Role '{role}' has no cameras assigned. Custom roles must have at least one camera."
|
|
)
|
|
|
|
return v
|
|
|
|
@model_validator(mode="after")
|
|
def ensure_default_roles(self):
|
|
# Ensure admin and viewer are never overridden
|
|
self.roles["admin"] = []
|
|
self.roles["viewer"] = []
|
|
|
|
return self
|