본문 바로가기

프로그램 개발

NestJS 개발 시작하기(로그인 인증 개발); 7. 암호화와 해쉬 함수

NestJS 개발 시작하기(로그인 인증 개발)

1. NestJS와 Spring의 비교

2. NestJS CLI로 개발시작

3. API개발과 Swagger

4. TypeORM으로 DB연결

5. Session, JWT, OAuth 차이

6. jwt 인증 (1)

7. 암호화와 해쉬 함수

 

6. 암호화와 해쉬 함수

유럽에서 2016년 5월에 GDPR (General Data Protection Regulation)이 발효되면서 많은 나라에서 개인정보보호에 대한 규정을 만들고 있다. 우리나라도 2021년 2월부터는 개인정보보 보호법에 따라 특정 정보, 또는 정보의 조합이 특정 개인을 지칭할 수 있는 경우 민감정보로 규정하고 이를 보호하는 규정을 적용하고 있다.

 

일반적으로 회사서비스는 어느 정도 개인 정보를 담고 있을 수밖에 없어 이 정보를 어떻게 저장하고 관리할 지에 대한 방법들이 제시되고 있다. 그중 가장 일반적인 방법이 민감정보를 암호화하는 방법이다. 그래서 암호화에 필요한 기능들이 개발언어나 라이브러리에 많이 나와 있고, Node.js의 경우 core패키지에 crypto가 포함되어 있어 따로 설치할 필요가 없다.

a. 암호화

암호화를 포함하여 뒤에 설명할 Hash 등 보안에 필요한 기능을 따로 모아 SecurityHandler 클래스로 만들고, 암호화를 위해 아래와 같이 encryptString와 descryptString 함수를 만들었다.

 

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
import { promisify } from "util";

const scryptAsync = promisify(scrypt);

@Injectable()
export class SecurityHandle {
    constructor(private jwtService: JwtService, private configService: ConfigService) { }

    async encryptString(str: string): Promise<string> {
        const salt = this.configService.get('CRYPTO_SALT');
        const secret = this.configService.get('CRYPTO_SECRET');
        if (!salt || !secret) {
            throw new Error('CRYPTO_SALT or CRYPTO_SECRET is not set');
        }
        console.log('Salt: ' + salt);
        const key = (await scryptAsync(secret, salt, 32)) as Buffer;
        const iv = randomBytes(16);
        const cipher = createCipheriv('aes-256-ctr', key, iv);
        const encryptedText = Buffer.concat([cipher.update(str, 'utf8'), cipher.final()]);
        return iv.toString('hex') + ':' + encryptedText.toString('hex');
    }

    async decryptString(encrypted: string): Promise<string> {
        const salt = this.configService.get('CRYPTO_SALT');
        const secret = this.configService.get('CRYPTO_SECRET');
        if (!salt || !secret) {
            throw new Error('CRYPTO_SALT or CRYPTO_SECRET is not set');
        }
        const [ivHex, encryptedHex] = encrypted.split(':');
        const iv = Buffer.from(ivHex, 'hex');
        const key = (await scryptAsync(secret, salt, 32)) as Buffer;
        const decipher = createDecipheriv('aes-256-ctr', key, iv);
        const decryptedText = Buffer.concat([decipher.update(Buffer.from(encryptedHex, 'hex')), decipher.final()]);
        return decryptedText.toString('utf8');
    }
}

 

crypto와 관련된 설정은 ConfigService를 통해 .env에서 가져오고 있으며, 암호화와 복호화에 필요한 secret은 CRYPTO_SECRET에, 사용자가 패스워드를 쉽게 설정해도 철저하게 암호화하기 위해 사용하는 salt는 CRYPTO_SALT에 지정하면 된다.  salt는 랜덤 시퀀스를 사용하는 것이 좋으며 openssl을 사용해 만드는 것이 편리하다.

 

openssl.exe rand -hex 32

b. 암호화 해쉬 함수 (Cryptographic Hash Function)

