Authentication

Overview

인증 과정의 전체 흐름부터 살펴보자.

  • 어떤 클라이언트가 애플리케이션에 요청하고 우리 서비스에 가입하려고 한다. 요청에는 eamil과 password가 들어있다.
  • 서버는 이메일이 이미 이메일이 사용중인지 확인한다.
  • 유저의 암호를 암호화한다.
  • 새로운 유저 레코드를 저장한다.
  • 요청에 대한 응답으로 유저의 id를 포함하는 쿠키를 되돌려보낸다.
  • 브라우저는 자동적으로 쿠키를 저장하고 이후 클라이언트의 요청에 쿠키를 붙여준다.
  • 유저는 reports를 보낸다. 여기엔 쿠키 포함 되어있고(쿠키 내에는 유저의 id가 포함되어 있다.) report를 위한 정보도 들어있다.

  • 서버는 쿠키의 데이터가 임의로 조작되진 않았는지 확인한다.
  • 이후 쿠키 내의 userid를 통해 누가 요청을 보낸건지 알아낸다.

인증과 관련된 항목들을 구현하는데는 고려할 수 있는 두 가지 옵션이 있다.

  1. Users Service에 추가하기.
    • signup(), signin()등의 메서드를 추가한다.
    • 단점 : 프로그램이 커질수록 서비스가 너무 커진다.
  2. 새로운 AuthService를 만든다. (우리가 선택할 방식)
    • 새로운 서비스에 두 메서드를 만든다.
    • 이 서비스도 user repository에 접근할 수 있어야 한다. 하지만 기존 Users Service가 데이터 불러오기나 데이터베이스 접근 로직을 많이 가지고 있다.
    • 따라서 Auth Service는 Users Service에 의존하는 형태로 만들어 준다.

새로운 서비스 생성

Users Module

  • Users Module 내에는 1개의 컨트롤러(Users Controller), 2개의 서비스(Users Service, Auth Service), 1개의 레포지토리(Users Repository)가 있어야 한다.
  • 컨트롤러는 두 서비스 모두가 필요하다.
  • Auth 서비스는 요청 받은 id가 기존 회원에 있는지 없는지에 따라 실행 메서드가 달라진다. 따라서 일단 Users 서비스는 필요하다.
  • 또한 Auth 서비스는 Repository에 직접 도달하지 않고 Users 서비스를 이용하도록 할 것이다.
  • 정리
    • 컨트롤러 : Users 서비스와 Auth 서비스 필요
    • Auth 서비스 : Users 서비스 필요
    • Users 서비스 : Users Repository 필요

DI Container Flow

위 과정에서 DI(Dependency Injection, 종속성 주입)이 필요하다. DI Container Flow도 살펴보자.

  1. 컨테이너에 모든 클래스들을 등록한다.
  2. 컨테이너는 각 클래스들이 어떤 종속성들을 갖고 있는지 알 수 있게 된다.
    • 이 과정에서 Injectable decorator를 각 클래스에 사용하고 providers의 모듈 리스트에 클래스들을 추가한다.
  3. 컨테이너에게 우리가 필요한 클래스 인스턴스를 생성하도록 요청한다.
  4. 컨테이너는 필요한 모든 종속성들을 생성하고 인스턴스를 넘긴다.
    • 이 과정은 자동적으로 이뤄진다.
  5. 컨테이너는 생성된 종속성 인스턴스를 지니고 있다가 이후 필요할 때 재사용한다.

이제 만들어보자!

Auth 서비스 생성

src의 user 디렉토리에 새로운 서비스 파일 auth.service.ts 파일을 만든다.

1
2
3
4
5
// auth.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {}

새로운 서비스를 User 모듈에 추가해주자

1
2
3
4
5
6
7
8
9
// users.module.ts
import { AuthService } from './auth.service';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, AuthService], // 여기에 추가되었다.
})
export class UsersModule {}

Auth 서비스는 Users Service의 복사본이 필요하니 추가해주자.

1
2
3
4
5
6
7
8
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from './users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}
}

