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