Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ import { NexusToken } from './modules/nexus/entities/nexus-token.entity';
import { NexusBuild } from './modules/nexus/entities/nexus-build.entity';
import { UserGroupModule } from './modules/user-group/user-group.module';
import { UserGroup } from './modules/user-group/entities/user-group.entity';
import { RbacModule } from './modules/rbac/rbac.module';
import { Role } from './modules/rbac/entities/role.entity';
import { RolePermission } from './modules/rbac/entities/role-permission.entity';
import { UserRoleAssignment } from './modules/rbac/entities/user-role-assignment.entity';
import { UserRoleAssignmentDeviceGroup } from './modules/rbac/entities/user-role-assignment-device-group.entity';
import { ConsoleAudit } from './modules/rbac/entities/console-audit.entity';
import { RbacGuard } from './modules/rbac/guards/rbac.guard';

/**
* 应用根模块
Expand Down Expand Up @@ -112,6 +119,11 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity';
NexusToken,
NexusBuild,
UserGroup,
Role,
RolePermission,
UserRoleAssignment,
UserRoleAssignmentDeviceGroup,
ConsoleAudit,
],
synchronize: true,
logging: false,
Expand All @@ -132,6 +144,7 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity';
UpdateCheckModule,
NexusModule,
UserGroupModule,
RbacModule,
],
providers: [
{
Expand All @@ -142,6 +155,10 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity';
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RbacGuard,
},
],
})
export class AppModule {}
23 changes: 19 additions & 4 deletions src/common/guards/admin.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { User, UserStatus } from '../../modules/user/entities/user.entity';

@Injectable()
/**
Expand All @@ -14,20 +16,33 @@ import {
* 只有管理员才能访问的路由会使用此守卫
*
* 验证逻辑:
* 检查用户信息中的isAdmin字段
* 读取数据库中的当前用户状态和 isAdmin 字段,不信任 JWT 内的旧权限状态
*/
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
constructor(private readonly dataSource: DataSource) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<{ user?: { isAdmin?: boolean } }>();
.getRequest<{ user?: { id?: string } }>();
const user = request.user;

if (!user) {
throw new ForbiddenException('请先登录');
}