이제 signup method를 구현해보자. signup에서 이뤄져야 할 것은 위에서 모두 고려하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// auth.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { UsersService } from './users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async signup(email: string, password: string) {
    // See if email is in use
    const users = await this.usersService.find(email);
    if (users.length) {
      throw new BadRequestException('email in use');
    }

    // Hash the users password

    // Create a new user and save it

    // return the user
  }
}

더 진행하기 전에 암호 해싱에 대해서 공부해보자.

암호 해싱

해싱

현재 우리의 SQLite 데이터베이스에는 password가 plain text로 들어있다.

Hashing Function : 받은 input을 기반으로 Hash를 계산하고 일련의 숫자와 문자를 출력한다.

  • 입력을 조금이라도 변경하면 출력은 완전히 변경된다. 동일한 입력은 동일한 출력을 내보낸다.
  • 출력값으로 원래 입력값을 알아낼 수 없다. 비가역적이다.

따라서 우리는 입력받은 비밀번호를 Hashing Function으로 변환하여 출력값을 데이터베이스에 저장한다.

유저가 올바른 비밀번호를 입력하면 똑같이 해싱된 결과를 받을 수 있기 때문에 저의 비밀번호를 검증할 수 있다.

단점

  • 자주 쓰이는 비밀번호(1234, 생일, 전화번호 등)은 유추될 수 있다.
  • 자주 쓰이는 단어들의 해시값 리스트를 가지고 다른 유저의 해시된 비밀번호를 보고 대조를 통해 원래 비밀번호를 유추할 수 있다.(Rainbow Table Attack)

Hashed and salted password

  • Salt : 우리 서비스 내에서 임의의 숫자와 문자를 생성한다. (ex : a1d01)
  • 받은 password와 Salt를 결합하여 Hashing한다.
  • Hashing 결과의 출력값과 Salt를 또 결합하여 저장한다.

  • 검증방식
    • 유저가 email을 입력하면 DB에 저장되있는 password에서 결합된 Salt와 Hash를 분리한다.
    • 그 Salt와 현재 유저가 입력한 password를 결합하여 Hashing한다.
    • 1번째의 Hash와 2번째의 결과를 비교한다.
  • Salt가 무엇이냐에 따라 무한대의 Salt를 가정하고 Table을 제작하기 때문에 Rainbow Table Attack이 힘들다

Auth 서비스 계속 생성하기

  • 공부한대로 salt를 만들고, password를 해싱하여 둘을 붙여보자.
  • 이제 입력받은 email로 새로운 유저를 만들어내는 코드를 추가하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// auth.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { UsersService } from './users.service';
import { randomBytes, scrypt as _scrypt } from 'crypto';
import { promisify } from 'util';

const scrypt = promisify(_scrypt);

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async signup(email: string, password: string) {
    // See if email is in use
    const users = await this.usersService.find(email);
    if (users.length) {
      throw new BadRequestException('email in use');
    }

    // Hash the users password
    // Generate a salt (8바이트, 16진수이므로 총 16개의 문자 혹은 숫자 생성)
    const salt = randomBytes(8).toString('hex');

    // Hash the salt and the password together(32자, 32바이트 길이를 반환할 것)
    // Typescript는 이것이 어떻게 반환되는지 모르므로 as Buffer를 추가해주자.
    const hash = (await scrypt(password, salt, 32)) as Buffer;

    // Join the hashed result and the salt together
    const result = salt + '.' + hash.toString('hex');

    // Create a new user and save it
    const user = await this.usersService.create(email, result);

    // return the user
    return user;
  }
}

컨트롤러가 서비스의 메서드에 접근할 수 있도록 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
// users.controller.ts
import { AuthService } from './auth.service';

export class UsersController {
  constructor(
    private usersService: UsersService,
    private authService: AuthService
  ) {}

  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    return this.authService.signup(body.email, body.password);
  }

다음과 같이 수정해주고 API Client를 통해 테스트해보자.

서버를 키고 새로운 아이디와 비밀번호를 입력하여 signup을 요청한다. 이 후 sqlite explorer를 통해 해싱값이 데이터베이스에 저장되었는지 확인하면 된다.

