feat: AES-256-GCM encryption service + auth module (JWT, guards, seed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5a6c376821
commit
3b9a0118b5
22
backend/prisma/seed.ts
Normal file
22
backend/prisma/seed.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const hash = await bcrypt.hash('wraith', 10);
|
||||
await prisma.user.upsert({
|
||||
where: { email: 'admin@wraith.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@wraith.local',
|
||||
passwordHash: hash,
|
||||
displayName: 'Admin',
|
||||
},
|
||||
});
|
||||
console.log('Seed complete: admin@wraith.local / wraith');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
20
backend/src/auth/auth.controller.ts
Normal file
20
backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('profile')
|
||||
getProfile(@Request() req: any) {
|
||||
return this.auth.getProfile(req.user.sub);
|
||||
}
|
||||
}
|
||||
21
backend/src/auth/auth.module.ts
Normal file
21
backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { WsAuthGuard } from './ws-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, WsAuthGuard],
|
||||
controllers: [AuthController],
|
||||
exports: [WsAuthGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
32
backend/src/auth/auth.service.ts
Normal file
32
backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
const payload = { sub: user.id, email: user.email };
|
||||
return {
|
||||
access_token: this.jwt.sign(payload),
|
||||
user: { id: user.id, email: user.email, displayName: user.displayName },
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return { id: user.id, email: user.email, displayName: user.displayName };
|
||||
}
|
||||
}
|
||||
10
backend/src/auth/dto/login.dto.ts
Normal file
10
backend/src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
}
|
||||
5
backend/src/auth/jwt-auth.guard.ts
Normal file
5
backend/src/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
18
backend/src/auth/jwt.strategy.ts
Normal file
18
backend/src/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: { sub: number; email: string }) {
|
||||
return { sub: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
19
backend/src/auth/ws-auth.guard.ts
Normal file
19
backend/src/auth/ws-auth.guard.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
|
||||
@Injectable()
|
||||
export class WsAuthGuard {
|
||||
constructor(private jwt: JwtService) {}
|
||||
|
||||
validateClient(client: any): { sub: number; email: string } | null {
|
||||
try {
|
||||
const url = new URL(client.url || client._url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) throw new WsException('No token');
|
||||
return this.jwt.verify(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/src/vault/encryption.service.ts
Normal file
41
backend/src/vault/encryption.service.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor() {
|
||||
const hex = process.env.ENCRYPTION_KEY;
|
||||
if (!hex || hex.length < 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)');
|
||||
}
|
||||
this.key = Buffer.from(hex.slice(0, 64), 'hex');
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv(this.algorithm, this.key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `v1:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
decrypt(encrypted: string): string {
|
||||
const [version, ivHex, authTagHex, ciphertextHex] = encrypted.split(':');
|
||||
if (version !== 'v1') throw new Error(`Unknown encryption version: ${version}`);
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
const decipher = createDecipheriv(this.algorithm, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
}
|
||||
13
backend/src/vault/vault.module.ts
Normal file
13
backend/src/vault/vault.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { CredentialsService } from './credentials.service';
|
||||
import { CredentialsController } from './credentials.controller';
|
||||
import { SshKeysService } from './ssh-keys.service';
|
||||
import { SshKeysController } from './ssh-keys.controller';
|
||||
|
||||
@Module({
|
||||
providers: [EncryptionService, CredentialsService, SshKeysService],
|
||||
controllers: [CredentialsController, SshKeysController],
|
||||
exports: [EncryptionService, CredentialsService, SshKeysService],
|
||||
})
|
||||
export class VaultModule {}
|
||||
56
backend/test/auth.service.spec.ts
Normal file
56
backend/test/auth.service.spec.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthService } from '../src/auth/auth.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const mockJwt = {
|
||||
sign: jest.fn().mockReturnValue('mock-jwt-token'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AuthService(mockPrisma as any, mockJwt as any);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns token for valid credentials', async () => {
|
||||
const hash = await bcrypt.hash('password123', 10);
|
||||
mockPrisma.user.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'admin@wraith.local',
|
||||
passwordHash: hash,
|
||||
displayName: 'Admin',
|
||||
});
|
||||
|
||||
const result = await service.login('admin@wraith.local', 'password123');
|
||||
expect(result).toEqual({
|
||||
access_token: 'mock-jwt-token',
|
||||
user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on wrong password', async () => {
|
||||
const hash = await bcrypt.hash('correct', 10);
|
||||
mockPrisma.user.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'admin@wraith.local',
|
||||
passwordHash: hash,
|
||||
});
|
||||
|
||||
await expect(service.login('admin@wraith.local', 'wrong'))
|
||||
.rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
|
||||
it('throws on unknown user', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
await expect(service.login('nobody@wraith.local', 'pass'))
|
||||
.rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
});
|
||||
46
backend/test/encryption.service.spec.ts
Normal file
46
backend/test/encryption.service.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { EncryptionService } from '../src/vault/encryption.service';
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
// 32-byte key as 64-char hex string
|
||||
process.env.ENCRYPTION_KEY = 'a'.repeat(64);
|
||||
service = new EncryptionService();
|
||||
});
|
||||
|
||||
it('encrypts and decrypts a string', () => {
|
||||
const plaintext = 'my-secret-password';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(encrypted).not.toEqual(plaintext);
|
||||
expect(encrypted.startsWith('v1:')).toBe(true);
|
||||
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it('produces different ciphertext for same plaintext (random IV)', () => {
|
||||
const plaintext = 'same-input';
|
||||
const a = service.encrypt(plaintext);
|
||||
const b = service.encrypt(plaintext);
|
||||
expect(a).not.toEqual(b);
|
||||
expect(service.decrypt(a)).toEqual(plaintext);
|
||||
expect(service.decrypt(b)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it('throws on tampered ciphertext', () => {
|
||||
const encrypted = service.encrypt('test');
|
||||
const parts = encrypted.split(':');
|
||||
parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext
|
||||
expect(() => service.decrypt(parts.join(':'))).toThrow();
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const encrypted = service.encrypt('');
|
||||
expect(service.decrypt(encrypted)).toEqual('');
|
||||
});
|
||||
|
||||
it('handles unicode', () => {
|
||||
const plaintext = 'p@$$w0rd-日本語-🔑';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user