if (!user.isAdmin) {
if (!user.id) {
throw new ForbiddenException('授权服务不可用');
}

const currentUser = await this.dataSource.getRepository(User).findOne({
where: { guid: user.id },
select: ['guid', 'isAdmin', 'status'],
});
const isAdmin =
currentUser?.isAdmin === true && currentUser.status === UserStatus.ACTIVE;

if (!isAdmin) {
throw new ForbiddenException('无权限访问,需要管理员权限');
}

Expand Down
2 changes: 2 additions & 0 deletions src/common/interfaces/login-response.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface LoginResponse {
passkey_options?: PublicKeyCredentialRequestOptionsJSON;
/** 用户信息 */
user?: {
/** Stable database identifier */
guid: string;
/** 用户名 */
name: string;
/** 显示名称 */
Expand Down
73 changes: 73 additions & 0 deletions src/database/database-init.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'reflect-metadata';
import { DatabaseInitService } from './database-init.service';

jest.mock('uuid', () => {
const cryptoModule =
jest.requireActual<typeof import('node:crypto')>('node:crypto');
return { v4: cryptoModule.randomUUID };
});

describe('DatabaseInitService owner startup guard', () => {
function createService() {
const userRepository = {
count: jest.fn(),
};
const dataSource = {
query: jest.fn().mockResolvedValue(undefined),
};
const service = new DatabaseInitService(
userRepository as never,
undefined as never,
undefined as never,
{
initializeStorage: jest.fn().mockResolvedValue({ guid: 'default' }),
} as never,
dataSource as never,
);
const internals = service as unknown as {
createDefaultAdmin: jest.Mock;
createDefaultOidcProviders: jest.Mock;
cleanupExpiredAuthStates: jest.Mock;
};
const createDefaultAdmin = (internals.createDefaultAdmin = jest.fn());
const createDefaultOidcProviders = (internals.createDefaultOidcProviders =
jest.fn());
const cleanupExpiredAuthStates = (internals.cleanupExpiredAuthStates =
jest.fn());
return {
service,
userRepository,
dataSource,
createDefaultAdmin,
createDefaultOidcProviders,
cleanupExpiredAuthStates,
};
}

it.each([0, 1])('continues startup with %i owner(s)', async (owners) => {
const context = createService();
context.userRepository.count.mockResolvedValue(owners);

await context.service.onModuleInit();

expect(context.createDefaultAdmin).toHaveBeenCalledWith('default');
expect(context.dataSource.query).toHaveBeenCalledWith(
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_users_single_owner ON users (isAdmin) WHERE isAdmin = 1',
);
expect(context.createDefaultOidcProviders).toHaveBeenCalledTimes(1);
expect(context.cleanupExpiredAuthStates).toHaveBeenCalledTimes(1);
});

it('rejects startup when legacy data contains multiple owners', async () => {
const context = createService();
context.userRepository.count.mockResolvedValue(2);

await expect(context.service.onModuleInit()).rejects.toThrow(
'Database contains 2 system owners',
);
expect(context.createDefaultAdmin).not.toHaveBeenCalled();
expect(context.dataSource.query).not.toHaveBeenCalled();
expect(context.createDefaultOidcProviders).not.toHaveBeenCalled();
expect(context.cleanupExpiredAuthStates).not.toHaveBeenCalled();
});
});
45 changes: 43 additions & 2 deletions src/database/database-init.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, QueryFailedError, Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { User, UserStatus } from '../modules/user/entities/user.entity';
Expand All @@ -27,10 +27,39 @@ export class DatabaseInitService implements OnModuleInit {
@InjectRepository(OidcAuthState)
private oidcAuthStateRepository: Repository<OidcAuthState>,
private readonly userGroupService: UserGroupService,
private readonly dataSource: DataSource,
) {}

async onModuleInit() {
const defaultGroup = await this.userGroupService.initializeStorage();
const owners = await this.userRepository.count({
where: { isAdmin: true },
});
if (owners > 1) {
throw new Error(
`Database contains ${owners} system owners; resolve the duplicate isAdmin rows offline before starting the server`,
);
}
// The partial unique index is the database-level owner boundary. Creating
// it after the explicit legacy check keeps duplicate historical owners
// readable and reports them with the actionable error above.
try {
await this.dataSource.query(
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_users_single_owner ON users (isAdmin) WHERE isAdmin = 1',
);
} catch (error: unknown) {
if (error instanceof QueryFailedError) {
const currentOwners = await this.userRepository.count({
where: { isAdmin: true },
});
if (currentOwners > 1) {
throw new Error(
`Database contains ${currentOwners} system owners; resolve the duplicate isAdmin rows offline before starting the server`,
);
}
}
throw error;
}
await this.createDefaultAdmin(defaultGroup.guid);
await this.createDefaultOidcProviders();
await this.cleanupExpiredAuthStates();
Expand Down Expand Up @@ -69,7 +98,19 @@ export class DatabaseInitService implements OnModuleInit {
userGroupGuid: defaultGroupGuid,
});

await this.userRepository.save(admin);
try {
await this.userRepository.save(admin);
} catch (error: unknown) {
// Another process may have won the empty-database race after the
// unique index was installed. Treat that loser as an idempotent start.
if (error instanceof QueryFailedError) {
const owner = await this.userRepository.findOne({
where: { isAdmin: true },
});
if (owner) return;
}
throw error;
}
this.logger.log(`Default admin user created: ${adminUsername}`);
this.logger.warn(
`Please change the default password for user: ${adminUsername}`,
Expand Down
38 changes: 30 additions & 8 deletions src/modules/address-book/address-book.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AddressBookService } from './services';
import {
Expand All @@ -31,7 +30,7 @@ import {
} from './dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AddressBookRuleService } from './services/address-book-rule.service';
import { AdminGuard } from '../../common/guards/admin.guard';
import { RequirePermission } from '../rbac/decorators/require-permission.decorator';

/**
* 地址簿控制器
Expand Down Expand Up @@ -251,6 +250,28 @@ export class AddressBookController {
);
}

@Get('shared/:guid/access')
@HttpCode(HttpStatus.OK)
getWebSharedAddressBook(
@Param('guid') guid: string,
@CurrentUser('id') userId: number,
) {
return this.addressBookService.getWebSharedAddressBook(
guid,
String(userId),
);
}

@Get('shared/:guid/share-candidates')
@RequirePermission('address_books.share')
@HttpCode(HttpStatus.OK)
getShareCandidates(
@Param('guid') guid: string,
@CurrentUser('id') userId: number,
) {
return this.ruleService.getShareCandidates(guid, String(userId));
}

/**
* 添加共享地址簿
* 创建一个新的共享地址簿
Expand All @@ -260,7 +281,7 @@ export class AddressBookController {
* @returns 操作结果
*/
@Post('shared/add')
@UseGuards(AdminGuard)
@RequirePermission('address_books.share')
@HttpCode(HttpStatus.OK)
async addSharedAddressBook(
@Body() dto: CreateAddressBookProfileDto,
Expand Down Expand Up @@ -288,7 +309,7 @@ export class AddressBookController {
* @returns 操作结果
*/
@Put('shared/update/profile')
@UseGuards(AdminGuard)
@RequirePermission('address_books.edit')
@HttpCode(HttpStatus.OK)
async updateSharedAddressBook(
@Body() dto: UpdateAddressBookProfileDto,
Expand Down Expand Up @@ -318,7 +339,7 @@ export class AddressBookController {
* @returns 操作结果
*/
@Delete('shared')
@UseGuards(AdminGuard)
@RequirePermission('address_books.edit')
@HttpCode(HttpStatus.OK)
async deleteSharedAddressBooks(
@Body() guids: string[],
Expand Down Expand Up @@ -573,6 +594,7 @@ export class AddressBookController {
* @returns 规则列表(分页)
*/
@Get('rules')
@RequirePermission('address_books.view')
@HttpCode(HttpStatus.OK)
async getRules(
@Query() query: RuleQueryDto,
Expand All @@ -590,7 +612,7 @@ export class AddressBookController {
* @returns 新创建的规则 GUID
*/
@Post('rule')
@UseGuards(AdminGuard)
@RequirePermission('address_books.share')
@HttpCode(HttpStatus.OK)
async addRule(@Body() dto: CreateRuleDto, @CurrentUser('id') userId: number) {
return this.ruleService.createRule(dto, String(userId));
Expand All @@ -605,7 +627,7 @@ export class AddressBookController {
* @returns 更新成功消息
*/
@Patch('rule')
@UseGuards(AdminGuard)
@RequirePermission('address_books.share')
@HttpCode(HttpStatus.OK)
async updateRule(
@Body() dto: UpdateRuleDto,
Expand All @@ -623,7 +645,7 @@ export class AddressBookController {
* @returns 删除成功消息
*/
@Delete('rules')
@UseGuards(AdminGuard)
@RequirePermission('address_books.share')
@HttpCode(HttpStatus.OK)
async deleteRules(
@Body() ruleGuids: string[],
Expand Down
2 changes: 2 additions & 0 deletions src/modules/address-book/address-book.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { Sysinfo, Peer } from '../../common/entities';
import { User } from '../user/entities/user.entity';
import { UserGroupModule } from '../user-group/user-group.module';
import { UserGroup } from '../user-group/entities/user-group.entity';

/**
* 地址簿模块
Expand Down Expand Up @@ -49,6 +50,7 @@ import { UserGroupModule } from '../user-group/user-group.module';
Sysinfo,
Peer,
User,
UserGroup,
]),
UserGroupModule,
],
Expand Down
Loading