Signin 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// auth.service.ts
async signin(email: string, password: string) {
    const [user] = await this.usersService.find(email);
    if (!user) {
      throw new NotFoundException('user not found');
    }

    const [salt, storedHash] = user.password.split('.');

    const hash = (await scrypt(password, salt, 32)) as Buffer;

    if (storedHash !== hash.toString('hex')) {
      throw new BadRequestException('bad password');
    }

    return user;
  }

컨트롤러에 추가

1
2
3
4
5
// users.controller.ts
@Post('/signin')
  signin(@Body() body: CreateUserDto) {
    return this.authService.signin(body.email, body.password);
  }

API Client로 테스트해보자.

Sessions

  • 요청이 들어오면 Headers에서 Cookies를 찾는다.
  • 라이브러리는 쿠키를 디코딩하여 session 객체를 만든다. 그 객체 내에는 우리가 원하는 정보가 들어있다.
  • decorator를 사용하여 요청 핸들러에서 세션 객체에 접근한다.
  • 세선 객체에 속성을 더하거나, 제거하거나, 바꾸는 등 처리를 한다.
  • 쿠키-세션은 수정된 세션을 보고 암호화된 문자열로 바꾼다.
  • 암호화된 문자열은 응답의 Headers에 부착되어 다시 전송된다.

설치

npm install cookie-session @types/cookie-session

main.ts 파일에 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.ts
const cookieSession = require('cookie-session'); // typescript와 잘 호환되지 않아 import 대신 이렇게 사용

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(
    cookieSession({
      keys: ['anything'],
    })
  );
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
    })
  );
  await app.listen(3000);
}
bootstrap();

컨트롤러에 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// users.controller.ts
@Post('/signup')
  async createUser(@Body() body: CreateUserDto, @Session() session: any) {
    const user = await this.authService.signup(body.email, body.password);
    session.userId = user.id
    return user;
  }

  @Post('/signin')
  async signin(@Body() body: CreateUserDto, @Session() session: any) {
    const user = await this.authService.signin(body.email, body.password);
    session.userId = user.id
    return user;
  }

아래와 같이 requests.http 를 작성하고 API 테스트해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// requests.http
### Create a new user
POST http://localhost:3000/auth/signup
content-type: application/json

{
    "email": "email@session.test",
    "password": "12345"
}

### Sign in as an existing user
POST http://localhost:3000/auth/signin
content-type: application/json

{
    "email": "email@session.test",
    "password": "12345"
}

위 요청을 보내면 아주 긴 cookie-session을 볼 수 있다.

두번째 요청에는 없다. 왜냐하면 쿠키가 업데이트되지 않았기 때문에 쿠키 헤더가 반환되지 않아 표시되지 않은 것이다.

Request handler 들 추가하기

현재 내 id 찾기

1
2
3
4
5
6
7
8
9
10
11
12
// users.controller.ts
// 현재 내 ID
@Get('/whoami')
  whoAmI(@Session() session: any) {
    return this.usersService.findOne(session.userId);
  }

// 로그아웃
@Post('/signout')
  signOut(@Session() session: any) {
    session.userId = null;
  }

API 테스트 코드

1
2
3
4
5
### Get the currently signed in user
GET http://localhost:3000/auth/whoami

### Sign out
POST http://localhost:3000/auth/signout

이 방법을 테스트해보면 올바르게 작동되지 않는 것을 볼 수 있다.

원인은 findOne 메서드에 null을 인수로 주면 첫번째 user를 반환하기 때문이다. 이 메서드를 수정해야 한다.

1
2
3
4
5
6
7
// users.service.ts
  findOne(id: number) {
    if (!id) { // 없으면 id 반환 안함
      return null;
    }
    return this.repo.findOneBy({ id });
  }

현재 구현 기능

  • 회원가입
  • 로그인
  • 로그아웃
  • 현재 로그인된 ID

추가해야할 기능

  • 유저가 로그인되어있지 않을 시 요청을 거부 => Guard
  • 자동적으로 handler에게 현재 로그인된 유저가 누구인지 알려주기 => Interceptor + Decorator