Enforce trading authorization and suitability checks
This commit is contained in:
@@ -30,13 +30,20 @@ from app.api.dependencies.database import get_session
|
||||
from app.api.schemas.trading import OrderCreateRequest
|
||||
from app.api.views.envelope import envelope, list_envelope
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
from app.service.suitability_service import SuitabilityService
|
||||
from app.service.trade_service import TradeService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users/me", tags=["trading"])
|
||||
|
||||
|
||||
def _service(session: AsyncSession, context: RequestContext) -> TradeService:
|
||||
return TradeService(session)
|
||||
return TradeService(session, suitability_evaluator=SuitabilityService())
|
||||
|
||||
|
||||
async def _authorize(context: RequestContext, permission: str) -> None:
|
||||
"""Enforce the endpoint permission declared in docs/05 before DB work."""
|
||||
await AuthorizationService.require(context, permission)
|
||||
|
||||
|
||||
# T001 账户看板
|
||||
@@ -45,6 +52,7 @@ async def get_account_dashboard(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "account:read:self")
|
||||
data = await _service(session, context).get_account_dashboard(context)
|
||||
return envelope(data, context)
|
||||
|
||||
@@ -56,6 +64,7 @@ async def submit_order(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:order:create")
|
||||
data = await _service(session, context).submit_order(payload, context)
|
||||
return envelope(data, context)
|
||||
|
||||
@@ -68,6 +77,7 @@ async def list_orders(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:order:read")
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
items, next_cursor = await _service(session, context).list_orders(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
@@ -85,6 +95,7 @@ async def get_order(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:order:read")
|
||||
data = await _service(session, context).get_order(order_no, context)
|
||||
return envelope(data, context)
|
||||
|
||||
@@ -96,6 +107,7 @@ async def cancel_order(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:order:cancel")
|
||||
order = await _service(session, context).cancel_order(order_no, context)
|
||||
return envelope(order, context)
|
||||
|
||||
@@ -106,6 +118,7 @@ async def list_holdings(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "holding:read:self")
|
||||
data = await _service(session, context).list_holdings(context)
|
||||
return envelope(data, context)
|
||||
|
||||
@@ -118,6 +131,7 @@ async def list_transactions(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:txn:read")
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
data = await _service(session, context).list_transactions(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
@@ -132,6 +146,7 @@ async def get_transaction(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "trade:txn:read")
|
||||
item = await _service(session, context).get_transaction(txn_no, context)
|
||||
return envelope(item, context)
|
||||
|
||||
@@ -144,8 +159,9 @@ async def list_cash_ledger(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
await _authorize(context, "account:read:self")
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
data = await _service(session, context).list_cash_ledger(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
)
|
||||
return envelope(data, context)
|
||||
return envelope(data, context)
|
||||
|
||||
@@ -67,7 +67,7 @@ from app.model.fund import (
|
||||
FundSimOrder,
|
||||
FundTransaction,
|
||||
)
|
||||
from app.service.suitability_service import SuitabilityToolInput
|
||||
from app.service.suitability_service import SuitabilityService, SuitabilityToolInput
|
||||
|
||||
TWO_PLACES = Decimal("0.01")
|
||||
FOUR_PLACES = Decimal("0.0001")
|
||||
@@ -104,7 +104,9 @@ class TradeService:
|
||||
suitability_evaluator: object | None = None,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._suitability_evaluator = suitability_evaluator
|
||||
# Every real trade must pass the shared suitability service. Tests and
|
||||
# explicit callers may still inject a compatible evaluator.
|
||||
self._suitability_evaluator = suitability_evaluator or SuitabilityService()
|
||||
|
||||
async def _next_id(self, model: Any) -> int:
|
||||
"""返回 ``model`` 表的下一个可用主键。
|
||||
@@ -182,29 +184,34 @@ class TradeService:
|
||||
)
|
||||
return product
|
||||
|
||||
async def _check_suitability(self, customer_id: int, product: FundProduct) -> None:
|
||||
"""首版:若注入了 `SuitabilityService.evaluate` 则按其决策判定。"""
|
||||
if self._suitability_evaluator is None:
|
||||
return
|
||||
async def _check_suitability(
|
||||
self, customer_id: int, product: FundProduct, context: RequestContext
|
||||
) -> None:
|
||||
"""Apply the authoritative suitability decision before an order.
|
||||
|
||||
A missing/misconfigured evaluator is a server configuration error and
|
||||
must not silently turn into an approval. The shared service also loads
|
||||
the customer's risk assessment from the database rather than trusting
|
||||
client supplied risk fields.
|
||||
"""
|
||||
evaluate = getattr(self._suitability_evaluator, "evaluate", None)
|
||||
if evaluate is None or not callable(evaluate):
|
||||
return
|
||||
raise SuitabilityMismatchError("适当性服务不可用,交易已拒绝")
|
||||
try:
|
||||
risk_level_int = int(product.risk_level.lstrip("Rr"))
|
||||
except (AttributeError, ValueError):
|
||||
return
|
||||
try:
|
||||
decision = await evaluate(
|
||||
SuitabilityToolInput(
|
||||
customer_id=str(customer_id),
|
||||
product_risk_level=risk_level_int,
|
||||
product_requires_disclosure=bool(product.risk_disclosure_required),
|
||||
requires_confirmation=bool(product.second_confirmation_required),
|
||||
),
|
||||
context=None,
|
||||
raise SuitabilityMismatchError(
|
||||
f"产品 {getattr(product, 'product_code', '')} 风险等级无效,交易已拒绝"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
decision = await evaluate(
|
||||
SuitabilityToolInput(
|
||||
customer_id=str(customer_id),
|
||||
product_risk_level=risk_level_int,
|
||||
product_requires_disclosure=bool(product.risk_disclosure_required),
|
||||
requires_confirmation=bool(product.second_confirmation_required),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
if not getattr(decision, "allowed", True):
|
||||
reason = getattr(decision, "reason_code", "unspecified")
|
||||
raise SuitabilityMismatchError(
|
||||
@@ -285,7 +292,7 @@ class TradeService:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
product = await self._load_tradable_product(payload.product_code)
|
||||
await self._check_suitability(customer_id, product)
|
||||
await self._check_suitability(customer_id, product, context)
|
||||
quote = await self._fetch_quote(product)
|
||||
account = await self._load_account(customer_id)
|
||||
holding = await self._load_holding(customer_id, product.id)
|
||||
|
||||
@@ -117,7 +117,10 @@ async function request(endpointId, options = {}) {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && endpoint.auth !== false) clearAuthSession();
|
||||
if (response.status === 401 && endpoint.auth !== false) {
|
||||
clearAuthSession({ eventType: 'session-expired' });
|
||||
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
|
||||
}
|
||||
const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId;
|
||||
document.documentElement.dataset.traceId = responseTraceId;
|
||||
if (!response.ok || payload.error) {
|
||||
@@ -178,6 +181,10 @@ async function stream(endpointId, body, options = {}) {
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && endpoint.auth !== false) {
|
||||
clearAuthSession({ eventType: 'session-expired' });
|
||||
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
|
||||
}
|
||||
const detail = payload.error || {};
|
||||
throw new ApiError(detail.message || '流式请求未完成', {
|
||||
code: detail.code,
|
||||
|
||||
@@ -4,7 +4,9 @@ const SESSION_KEY = 'portalAuthContext';
|
||||
const IDENTITY_COOKIE = 'portal_auth_user';
|
||||
const CONTEXT_COOKIE = 'portal_auth_context';
|
||||
const AUTH_CHANNEL_NAME = 'portal-auth-session';
|
||||
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched']);
|
||||
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']);
|
||||
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const SESSION_CHECK_INTERVAL_MS = 15 * 1000;
|
||||
let authChannel = null;
|
||||
let authSyncStarted = false;
|
||||
|
||||
@@ -38,13 +40,59 @@ function writeSharedContext(context, maxAge) {
|
||||
}
|
||||
|
||||
function hasMatchingIdentity(token, context) {
|
||||
const expiresAt = tokenExpiry(token);
|
||||
return Boolean(
|
||||
token
|
||||
&& context
|
||||
&& readCookie(IDENTITY_COOKIE) === String(context.userId),
|
||||
&& readCookie(IDENTITY_COOKIE) === String(context.userId)
|
||||
&& (!expiresAt || Date.now() < expiresAt),
|
||||
);
|
||||
}
|
||||
|
||||
function tokenExpiry(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return 0;
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
||||
const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join('')));
|
||||
return Number(value.exp || 0) * 1000;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionExpired() {
|
||||
clearAuthSession({ eventType: 'session-expired' });
|
||||
if (isProtectedPortalPage()) {
|
||||
window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSessionLifetime() {
|
||||
const token = readCookie('auth_token');
|
||||
const context = readSharedContext() || readSessionContext();
|
||||
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
||||
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
||||
const lastActivityAt = Number(context.lastActivityAt || Date.now());
|
||||
if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) {
|
||||
sessionExpired();
|
||||
}
|
||||
}
|
||||
|
||||
function touchActivity() {
|
||||
const token = readCookie('auth_token');
|
||||
const context = readSharedContext() || readSessionContext();
|
||||
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
||||
const now = Date.now();
|
||||
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
||||
if (expiresAt && now >= expiresAt) { sessionExpired(); return; }
|
||||
if (now - Number(context.lastActivityAt || 0) < 60_000) return;
|
||||
const next = { ...context, lastActivityAt: now, expiresAt };
|
||||
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800;
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
||||
writeSharedContext(next, remaining);
|
||||
}
|
||||
|
||||
function openAuthChannel() {
|
||||
if (!('BroadcastChannel' in window)) return null;
|
||||
if (authChannel) return authChannel;
|
||||
@@ -80,7 +128,8 @@ function redirectForExternalAuthChange(type) {
|
||||
window.location.replace('/portal/guest/home/?reason=signed-out');
|
||||
return;
|
||||
}
|
||||
window.location.replace(`${loginPathForCurrentPortal()}?reason=account-switched`);
|
||||
const reason = type === 'session-expired' ? 'session-expired' : 'account-switched';
|
||||
window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`);
|
||||
}
|
||||
|
||||
function handleExternalAuthChange(type) {
|
||||
@@ -108,6 +157,11 @@ export function startAuthSync() {
|
||||
});
|
||||
window.addEventListener('focus', reconcileSharedSession);
|
||||
window.addEventListener('pageshow', reconcileSharedSession);
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => {
|
||||
window.addEventListener(eventName, touchActivity, { passive: true });
|
||||
});
|
||||
window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS);
|
||||
checkSessionLifetime();
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
@@ -130,6 +184,8 @@ export function setAuthSession(loginData, username) {
|
||||
roles: Array.isArray(loginData.roles) ? loginData.roles : [],
|
||||
dataScope: loginData.data_scope || 'self',
|
||||
permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [],
|
||||
expiresAt: Date.now() + maxAge * 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
};
|
||||
document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`;
|
||||
writeSharedContext(context, maxAge);
|
||||
@@ -190,8 +246,12 @@ export function updateAuthPermissions(permissions, dataScope) {
|
||||
permissions: Array.isArray(permissions) ? [...permissions] : [],
|
||||
dataScope: dataScope || context.dataScope,
|
||||
};
|
||||
const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token')));
|
||||
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800;
|
||||
next.expiresAt = expiresAt;
|
||||
next.lastActivityAt = Date.now();
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
||||
writeSharedContext(next, 1800);
|
||||
writeSharedContext(next, remaining);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ export function mountShell({ active, mode = 'public' }) {
|
||||
networkStatus.textContent = detail.message || '请求未完成';
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
});
|
||||
window.addEventListener('portal:auth-expired', () => {
|
||||
networkStatus.textContent = '登录已过期,请重新登录';
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
});
|
||||
if (new URLSearchParams(window.location.search).get('reason') === 'signed-out') {
|
||||
networkStatus.textContent = '您已安全退出登录';
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
const REASON_MESSAGES = Object.freeze({
|
||||
'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。',
|
||||
'signed-out': '您已安全退出登录。',
|
||||
'session-expired': '登录已过期或长时间未操作,请重新登录。',
|
||||
});
|
||||
|
||||
export function setupLogin({ mode }) {
|
||||
|
||||
Reference in New Issue
Block a user