feat(auth): 后台登录、角色权限与会话访问策略

管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
WangShen007 committed 2026-09-24 21:35:00 +08:00
1 parent 9eb8613c05
commit 556b650c7a
123 files changed
+25293

No files matched your search

+124
View File
@@ -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
+204
View File
@@ -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}"
+131
View File
@@ -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)}")