Authentication#
Overview#
인증 과정의 전체 흐름부터 살펴보자.
어떤 클라이언트가 애플리케이션에 요청하고 우리 서비스에 가입하려고 한다. 요청에는 eamil과 password가 들어있다.
서버는 이메일이 이미 이메일이 사용중인지 확인한다.
유저의 암호를 암호화한다.
새로운 유저 레코드를 저장한다.
요청에 대한 응답으로 유저의 id를 포함하는 쿠키를 되돌려보낸다.
브라우저는 자동적으로 쿠키를 저장하고 이후 클라이언트의 요청에 쿠키를 붙여준다.
유저는 reports를 보낸다. 여기엔 쿠키 포함 되어있고(쿠키 내에는 유저의 id가 포함되어 있다.) report를 위한 정보도 들어있다.
서버는 쿠키의 데이터가 임의로 조작되진 않았는지 확인한다.
이후 쿠키 내의 userid를 통해 누가 요청을 보낸건지 알아낸다.
인증과 관련된 항목들을 구현하는데는 고려할 수 있는 두 가지 옵션이 있다.
- Users Service에 추가하기.
signup(), signin()등의 메서드를 추가한다.- 단점 : 프로그램이 커질수록 서비스가 너무 커진다.
- 새로운 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도 살펴보자.
- 컨테이너에 모든 클래스들을 등록한다.
- 컨테이너는 각 클래스들이 어떤 종속성들을 갖고 있는지 알 수 있게 된다.
- 이 과정에서
Injectable decorator를 각 클래스에 사용하고 providers의 모듈 리스트에 클래스들을 추가한다.
- 컨테이너에게 우리가 필요한 클래스 인스턴스를 생성하도록 요청한다.
- 컨테이너는 필요한 모든 종속성들을 생성하고 인스턴스를 넘긴다.
- 컨테이너는 생성된 종속성 인스턴스를 지니고 있다가 이후 필요할 때 재사용한다.
이제 만들어보자!
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#
Cookie-Session library에서 쿠키 내부의 정보를 다루는#
- 요청이 들어오면 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 });
}
|
현재 구현 기능#
추가해야할 기능
- 유저가 로그인되어있지 않을 시 요청을 거부 => Guard
- 자동적으로 handler에게 현재 로그인된 유저가 누구인지 알려주기 => Interceptor + Decorator