들어가기전에…

이번 장에서는 쿠키를 사용해 인증 기능을 추가 해보려고 한다.

  1. AuthController에 login 핸들러 메서드 추가
  2. AuthService에서 email, password를 넘겨주면 해당 정보의 유저가 있는지 유효성 검증을 하는 로직 추가
  3. 유저 정보의 유효성 검증이 끝나면 응답값에 쿠키 정보를 추가해 반환

NestJS에서 인증을 구현할 때는 보통 인증용 미들웨어인 ‘가드’를 사용한다고 한다. 가드는 특정 상황(권한, 롤, 액세스컨트롤)에서 받은 요청을 가드를 추가한 라우트 메서드에서 처리할지 말지를 결정하는 역할을 한다.

1. 쿠키를 사용한 인증 구현하기

순서 1. AuthService에 이메일과 패스워드 검증 로직 만들기

순서 1-1. 쿠키에 데이터를 추가하기 전에 유저의 데이터가 맞는지 검증하는 로직이 필요하다. 쿠키 정보를 정하는 내용 전에 유저의 이메일과 패스워드 검증 로직을 아래와 같이 작성한다

// src > auth > auth.service.ts

... 생략 ...

@Injectable()
export class AuthService {
	... 생략 ...
    
    async validateUser(email: string, password: string) {
        const user = await this.userService.getUser(email); // 1. 이메일로 유저 정보 받아옴

        if (!user) {  // 2. 유저가 없으면 검증 실패
            return null;
        }
        const { password: hashedPassword, ...userInfo } = user;
        // 3. 패스워드를 따로 뽑아냄
        if (bcrypt.compareSync(password, hashedPassword)) { // 4. 패스워드가 일치하면 성공
            return userInfo;
        }
        return null;
    }
}

1번. 입력받은 email로 등록된 유저가 있는지 찾는다

2번. 유저가 없다면 올바른 정보가 아니므로 null 반환

3번. 패스워드 검증을 위해, 유저 정보에서 패스워드만 따로 뽑아서 hashedPassword라는 변수로 받아온다

4번. bcrypt.compareSync(data, encrypted) 함수에서 data는 입력받은 패스워드값을 넣고 두번째에는 패스워드 해시값을 넣어주면 바르게 암호화된 경우 userInfo를 반환한다


순서 1-2. validateUser() 메서드를 AuthController에서 사용해 인증 결과를 쿠키에 추가한다

// src > auth > auth.controller.ts

import { Post, Body, Controller, Request, Response } from '@nestjs/common';
import { CreateUserDto } from 'src/user/user.dto';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {

    ... 생략 ...

    @Post('login')
    async login(@Request() req, @Response() res) { // 1. Request, Response 둘 다 사용
        // 2. validateUser를 호출해 유저 정보 획득
        const userInfo = await this.authService.validateUser(
            req.body.email,
            req.body.password
        );

        // 3. 유저 정보가 있으면, 쿠키 정보를 Response에 저장
        if (userInfo) {
            // 'login'은 쿠키 이름, userInfo는 값
            res.cookie('login', JSON.stringify(userInfo), {
                httpOnly: false, // 4. 브라우저에서 읽을 수 있도록 함
                maxAge: 1000 * 60 * 24 * 7, // 7day  단위는 밀리초
            });
        }
        return res.send({ message: 'login success' });
    }
}

1번. login() 메서드는 Request와 Response를 모두 사용해야 하므로 @Body나 @Param이 아닌 @Request를 직접 사용한다. Response 객체는 쿠키를 설정할 때 사용한다

2번. 앞서 만든 authService의 validateUser를 호출해 패스워드를 제외한 유저 정보를 받아온다

3번. 유저 정보가 있으며 res.cookie를 사용해서 쿠키를 설정한다. (브라우저에서 쿠키를 읽지 못하게 해야한다)

4번. httpOnly를 true로 설정하면 브라우저에서 쿠키를 읽지 못한다


순서 1-3. 테스트하기

image-20230829132742656


순서 2. 가드를 사용해 인증됐는지 검사하기

‘가드’는 어떠한 기능을 하는가?
  1. 로그인 시, body에 전달한 정보(이메일, 비밀번호)가 DB에 있는지 없는지를 서비스(auth.service.ts)에서 체크한다
  2. DB에 요청한 body에 전달한 정보가 있다면, request.user에 정보(이메일, 비밀번호)를 추가한다.
  3. 컨트롤(auth.controller.ts)에서 request.user의 정보가 있다면, 쿠키를 저장한다. 그리고 이 쿠키는 설정한 시간(maxAge)까지 저장되고 이후에는 사라진다.

Nest.js에는 인증할 때 가드라는 미들웨어를 보편적으로 사용한다.

가드는 @Injectable() 데코레이터가 붙어 있고 CanActivate 인터페이스를 구현한 클래스이다. @UseGuard 데코레이터로 가드를 사용할 수 있다

image-20230829134033520

순서 2-1. 서버 측에서 HTTP 요청의 헤더에 있는 쿠키를 읽는 코드가 필요하다. 쿠키를 읽는 이유는 인증 정보를 확인하기 위해서이다. HTTP 헤더에서 쿠키를 읽으려면 cookie-parser 패키지를 설치하고 설정을 해야 한다. cookie-parser 패키지를 설치한다

npm install cookie-parser

순서 2-2. HTTP 요청의 헤더에서 쿠키를 읽어올 수 있도록 NestApplication의 설정을 변경하기 위해, main.ts에 추가한다

// src > main.ts

... 생략 ...
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());validationPipe 객체 추가
  app.use(cookieParser()); // 1. 쿠키 파서 설정
  await app.listen(3000);
}
bootstrap();