지금까지는 Service를 만들 때 패스워드를 텍스트를 그대로 저장했다. 하지만 일반적으로 회사에서 서비스를 만들 때 문제가 발생할 것을 우려해서 사용자의 패스워드를 저장, 보관하는 것을 원하지 않는다. 하지만, 로그인을 하려면 가입할 때 입력했던 패스워드와 일치하는지 확인해야 로그인이 가능하기 때문에 패스워드 대신 암호화 해쉬 함수 (Cryptographic Hash Function)가 만들어 내는 Hash값을 사용한다. 이 암호화 해쉬 암수는

  • Hash 값에서 원래 데이터를 복원할 수 없는 one way function이고
  • Hash(m1) = Hash(m2)가 나옰 수 없는, 즉 각자 다른 데이터에 대한 hash값은 항상 다른 함수이다.

그래서, 사용자가 입력한 패스워드를 암호화 해쉬 함수로 Hash값을 만들고 저장하면, 첫 번째 원칙에 따라 사용자의 패스워드는 사용자가 입력한 패스워드로 복원할 수 없고, 모든 암호에 대해 중복된 Hash값이 나올 수 없기 때문에, 사용자가 입력한 패스워드 원본을 저장하지 않으면서 로그인을 할 수 있는 방법을 만들 수 있는 것이다.

 

패스워드에 해쉬함수를 적용하기 위해서는, 먼저 암호화 해쉬 함수를 사용할 수 있는 bcrypt 패키지를 설치한다.

 

pnpm i bcrypt
pnpm i -D @types/bcrypt

 

위에서 만든 SecurityHandler에 아래와 같이, 패스워드에서 Hash 값을 만드는 getPasswordHash 함수와 입력 패스워드와 이전에 저장한 Hash값을 비교하는 comparePassword 함수를 추가한다.

 

...
import * as bcrypt from 'bcrypt';

@Injectable()
export class SecurityHandle {
...
    async getPasswordHash(password: string): Promise<string> {
        return await bcrypt.hash(password, Number(this.configService.get('SALT_ROUNDS')));
    }

    async comparePassword(password: string, hash: string): Promise<boolean> {
        return await bcrypt.compare(password, hash);
    }
}

c. Service에 암호화와 해쉬 함수를 적용

SecurityHandle을 @Injectable로 선언했으니 auth.moduel에 provider로 등록하는 것도 잊지 말고 한다.

 

...
@Module({
  imports: [
    ConfigModule,
    TypeOrmModule.forFeature([Account]),
    JwtModule.registerAsync({
      useFactory: (configService: ConfigService) => ({
        global: true,
        secret: configService.get('JWT_SECRET'),
        signOptions: { expiresIn: '1d' }
      }),
      inject: [ConfigService]
    })
    ],
  controllers: [SignController],
  providers: [SignService, SecurityHandle]
})
...

 

앞에서 만든 Service를 수정해서 SecurityHandle을 inject 하고, createAccount에는 getPasswordHash함수를 사용해 패스워드를 해쉬값으로 변경하고, encryptString함수를 사용해 name을 암호화하도록 했다. login은 comparePassword를 사용해서 사용자가 입력한 패스워드의 해쉬값이 DB에 저장된 값과 일치하는지 비교도록 했다.

 

export class SignService {
    constructor(
        @InjectRepository(Account) private repo: Repository<Account>,
        private jwtService: JwtService,
        private configService: ConfigService,
        private securityHandle: SecurityHandle) { }
...
    async createAccount(account: SignUpDto): Promise<boolean> {
        const existingAccount: Account = await this.find(account.email);
        if (existingAccount == null) {
            account.password = await this.securityHandle.getPasswordHash(account.password);
            account.name = await this.securityHandle.encryptString(account.name);
            const newAccount = this.repo.create(account);
            this.repo.save(newAccount);
            return true;
        }
        return false;
    }
..
    async login(signInDto: SignInDto): Promise<IAuthInfo> {
        const existingAccount: Account = await this.find(signInDto.email);
        if (!await this.securityHandle.comparePassword(signInDto.password, existingAccount.password)) {
            throw new UnauthorizedException();
        }
        const name = await this.securityHandle.decryptString(existingAccount.name);
        const payload = { id: existingAccount.id };
        return {
            accessToken: await this.jwtService.signAsync(payload),
            name: name
        }
    }
...

 

이제, SignUp 테스트를 하고 나서 DB를 확인하면 password는 hash값으로 name은 암호화 되어 저장되어 있는 것을 확인할 수 있다.