feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
9eb8613c05
commit
556b650c7a
123 files changed
+25293
No files matched your search
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Server-side sessions behind the dashboard JWTs.
|
||||
|
||||
The tokens are stateless, so on their own signing out only deleted cookies: a
|
||||
copied access token kept working for its 30 minutes and a refresh token kept
|
||||
minting new ones for 7 days, and a password change ended nothing. Every token
|
||||
now names an auth_sessions row in its `sid` claim, and a token is only live
|
||||
while that row is.
|
||||
|
||||
Tokens without a `sid` (issued before this existed, or by the enterprise signup
|
||||
flow) are accepted until they expire, so deploying this signs nobody out.
|
||||
|
||||
None of these functions commit: the session row belongs in the caller's
|
||||
transaction, alongside whatever it is doing (a login, an org setup, a password
|
||||
change).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_token
|
||||
from app.models.auth_session import AuthSession
|
||||
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
def issue_tokens(db: Session, user_id, org_id, sid=None) -> Tuple[str, str]:
|
||||
"""Return (access_token, refresh_token) for a session.
|
||||
|
||||
Without `sid` a new session is opened (a login). With the `sid` of a live
|
||||
session the tokens are re-minted for it (a refresh), so revoking it still
|
||||
ends everything that came from the same login.
|
||||
"""
|
||||
if sid is None:
|
||||
session = AuthSession(user_id=_as_uuid(user_id))
|
||||
db.add(session)
|
||||
db.flush()
|
||||
sid = session.id
|
||||
|
||||
token_data = {"sub": str(user_id), "org": str(org_id), "sid": str(sid)}
|
||||
return create_access_token(token_data), create_refresh_token(token_data)
|
||||
|
||||
|
||||
def live_payload(db: Session, token: str, expected_type: str) -> Optional[dict]:
|
||||
"""The token's payload if it is valid, of `expected_type` and not revoked.
|
||||
|
||||
The type check keeps a 7-day refresh token from being used as an access
|
||||
token. A token without a `sid` predates sessions and is let through.
|
||||
"""
|
||||
payload = verify_token(token)
|
||||
if not payload or payload.get("type") != expected_type:
|
||||
return None
|
||||
|
||||
sid = payload.get("sid")
|
||||
if sid is None:
|
||||
return payload
|
||||
|
||||
session_id = _as_uuid(sid)
|
||||
if session_id is None:
|
||||
return None
|
||||
session = db.query(AuthSession).filter(AuthSession.id == session_id).first()
|
||||
if (
|
||||
session is None
|
||||
or session.revoked_at is not None
|
||||
or str(session.user_id) != str(payload.get("sub"))
|
||||
):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def session_id_of(token: Optional[str]) -> Optional[str]:
|
||||
"""The `sid` a correctly signed token carries, or None."""
|
||||
payload = verify_token(token) if token else None
|
||||
return payload.get("sid") if payload else None
|
||||
|
||||
|
||||
def revoke_session(db: Session, sid) -> None:
|
||||
"""End one session; both of its tokens stop working."""
|
||||
session_id = _as_uuid(sid)
|
||||
if session_id is None:
|
||||
return
|
||||
db.query(AuthSession).filter(
|
||||
AuthSession.id == session_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
).update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
|
||||
|
||||
|
||||
def revoke_user_sessions(db: Session, user_id, keep_sid=None) -> None:
|
||||
"""End every session the user has, except `keep_sid` when given."""
|
||||
query = db.query(AuthSession).filter(
|
||||
AuthSession.user_id == _as_uuid(user_id),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
keep = _as_uuid(keep_sid) if keep_sid is not None else None
|
||||
if keep is not None:
|
||||
query = query.filter(AuthSession.id != keep)
|
||||
query.update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
|
||||
|
||||
|
||||
def _as_uuid(value) -> Optional[UUID]:
|
||||
if isinstance(value, UUID):
|
||||
return value
|
||||
try:
|
||||
return UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
"""Turn the chat-scope toggles on the user form into a role.
|
||||
|
||||
Permissions hang off roles, not off people, so "let this agent read every
|
||||
conversation while their colleague is on leave" has to resolve to *some* role.
|
||||
Rather than inventing per-user grants — which would mean every permission check
|
||||
in the codebase learning about a second source of truth — the two toggles
|
||||
describe the chat scope a person should have, and this module finds the role in
|
||||
their organization that matches. A new role is created only when no existing
|
||||
one does, so the common case adds nothing to the Roles screen.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Set
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.auth import require_grantable
|
||||
from app.core.logger import get_logger
|
||||
from app.models.permission import Permission
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The unclaimed queue: sessions the AI is still handling that nobody has taken.
|
||||
AI_QUEUE_PERMISSION = "view_unassigned_chats"
|
||||
# Every conversation in the organization, including other agents' assignments.
|
||||
ALL_CHATS_PERMISSION = "view_all_chats"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatScope:
|
||||
"""What the two toggles on the user form are asking for."""
|
||||
|
||||
see_all_ai_chats: bool
|
||||
see_all_org_chats: bool
|
||||
|
||||
@classmethod
|
||||
def of(cls, role: Role) -> "ChatScope":
|
||||
"""The scope a role already grants — what the form shows on open."""
|
||||
names = {p.name for p in role.permissions}
|
||||
return cls(
|
||||
see_all_ai_chats=AI_QUEUE_PERMISSION in names,
|
||||
see_all_org_chats=ALL_CHATS_PERMISSION in names,
|
||||
)
|
||||
|
||||
def label(self) -> str:
|
||||
if self.see_all_org_chats and self.see_all_ai_chats:
|
||||
return "all chats"
|
||||
if self.see_all_org_chats:
|
||||
return "all chats, no AI queue"
|
||||
if self.see_all_ai_chats:
|
||||
return "assigned + AI queue"
|
||||
return "assigned only"
|
||||
|
||||
|
||||
def resolve_role(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
base_role: Role,
|
||||
see_all_ai_chats: Optional[bool],
|
||||
see_all_org_chats: Optional[bool],
|
||||
) -> Role:
|
||||
"""The role that grants `base_role` plus/minus the requested chat scope.
|
||||
|
||||
Either toggle may be None, meaning "whatever the role already does" — an
|
||||
API client that has never heard of these fields gets exactly the old
|
||||
behaviour.
|
||||
"""
|
||||
current = ChatScope.of(base_role)
|
||||
wanted = ChatScope(
|
||||
see_all_ai_chats=(
|
||||
current.see_all_ai_chats if see_all_ai_chats is None else see_all_ai_chats
|
||||
),
|
||||
see_all_org_chats=(
|
||||
current.see_all_org_chats if see_all_org_chats is None else see_all_org_chats
|
||||
),
|
||||
)
|
||||
if wanted == current:
|
||||
return base_role
|
||||
|
||||
held = {p.name for p in base_role.permissions}
|
||||
# super_admin already passes every check, so widening it would create a
|
||||
# near-duplicate role that grants nothing new.
|
||||
if "super_admin" in held:
|
||||
return base_role
|
||||
|
||||
desired = set(held)
|
||||
for granted, permission in (
|
||||
(wanted.see_all_ai_chats, AI_QUEUE_PERMISSION),
|
||||
(wanted.see_all_org_chats, ALL_CHATS_PERMISSION),
|
||||
):
|
||||
if granted:
|
||||
desired.add(permission)
|
||||
else:
|
||||
desired.discard(permission)
|
||||
|
||||
# Same rule as the Roles editor: an admin cannot hand out what they do not
|
||||
# hold themselves. Only the added permissions are checked, not the base
|
||||
# role's own — someone with manage_users can already assign any role in the
|
||||
# org as it stands, so carrying its permissions across grants nothing new.
|
||||
require_grantable(current_user, desired - held)
|
||||
|
||||
existing = _role_granting(db, base_role.organization_id, desired)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return _create_scoped_role(db, base_role, desired, wanted)
|
||||
|
||||
|
||||
#: Every label label() can produce, so a name can be stripped back down again.
|
||||
SCOPE_LABELS = tuple(
|
||||
ChatScope(ai, org).label() for ai in (True, False) for org in (True, False)
|
||||
)
|
||||
_SCOPE_SUFFIX = re.compile(
|
||||
r" \((?:%s)\)(?: \d+)?$" % "|".join(re.escape(label) for label in SCOPE_LABELS)
|
||||
)
|
||||
|
||||
|
||||
def _unscoped(name: str) -> str:
|
||||
"""`Agent (all chats)` -> `Agent`.
|
||||
|
||||
Re-scoping someone who is already on a derived role would otherwise stack
|
||||
suffixes: "Agent (all chats) (assigned only)".
|
||||
"""
|
||||
return _SCOPE_SUFFIX.sub("", name)
|
||||
|
||||
|
||||
def _role_granting(db: Session, organization_id: UUID, desired: Set[str]) -> Optional[Role]:
|
||||
"""An existing role in the org whose permissions are exactly `desired`.
|
||||
|
||||
Exact, not superset: a role that also grants manage_users would hand the
|
||||
person far more than the toggle asked for.
|
||||
"""
|
||||
roles = (
|
||||
db.query(Role)
|
||||
.options(joinedload(Role.permissions))
|
||||
.filter(Role.organization_id == organization_id)
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if {p.name for p in role.permissions} == desired:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def _create_scoped_role(
|
||||
db: Session, base_role: Role, desired: Set[str], scope: ChatScope
|
||||
) -> Role:
|
||||
role = Role(
|
||||
name=_available_name(db, base_role, scope),
|
||||
description=f"{_unscoped(base_role.name)}, scoped to {scope.label()}",
|
||||
organization_id=base_role.organization_id,
|
||||
# Never the org default. get_default_role() picks the default for
|
||||
# invited users, and a scope chosen for one person is not that.
|
||||
is_default=False,
|
||||
)
|
||||
role.permissions = (
|
||||
db.query(Permission).filter(Permission.name.in_(desired)).all()
|
||||
)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
logger.info(
|
||||
"Created chat-scope role %r (%s) for organization %s",
|
||||
role.name, scope.label(), base_role.organization_id,
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
def _available_name(db: Session, base_role: Role, scope: ChatScope) -> str:
|
||||
"""`Agent (all chats)`, or the first free numbered variant of it.
|
||||
|
||||
Role names are not unique in the schema, but two roles sharing a name in
|
||||
one org is confusing in every screen that lists them.
|
||||
"""
|
||||
taken = {
|
||||
name for (name,) in db.query(Role.name).filter(
|
||||
Role.organization_id == base_role.organization_id
|
||||
)
|
||||
}
|
||||
preferred = f"{_unscoped(base_role.name)} ({scope.label()})"
|
||||
if preferred not in taken:
|
||||
return preferred
|
||||
suffix = 2
|
||||
while f"{preferred} {suffix}" in taken:
|
||||
suffix += 1
|
||||
return f"{preferred} {suffix}"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.repositories.fcm_token import FCMTokenRepository
|
||||
from app.core.logger import get_logger
|
||||
from firebase_admin import messaging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Errors that identify the *token* as dead rather than the send as failed: the
|
||||
# browser revoked the subscription or cleared site data (Unregistered), or the
|
||||
# token belongs to a different Firebase project (SenderIdMismatch). Retrying
|
||||
# these never succeeds, so the row is pruned and the browser re-registers on
|
||||
# its next load.
|
||||
#
|
||||
# InvalidArgumentError is deliberately NOT here. FCM also returns it for a bad
|
||||
# *message* — an oversized data payload, say — which fails identically for
|
||||
# every token in the batch and would prune every device the user owns.
|
||||
DEAD_TOKEN_ERRORS = (
|
||||
messaging.UnregisteredError,
|
||||
messaging.SenderIdMismatchError,
|
||||
)
|
||||
|
||||
# send_each rejects a longer list outright. Chunking keeps an account that has
|
||||
# somehow accumulated more devices than this from raising before a single
|
||||
# message is sent — which would also stop the dead-token pruning below from
|
||||
# ever running, leaving that user permanently without push.
|
||||
FCM_SEND_BATCH_SIZE = 500
|
||||
|
||||
|
||||
async def send_fcm_notification(user_id: str, notification: Notification, db):
|
||||
"""Push a notification to every browser the user has signed in on."""
|
||||
try:
|
||||
token_repo = FCMTokenRepository(db)
|
||||
tokens = token_repo.get_tokens(user_id)
|
||||
if not tokens:
|
||||
# Logged, not silent: an account with notifications enabled but no
|
||||
# registered device looks identical to a broken push pipeline from
|
||||
# the outside, and used to leave no trace at all.
|
||||
logger.info(
|
||||
f"No FCM tokens registered for user {user_id}; "
|
||||
f"skipping push for notification {notification.id}"
|
||||
)
|
||||
return
|
||||
|
||||
metadata = notification.notification_metadata or {}
|
||||
|
||||
# Data-only FCM message (no messaging.Notification): with a notification
|
||||
# payload the Firebase web SDK auto-displays its own copy in the service
|
||||
# worker AND swallows clicks on it, so the app's tagged, deep-linking
|
||||
# notification would appear as a duplicate. The service worker renders
|
||||
# the notification itself from this data. session_id is flattened out of
|
||||
# the metadata so a click can deep-link to /conversations?session=<id>;
|
||||
# metadata is JSON (str() produced a Python repr the frontend could
|
||||
# never parse).
|
||||
data = {
|
||||
'title': notification.title or '',
|
||||
'body': notification.message or '',
|
||||
'type': notification.type,
|
||||
'notification_id': str(notification.id),
|
||||
'session_id': str(metadata.get('session_id') or ''),
|
||||
'metadata': json.dumps(metadata, default=str)
|
||||
}
|
||||
|
||||
messages = [
|
||||
messaging.Message(
|
||||
data=data,
|
||||
token=token,
|
||||
# These are web-push tokens, so the priority knob is the WebPush
|
||||
# `Urgency` header — NOT AndroidConfig/APNSConfig, which only
|
||||
# apply to native FCM SDK tokens and are ignored for web push.
|
||||
# High urgency asks the push service to deliver promptly even
|
||||
# while the device is idle / in battery-saver instead of
|
||||
# batching it; TTL keeps it deliverable for a day if the device
|
||||
# is offline.
|
||||
webpush=messaging.WebpushConfig(
|
||||
headers={'Urgency': 'high', 'TTL': '86400'},
|
||||
),
|
||||
)
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# send_each reports per-token outcomes, so one dead device does not
|
||||
# stop the others from being delivered to.
|
||||
dead_tokens = []
|
||||
delivered = 0
|
||||
for start in range(0, len(messages), FCM_SEND_BATCH_SIZE):
|
||||
chunk = messages[start:start + FCM_SEND_BATCH_SIZE]
|
||||
batch = messaging.send_each(chunk)
|
||||
delivered += batch.success_count
|
||||
|
||||
for token, result in zip(tokens[start:start + FCM_SEND_BATCH_SIZE],
|
||||
batch.responses):
|
||||
if result.success:
|
||||
continue
|
||||
if isinstance(result.exception, DEAD_TOKEN_ERRORS):
|
||||
dead_tokens.append(token)
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to send FCM notification {notification.id} to a device "
|
||||
f"of user {user_id}: {str(result.exception)}"
|
||||
)
|
||||
|
||||
if dead_tokens:
|
||||
removed = token_repo.remove_tokens(dead_tokens)
|
||||
logger.info(
|
||||
f"Pruned {removed} dead FCM token(s) for user {user_id}")
|
||||
|
||||
logger.info(
|
||||
f"Sent FCM notification {notification.id} to {delivered}/"
|
||||
f"{len(tokens)} device(s) of user {user_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM notification: {str(e)}")
|
||||
Reference in new issue
Block a user