mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 21:52:50 +03:00
Revoke stale sessions in /auth (#24713)
* 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.
This commit is contained in:
@@ -19,6 +19,7 @@ location /auth {
|
|||||||
# this cannot be spoofed
|
# this cannot be spoofed
|
||||||
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
||||||
proxy_set_header X-Server-Port $server_port;
|
proxy_set_header X-Server-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
# Pass along auth related info
|
# Pass along auth related info
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ auth:
|
|||||||
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||||
cookie_name: frigate_token
|
cookie_name: frigate_token
|
||||||
# Optional: Set secure flag on cookie. (default: shown below)
|
# Optional: Set secure flag on cookie. (default: shown below)
|
||||||
# NOTE: This should be set to True if you are using TLS
|
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
|
||||||
|
# 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
|
||||||
cookie_secure: False
|
cookie_secure: False
|
||||||
# Optional: Session length in seconds (default: shown below)
|
# Optional: Session length in seconds (default: shown below)
|
||||||
session_length: 86400 # 24 hours
|
session_length: 86400 # 24 hours
|
||||||
|
|||||||
+60
-38
@@ -471,8 +471,37 @@ def create_encoded_jwt(user, role, expiration, secret):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_session(user: User) -> tuple[str, int | None]:
|
||||||
|
changed_at = user.password_changed_at
|
||||||
|
|
||||||
|
# floored like iat, so a login in the same second as the change stays valid
|
||||||
|
return user.role, int(changed_at.timestamp()) if changed_at else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
|
||||||
|
"""Map each username to its role and password change time for /auth."""
|
||||||
|
return {user.username: _user_session(user) for user in User.select()}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_user_session(request: Request, username: str) -> None:
|
||||||
|
"""Sync the /auth session map after a user is created, changed or deleted."""
|
||||||
|
user = User.get_or_none(User.username == username)
|
||||||
|
if user is None:
|
||||||
|
request.app.user_sessions.pop(username, None)
|
||||||
|
else:
|
||||||
|
request.app.user_sessions[username] = _user_session(user)
|
||||||
|
|
||||||
|
|
||||||
|
def is_secure_request(request: Request) -> bool:
|
||||||
|
"""Check the cookie setting and nginx's forwarded scheme for HTTPS."""
|
||||||
|
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
|
||||||
|
return (
|
||||||
|
request.app.frigate_config.auth.cookie_secure
|
||||||
|
or request.headers.get("x-forwarded-proto") == "https"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||||
# TODO: ideally this would set secure as well, but that requires TLS
|
|
||||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||||
@@ -750,7 +779,6 @@ def auth(request: Request):
|
|||||||
fail_response.headers["location"] = "/login"
|
fail_response.headers["location"] = "/login"
|
||||||
|
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
|
||||||
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
@@ -776,20 +804,27 @@ def auth(request: Request):
|
|||||||
if "sub" not in token.claims:
|
if "sub" not in token.claims:
|
||||||
logger.debug("user not set in jwt token")
|
logger.debug("user not set in jwt token")
|
||||||
return fail_response
|
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:
|
if "exp" not in token.claims:
|
||||||
logger.debug("exp not set in jwt token")
|
logger.debug("exp not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
user = token.claims.get("sub")
|
user = token.claims.get("sub")
|
||||||
role = token.claims.get("role")
|
session = request.app.user_sessions.get(user)
|
||||||
|
if session is None:
|
||||||
|
logger.debug("jwt user %s no longer exists", user)
|
||||||
|
return fail_response
|
||||||
|
|
||||||
|
role, password_changed_at = session
|
||||||
|
if password_changed_at is not None and (
|
||||||
|
int(token.claims.get("iat", 0)) < password_changed_at
|
||||||
|
):
|
||||||
|
logger.debug("jwt token issued before password change")
|
||||||
|
return fail_response
|
||||||
|
|
||||||
# the token keeps the role it was issued with, so a role removed from
|
|
||||||
# the config since then must send the user back through login
|
|
||||||
if role not in auth_config.roles:
|
if role not in auth_config.roles:
|
||||||
logger.debug("jwt role %s is not in the config", role)
|
logger.debug(
|
||||||
|
"jwt user %s has role %s, which is not in the config", user, role
|
||||||
|
)
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
@@ -812,25 +847,6 @@ def auth(request: Request):
|
|||||||
# if the jwt cookie is expiring soon
|
# if the jwt cookie is expiring soon
|
||||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||||
|
|
||||||
# Check if password has been changed since token was issued
|
|
||||||
# If so, force re-login by rejecting the refresh
|
|
||||||
try:
|
|
||||||
user_obj = User.get_by_id(user)
|
|
||||||
if user_obj.password_changed_at is not None:
|
|
||||||
token_iat = int(token.claims.get("iat", 0))
|
|
||||||
password_changed_timestamp = int(
|
|
||||||
user_obj.password_changed_at.timestamp()
|
|
||||||
)
|
|
||||||
if token_iat < password_changed_timestamp:
|
|
||||||
logger.debug(
|
|
||||||
"jwt token issued before password change, rejecting refresh"
|
|
||||||
)
|
|
||||||
return fail_response
|
|
||||||
except DoesNotExist:
|
|
||||||
logger.debug("user not found")
|
|
||||||
return fail_response
|
|
||||||
|
|
||||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
new_encoded_jwt = create_encoded_jwt(
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
user, role, new_expiration, request.app.jwt_token
|
user, role, new_expiration, request.app.jwt_token
|
||||||
@@ -840,7 +856,7 @@ def auth(request: Request):
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
new_encoded_jwt,
|
new_encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
JWT_COOKIE_SECURE,
|
is_secure_request(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
success_response.headers["remote-user"] = user
|
success_response.headers["remote-user"] = user
|
||||||
@@ -929,7 +945,6 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
)
|
)
|
||||||
|
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
user = body.user
|
user = body.user
|
||||||
password = body.password
|
password = body.password
|
||||||
@@ -945,13 +960,16 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
|
|
||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
if verify_password(password, password_hash):
|
if verify_password(password, password_hash):
|
||||||
role = getattr(db_user, "role", "viewer")
|
role = db_user.role
|
||||||
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
if role not in request.app.frigate_config.auth.roles:
|
||||||
if role not in config_roles_set:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
"Login refused for user '%s' with role '%s', which is not in the config",
|
||||||
|
user,
|
||||||
|
role,
|
||||||
)
|
)
|
||||||
role = "viewer"
|
failed_logins.record(user, time.time(), known=True)
|
||||||
|
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||||
|
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||||
response = Response("", 200)
|
response = Response("", 200)
|
||||||
@@ -960,7 +978,7 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
encoded_jwt,
|
encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
JWT_COOKIE_SECURE,
|
is_secure_request(request),
|
||||||
)
|
)
|
||||||
# Clear admin_first_time_login flag after successful admin login so the
|
# Clear admin_first_time_login flag after successful admin login so the
|
||||||
# UI stops showing the first-time login documentation link.
|
# UI stops showing the first-time login documentation link.
|
||||||
@@ -1026,9 +1044,11 @@ def create_user(
|
|||||||
User.username: body.username,
|
User.username: body.username,
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
User.role: role,
|
User.role: role,
|
||||||
|
User.password_changed_at: datetime.now(),
|
||||||
User.notification_tokens: [],
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
|
refresh_user_session(request, body.username)
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"username": body.username})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|
||||||
@@ -1047,6 +1067,7 @@ def delete_user(request: Request, username: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.delete_by_id(username)
|
User.delete_by_id(username)
|
||||||
|
refresh_user_session(request, username)
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
@@ -1114,13 +1135,13 @@ async def update_password(
|
|||||||
User.password_changed_at: datetime.now(),
|
User.password_changed_at: datetime.now(),
|
||||||
}
|
}
|
||||||
).where(User.username == username).execute()
|
).where(User.username == username).execute()
|
||||||
|
refresh_user_session(request, username)
|
||||||
|
|
||||||
response = JSONResponse(content={"success": True})
|
response = JSONResponse(content={"success": True})
|
||||||
|
|
||||||
# If user changed their own password, issue a new JWT to keep them logged in
|
# If user changed their own password, issue a new JWT to keep them logged in
|
||||||
if current_username == username:
|
if current_username == username:
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
@@ -1133,7 +1154,7 @@ async def update_password(
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
encoded_jwt,
|
encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
JWT_COOKIE_SECURE,
|
is_secure_request(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -1173,6 +1194,7 @@ async def update_role(
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.set_by_id(username, {User.role: body.role})
|
User.set_by_id(username, {User.role: body.role})
|
||||||
|
refresh_user_session(request, username)
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ def create_fastapi_app(
|
|||||||
key_bytes = str(secret).encode("utf-8")
|
key_bytes = str(secret).encode("utf-8")
|
||||||
|
|
||||||
app.jwt_token = OctKey.import_key(key_bytes)
|
app.jwt_token = OctKey.import_key(key_bytes)
|
||||||
|
app.user_sessions = auth.load_user_sessions()
|
||||||
else:
|
else:
|
||||||
app.jwt_token = None
|
app.jwt_token = None
|
||||||
|
app.user_sessions = {}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ class FrigateApp:
|
|||||||
username="admin",
|
username="admin",
|
||||||
role="admin",
|
role="admin",
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
|
password_changed_at=datetime.datetime.now(),
|
||||||
notification_tokens=[],
|
notification_tokens=[],
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
|
|||||||
cookie_secure: bool = Field(
|
cookie_secure: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
title="Secure cookie flag",
|
title="Secure cookie flag",
|
||||||
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
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(
|
session_length: int = Field(
|
||||||
default=86400,
|
default=86400,
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
"""Tests that /auth takes the role and revocation state from the user, not the JWT."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from frigate.api.auth import create_encoded_jwt
|
from joserfc import jwt
|
||||||
|
|
||||||
|
from frigate.api.auth import create_encoded_jwt, hash_password
|
||||||
from frigate.api.fastapi_app import create_fastapi_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
from frigate.app import FrigateApp
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
from frigate.const import JWT_SECRET_ENV_VAR
|
from frigate.const import JWT_SECRET_ENV_VAR
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment, User
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
PASSWORD = "correct-horse-battery"
|
||||||
|
|
||||||
|
|
||||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||||
class TestAuthJwtRole(BaseTestHttp):
|
class TestAuthJwtRole(BaseTestHttp):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
super().setUp(models=[Event, Recordings, ReviewSegment, User])
|
||||||
self.minimal_config = {
|
self.minimal_config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||||
@@ -36,6 +43,19 @@ class TestAuthJwtRole(BaseTestHttp):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
User.delete().execute()
|
||||||
|
self._insert_user("admin", "admin")
|
||||||
|
self._insert_user("bob", "garage")
|
||||||
|
|
||||||
|
def _insert_user(self, username: str, role: str):
|
||||||
|
User.insert(
|
||||||
|
{
|
||||||
|
User.username: username,
|
||||||
|
User.role: role,
|
||||||
|
User.password_hash: hash_password(PASSWORD, iterations=1),
|
||||||
|
User.notification_tokens: [],
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
def _create_app(self):
|
def _create_app(self):
|
||||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
@@ -55,27 +75,243 @@ class TestAuthJwtRole(BaseTestHttp):
|
|||||||
enforce_default_admin=False,
|
enforce_default_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _auth(self, app, role: str):
|
def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
|
||||||
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
if iat is None:
|
||||||
|
return create_encoded_jwt(
|
||||||
with AuthTestClient(app) as client:
|
user, role, int(time.time()) + 3600, app.jwt_token
|
||||||
return client.get(
|
|
||||||
"/auth",
|
|
||||||
headers={
|
|
||||||
"x-server-port": "8971",
|
|
||||||
"authorization": f"Bearer {token}",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
{"alg": "HS256"},
|
||||||
|
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
|
||||||
|
app.jwt_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth(self, client, token: str, source: str = "bearer"):
|
||||||
|
headers = {"x-server-port": "8971"}
|
||||||
|
if source == "bearer":
|
||||||
|
headers["authorization"] = f"Bearer {token}"
|
||||||
|
else:
|
||||||
|
client.cookies.set("frigate_token", token)
|
||||||
|
|
||||||
|
resp = client.get("/auth", headers=headers)
|
||||||
|
client.cookies.clear()
|
||||||
|
return resp
|
||||||
|
|
||||||
def test_configured_role_is_accepted(self):
|
def test_configured_role_is_accepted(self):
|
||||||
resp = self._auth(self._create_app(), "garage")
|
app = self._create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = self._auth(client, self._token(app, "bob", "garage"))
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
self.assertEqual(resp.status_code, 202)
|
||||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||||
|
|
||||||
|
def test_role_claim_is_ignored(self):
|
||||||
|
app = self._create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = self._auth(client, self._token(app, "bob", "admin"))
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||||
|
|
||||||
def test_role_removed_from_config_is_rejected(self):
|
def test_role_removed_from_config_is_rejected(self):
|
||||||
resp = self._auth(self._create_app(), "removed_role")
|
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||||
|
app = self._create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
resp = self._auth(client, self._token(app, "bob", "garage"), source)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
self.assertNotIn("remote-role", resp.headers)
|
||||||
|
|
||||||
|
def test_login_refused_for_role_removed_from_config(self):
|
||||||
|
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||||
|
with AuthTestClient(self._create_app()) as client:
|
||||||
|
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 401)
|
self.assertEqual(resp.status_code, 401)
|
||||||
self.assertNotIn("remote-role", resp.headers)
|
self.assertNotIn("set-cookie", resp.headers)
|
||||||
|
|
||||||
|
def test_demoted_user_gets_new_role_on_next_request(self):
|
||||||
|
app = self._create_app()
|
||||||
|
token = self._token(app, "bob", "admin")
|
||||||
|
User.update(role="admin").where(User.username == "bob").execute()
|
||||||
|
app.user_sessions["bob"] = ("admin", None)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
|
||||||
|
resp = client.put("/users/bob/role", json={"role": "viewer"})
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
resp = self._auth(client, token, source)
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||||
|
|
||||||
|
def test_deleted_user_is_rejected(self):
|
||||||
|
app = self._create_app()
|
||||||
|
token = self._token(app, "bob", "garage")
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(self._auth(client, token).status_code, 202)
|
||||||
|
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
||||||
|
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
||||||
|
|
||||||
|
def test_recreated_user_rejects_token_from_deleted_account(self):
|
||||||
|
app = self._create_app()
|
||||||
|
token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
json={"username": "bob", "password": PASSWORD, "role": "viewer"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
||||||
|
|
||||||
|
def test_created_user_is_accepted(self):
|
||||||
|
app = self._create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
resp = self._auth(client, self._token(app, "carol", "viewer"))
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||||
|
|
||||||
|
def test_password_change_revokes_older_tokens(self):
|
||||||
|
app = self._create_app()
|
||||||
|
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(self._auth(client, old_token).status_code, 202)
|
||||||
|
resp = client.put(
|
||||||
|
"/users/bob/password",
|
||||||
|
json={"password": "a-brand-new-password"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||||
|
|
||||||
|
changed_at = app.user_sessions["bob"][1]
|
||||||
|
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
|
||||||
|
def test_login_in_the_same_second_as_password_change_is_valid(self):
|
||||||
|
app = self._create_app()
|
||||||
|
changed_at = datetime.now().replace(microsecond=500000)
|
||||||
|
new_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("frigate.api.auth.datetime") as mock_datetime,
|
||||||
|
AuthTestClient(app) as client,
|
||||||
|
):
|
||||||
|
mock_datetime.now.return_value = changed_at
|
||||||
|
resp = client.put(
|
||||||
|
"/users/bob/password",
|
||||||
|
json={"password": "a-brand-new-password"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
self.assertEqual(self._auth(client, new_token, source).status_code, 202)
|
||||||
|
|
||||||
|
def test_own_password_change_cookie_stays_valid(self):
|
||||||
|
app = self._create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/users/bob/password",
|
||||||
|
json={"password": "a-brand-new-password", "old_password": PASSWORD},
|
||||||
|
headers={"remote-user": "bob", "remote-role": "garage"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
new_token = resp.cookies["frigate_token"]
|
||||||
|
|
||||||
|
resp = self._auth(client, new_token, "cookie")
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||||
|
|
||||||
|
def test_admin_password_reset_revokes_older_tokens(self):
|
||||||
|
config = FrigateConfig(
|
||||||
|
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
|
||||||
|
)
|
||||||
|
FrigateApp.init_auth(SimpleNamespace(config=config))
|
||||||
|
|
||||||
|
app = self._create_app()
|
||||||
|
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
for source in ("bearer", "cookie"):
|
||||||
|
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||||
|
|
||||||
|
def test_auth_does_not_query_users(self):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
# expires inside the refresh window so the cookie refresh runs too
|
||||||
|
token = jwt.encode(
|
||||||
|
{"alg": "HS256"},
|
||||||
|
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
|
||||||
|
app.jwt_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(User, "select", side_effect=AssertionError("db query")),
|
||||||
|
AuthTestClient(app) as client,
|
||||||
|
):
|
||||||
|
client.cookies.set("frigate_token", token)
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertIn("frigate_token=", resp.headers["set-cookie"])
|
||||||
|
|
||||||
|
def _login_cookie(self, headers: dict | None = None) -> str:
|
||||||
|
with AuthTestClient(self._create_app()) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/login",
|
||||||
|
json={"user": "bob", "password": PASSWORD},
|
||||||
|
headers=headers or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
return resp.headers["set-cookie"].lower()
|
||||||
|
|
||||||
|
def test_login_cookie_is_secure_over_tls(self):
|
||||||
|
cookie = self._login_cookie({"x-forwarded-proto": "https"})
|
||||||
|
self.assertIn("; secure", cookie)
|
||||||
|
|
||||||
|
def test_login_cookie_is_not_secure_over_http(self):
|
||||||
|
cookie = self._login_cookie({"x-forwarded-proto": "http"})
|
||||||
|
self.assertNotIn("; secure", cookie)
|
||||||
|
|
||||||
|
def test_cookie_secure_config_forces_secure(self):
|
||||||
|
self.minimal_config["auth"]["cookie_secure"] = True
|
||||||
|
cookie = self._login_cookie()
|
||||||
|
self.assertIn("; secure", cookie)
|
||||||
|
|
||||||
|
def test_refreshed_cookie_is_secure_over_tls(self):
|
||||||
|
app = self._create_app()
|
||||||
|
token = jwt.encode(
|
||||||
|
{"alg": "HS256"},
|
||||||
|
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
|
||||||
|
app.jwt_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
client.cookies.set("frigate_token", token)
|
||||||
|
resp = client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertIn("; secure", resp.headers["set-cookie"].lower())
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class TestGo2rtcProxyAuth(BaseTestHttp):
|
|||||||
self.minimal_config = _CONFIG
|
self.minimal_config = _CONFIG
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
||||||
|
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
|
||||||
|
|
||||||
get_patch = patch(
|
get_patch = patch(
|
||||||
"frigate.api.auth.requests.get",
|
"frigate.api.auth.requests.get",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"cookie_secure": {
|
"cookie_secure": {
|
||||||
"label": "Secure cookie flag",
|
"label": "Secure cookie flag",
|
||||||
"description": "Set the secure flag on the auth cookie; should be true when using TLS."
|
"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": {
|
"session_length": {
|
||||||
"label": "Session length",
|
"label": "Session length",
|
||||||
|
|||||||
Reference in New Issue
Block a user