1번. 쿠키 파서는 쿠키를 Request 객체에서 읽어오는데 사용하는 미들웨어다. cookieParser()로 추가한다


순서 2-3. authService의 validateUser를 사용해 가드를 만든다. src/auth 아래에 auth.guard.ts 파일을 생성하고 아래와 같이 작성한다

// src > auth > auth.guard.ts

import { CanActivate, Injectable } from "@nestjs/common";
import { AuthService } from "./auth.service";

@Injectable() // 1. Injectable이 있으니 프로바이더
export class LoginGuard implements CanActivate { // 2. CanActivate 인터페이스 구현
    constructor(private authService: AuthService) { } // 3. authService를 주입받음

    // 4. CanActivate 인터페이스의 메서드
    async canActivate(context: any): Promise<boolean> {
        // 5. 컨텍스트에서 리퀘스트 정보를 가져옴
        const request = context.switchToHttp().getRequest();

        // 6. 쿠키가 있으면 인증된 것
        // login은 쿠키의 key
        if (request.cookies['login']) {
            console.log(request.cookies['login'])
            return true;
        }

        // 7. 재인증 작업, 쿠키가 없으면 request의 body 정보 확인
        if (!request.body.email || !request.body.password) {
            return false;
        }

        // 8. 인증 로직은 기존의 authService.validateUser 사용
        const user = await this.authService.validateUser(
            request.body.email,
            request.body.password,
        );

        // 유저 정보가 없으면 false 반환
        if (!user) {
            return false;
        }
        // 9. 있으면 request에 user 정보를 추가하고 true 반환
        request.user = user;
        return true;
    }
}

1번. @Injectable 이 있으므로 다른 클래스에 주입할 수 있음

2번. CanActivate를 구현했으므로 가드가 된다

3번. 인증에 사용할 AuthService 객체를 주입

4번. canActivate() 메서드는 CanActivate의 추상 메서드이므로, 사용할 클래스에서 구현해야 한다. 반환 타입으로 boolean 또는 ‘Promise'을 줘야 하는데, async await 구문을 사용하므로 여기서는 Promise을 반환 타입으로 사용한다.

5번. context는 ExecuteContext 타입으로 주로 Request나 Response 객체를 얻어오는 데 사용한다. 여기서는 Request 정보를 가져오는 데 사용했다

6번. login() 메서드에서 응답값으로 쿠키를 저장한다. 다시 한번 인증을 요청할 때 쿠키의 login 항목에 값이 있다면 인증이 되었다는 의미

7번. 쿠키에 값이 없다면 다시 인증을 시도하기 위해 request.body에 email과 password값이 모두 있는지 확인한다. 둘 중에 하나라도 없다면 false를 반환

8번. 인증 로직은 기존의 authService.validateUser를 사용

9번. user 정보가 있다면 request.user에 user 정보를 할당하고 true를 반환한다


순서 2-4. 컨트롤러(auth.controller.ts)에 LoginGuard 로직을 추가한다

// src > auth > auth.controller.ts

import { Post, Body, Controller, Request, Response, UseGuards, Get } from '@nestjs/common'; // UseGuards 임포트 추가
import { LoginGuard } from './auth.guard'; // 추가

... 생략 ...

@Controller('auth')
export class AuthController {

    ... 생략 ...

    @UseGuards(LoginGuard) // 1. LoginGuard 사용
    @Post('login2')
    async login2(@Request() req, @Response() res) {
        // 2. 쿠키 정보는 없지만 request에 user 정보가 있다면 응답값에 쿠키 정보 추가
        if (!req.cookies['login'] && req.user) {
            // 응답에 쿠키 정보 추가
            res.cookie('login', JSON.stringify(req.user), {
                httpOnly: true,
                maxAge: 1000 * 10, // 3. 로그인 테스트를 고려해 10초로 설정
            });
            return res.send({ message: 'login2 success' });
        }
    }

    // 4. 로그인을 한 때만 실행되는 메서드
    @UseGuards(LoginGuard)
    @Get('test-guard')
    testGuard() {
        return '로그인된 때만 이 글이 보입니다.';
    }
}

1번. 가드를 사용 시에는 @UseGuards 데코레이터를 사용한다. 괄호 안에 만들어둔 LoginGuard를 넣어준다

2번. LoginGuard에서 인증에 성공 시 request.user에 user 정보를 할당한다. 쿠키 정보가 없으나 user 정보가 있는 경우 로그인 프로세스를 진행한 것으로 보고 쿠키를 설정한다

3번. (설명 생략)

4번. (설명 생략)


순서 2-5. 테스트하기

DB에 없는 정보로 로그인 시도 시, 토큰이 발급되지 않는다. 따라서, 쿠키가 필요한 인증 API에서는 어떠한 응답도 얻을 수 없다

image-20230829162659445

image-20230829162824514


image-20230829162943110

image-20230829163039769

image-20230829163112086

정리

  • ‘가드’를 사용하여, 2개의 API(유효한 회원인 경우, 쿠키 저장 API, 저장된 쿠키로 인증을 확인하는 API)를 만들어보았다. 이해가 어려웠던 부분은 이 인증이라는 건 항상 2개의 API(로그인 후, 쿠키 발급 & 쿠키로 인증 확인)를 사용해야 한다는 것과 가드, 서비스, 컨트롤러가 돌아가는 과정이 어려웠던 것 같다. 하지만, 이제는 이해가 된 것 같아 로그인과 인증에 대해 다뤄볼 수 있어 의미있었다

참고

다음에 다루게 될 것

  • 회원 가입과 인증하기(3) - 패스포트와 세션을 사용한 인증 구현

댓글남기기