feat: harden platform workflows and UI governance
This commit is contained in:
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@n
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
||||
import type { SessionRequest } from './session-validation.middleware';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -38,13 +38,31 @@ export class AuthController {
|
||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
||||
}
|
||||
|
||||
@Get('auth/session')
|
||||
currentSession(@Req() request: SessionRequest) {
|
||||
@Get(['admin/auth/session', 'client/auth/session'])
|
||||
async currentSession(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
return this.sessions.publicSession(request.authSession!);
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: request.sessionUserId! },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
return {
|
||||
portal: request.authSession!.portal,
|
||||
locked: Boolean(request.authSession!.lockedAt),
|
||||
user: {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant?.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles.map((item) => item.role.code),
|
||||
},
|
||||
...this.sessions.publicSession(request.authSession!),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('auth/session/touch')
|
||||
@Post(['admin/auth/session/touch', 'client/auth/session/touch'])
|
||||
async touch(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
const result = await this.sessions.touch(request.sessionToken!);
|
||||
@@ -52,7 +70,7 @@ export class AuthController {
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/session/lock')
|
||||
@Post(['admin/auth/session/lock', 'client/auth/session/lock'])
|
||||
async lock(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
const record = await this.sessions.lock(request.sessionToken!);
|
||||
@@ -60,17 +78,17 @@ export class AuthController {
|
||||
return { locked: Boolean(record) };
|
||||
}
|
||||
|
||||
@Post('auth/session/unlock')
|
||||
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
||||
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||
this.setCookie(response, result.token);
|
||||
this.setCookie(response, result.record.portal, result.token);
|
||||
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/reauthenticate')
|
||||
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
||||
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||
@@ -79,22 +97,23 @@ export class AuthController {
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/logout')
|
||||
@Post(['admin/auth/logout', 'client/auth/logout'])
|
||||
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
||||
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
||||
this.clearCookie(response);
|
||||
this.clearCookie(response, request.authSession?.portal);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('auth/password')
|
||||
@Post(['admin/auth/password', 'client/auth/password'])
|
||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||
}
|
||||
|
||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||
this.setCookie(response, result.sessionToken);
|
||||
this.setCookie(response, result.portal, result.sessionToken);
|
||||
this.clearLegacyCookie(response);
|
||||
await this.prisma.operationLog.create({
|
||||
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
|
||||
});
|
||||
@@ -114,8 +133,8 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private setCookie(response: CookieResponse, token: string) {
|
||||
response.cookie(this.sessions.cookieName, token, {
|
||||
private setCookie(response: CookieResponse, portal: SessionPortal, token: string) {
|
||||
response.cookie(this.sessions.cookieName(portal), token, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
@@ -123,8 +142,18 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
private clearCookie(response: CookieResponse) {
|
||||
response.clearCookie(this.sessions.cookieName, {
|
||||
private clearCookie(response: CookieResponse, portal?: SessionPortal) {
|
||||
if (!portal) return;
|
||||
response.clearCookie(this.sessions.cookieName(portal), {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
private clearLegacyCookie(response: CookieResponse) {
|
||||
response.clearCookie(this.sessions.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
|
||||
@@ -6,7 +6,7 @@ const record = {
|
||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
|
||||
function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'): SessionRequest {
|
||||
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
|
||||
return {
|
||||
originalUrl: path,
|
||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
||||
@@ -14,9 +14,11 @@ function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'
|
||||
}
|
||||
|
||||
describe('SessionValidationMiddleware', () => {
|
||||
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
||||
|
||||
it('accepts an active Redis session and exposes its user and record', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
const currentRequest = request();
|
||||
const next = jest.fn();
|
||||
@@ -31,7 +33,7 @@ describe('SessionValidationMiddleware', () => {
|
||||
|
||||
it('rejects a session after the user session version changes', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
@@ -40,12 +42,34 @@ describe('SessionValidationMiddleware', () => {
|
||||
|
||||
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
const next = jest.fn();
|
||||
await middleware.use(request('/api/auth/session/unlock'), {}, next);
|
||||
await middleware.use(request('/api/admin/auth/session/unlock'), {}, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects only the cookie belonging to the requested portal', async () => {
|
||||
const clientRecord = { ...record, portal: 'client' as const };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
||||
|
||||
await middleware.use(currentRequest, {}, jest.fn());
|
||||
|
||||
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
||||
expect(currentRequest.sessionToken).toBe('client-token');
|
||||
});
|
||||
|
||||
it('does not accept an admin cookie for a client route', async () => {
|
||||
const sessions = { cookieName, validate: jest.fn(), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware({} as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request('/api/client/users', 'cmpp_admin_session=admin-token'), {}, jest.fn()))
|
||||
.rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(sessions.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthSessionRecord, DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionService } from './session.service';
|
||||
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
||||
|
||||
export type SessionRequest = {
|
||||
header(name: string): string | undefined;
|
||||
@@ -22,9 +22,10 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.readCookie(request.header('cookie'));
|
||||
const portal = this.portalForPath(path);
|
||||
const token = portal ? this.readCookie(request.header('cookie'), portal) : undefined;
|
||||
if (!token) {
|
||||
if ((path.includes('/admin/') && !path.includes('/admin/gateway/')) || path.includes('/client/') || path.includes('/auth/')) {
|
||||
if (portal && !path.includes('/admin/gateway/')) {
|
||||
throw this.unauthorized('SESSION_INVALID', '请先登录');
|
||||
}
|
||||
next();
|
||||
@@ -43,14 +44,15 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
||||
await this.sessions.remove(token);
|
||||
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
|
||||
}
|
||||
if ((path.includes('/admin/') && result.record.portal !== 'admin') || (path.includes('/client/') && result.record.portal !== 'client')) {
|
||||
if (result.record.portal !== portal) {
|
||||
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
|
||||
}
|
||||
|
||||
request.sessionUserId = user.id;
|
||||
request.sessionToken = token;
|
||||
request.authSession = result.record;
|
||||
if (result.status === 'locked' && !path.includes('/auth/session/unlock') && !path.includes('/auth/logout')) {
|
||||
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
||||
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
||||
if (result.newlyLocked) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
|
||||
@@ -61,11 +63,18 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
||||
next();
|
||||
}
|
||||
|
||||
private readCookie(cookieHeader?: string) {
|
||||
private portalForPath(path: string): SessionPortal | undefined {
|
||||
if (/\/admin\//.test(path)) return 'admin';
|
||||
if (/\/client\//.test(path)) return 'client';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private readCookie(cookieHeader: string | undefined, portal: SessionPortal) {
|
||||
if (!cookieHeader) return undefined;
|
||||
const cookieName = this.sessions.cookieName(portal);
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === SESSION_COOKIE_NAME || name === DEVELOPMENT_SESSION_COOKIE_NAME) return decodeURIComponent(value.join('='));
|
||||
if (name === cookieName) return decodeURIComponent(value.join('='));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -70,4 +70,12 @@ describe('SessionService', () => {
|
||||
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
||||
}
|
||||
});
|
||||
|
||||
it('uses separate cookie names for admin and client sessions', () => {
|
||||
process.env.SESSION_COOKIE_SECURE = 'false';
|
||||
const service = new SessionService();
|
||||
expect(service.cookieName('admin')).toBe('cmpp_admin_session');
|
||||
expect(service.cookieName('client')).toBe('cmpp_client_session');
|
||||
delete process.env.SESSION_COOKIE_SECURE;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export type SessionValidationResult =
|
||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
||||
export const CLIENT_SESSION_COOKIE_NAME = '__Host-cmpp_client_session';
|
||||
export const DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME = 'cmpp_admin_session';
|
||||
export const DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME = 'cmpp_client_session';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnModuleDestroy {
|
||||
@@ -137,8 +141,9 @@ export class SessionService implements OnModuleDestroy {
|
||||
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
|
||||
}
|
||||
|
||||
get cookieName() {
|
||||
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
|
||||
cookieName(portal: SessionPortal) {
|
||||
if (this.cookieSecure) return portal === 'admin' ? ADMIN_SESSION_COOKIE_NAME : CLIENT_SESSION_COOKIE_NAME;
|
||||
return portal === 'admin' ? DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME : DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
get absoluteTimeoutMs() {
|
||||
|
||||
Reference in New Issue
Block a user