# NestJS/Typescript : JWT를 통한 인증과 인가 과정

- Author: @laetipark
- Published: 2023-12-01
- Updated: 2024-02-19
- Source: http://blex.me/@laetipark/nestjstypescript-jwt%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EC%9D%B8%EA%B0%80-%EA%B3%BC%EC%A0%95
- Tags: nodejs, nestjs, typescript, jwt, 인가, 인증, auth, token

---

# 인증과 인가 과정

![](https://static.blex.me/images/content/2023/11/30/202311301_YnY1bS5kTxFluDrdN6G4.png)

- **인증(Authentication)** : `식별 가능한 정보`를 입력하여 자신의 신원을 시스템에 `증명`
    - 아이디와 패스워드를 입력해 인증서버를 통해 로그인
- **인가(Authorization)** : `인증된 사용자`에 대하여 `자원 접근 권한`을 확인
    - 로그인한 사용자가 자원서버에서의 글 쓰기와 같은 서비스를 이용할 수 있는지 확인

# JWT(Json Web Token)

![](https://static.blex.me/images/content/2023/11/30/202311301_5OpQnfvrqt7vKBKe4s42.png)

- 로그인 이후 `인증`과 관련한 정보들을` 인코딩`한 상태로 담은 `토큰`
- 토큰은 **Header.Payload.Signature**로 세 파트의 JSON 쌍이 `인코딩(base64)`된 구조
    - **Header**(헤더) : 토큰의 `종류`(typ)와 `해시 알고리즘 방식`(alg)
    - **Payload(정보)** : 토큰에 대한 `정보`
        - 등록된 클레임 : 토큰에 대한 정보들을 담기 위해 이름이 이미 정해진 클레임(sub, lat)
        - 공개 클레임 : 그 외 다른 정보들로 충돌이 방지된 이름을 가지고 있어야 함
        - 비공개 클레임 : 클라이언트-서버 간 서비스를 공유하기 위한 정보를 담은 클레임
    - **Signature(서명)** : `토큰의 무결성`을 위한 `암호화 코드`
        - Header의 인코딩 값과 Payload의 인코딩 값, 그리고 secret 키를 해쉬 함수로 생성
        - 헤더에서 지정한 알고리즘을 통해 암호화
        - 복호화도 가능
- `Cookie`와 `Session`의 단점을 보완할 수 있다.
    - **Cookie**
        - 요청 보낼 때 값을 그대로 보내기 때문에, 중간에 유출 및 조작 당할 수 있음
        - 용량 제한이 있기 때문에 많은 정보를 담을 수 없으며, 쿠키 사이즈가 클 수록 네트워크 부하가 심해짐
        - 웹 브라우저 간 쿠키 지원 형태가 다르기 때문에, 브라우저 간 공유가 불가능
    - **Session**
        - 쿠키에 저장된 세션을 탈취하여, 공격자가 사용자인 척 위장할 수 있음(세션 하이재킹)
        - 세션을 저장하기 위한 저장소를 사용하기 때문에 요청이 많아질 경우 서버 부하가 심해짐
        - 확장성이 낮음
    - **JWT**
        - Header와 Payload를 가지고 Signature을 생성하므로 데이터 위변조를 막을 수 있음
        - 인증 정보에 대한 별도의 저장소가 필요 없음
        - 서버가 클라이언트 상태를 저장할 필요가 없어, 서버를 무상태(Stateless)로 만들 수 있으며, 확장성(Scalability)이 좋음
        - 다른 서비스에 권공유(소셜 계정 로그인)하는 등, 로그인 정보의 확장성(Extensibility)이 좋음

## Access Token & Refresh Token

- JWT는 발급 시 사용자의 인증과 인가를 무효화할 수 없기 때문에, 토큰 탈취 위험을 막기 위해 만료 시간을 짧게 줌
    - 토큰 만료 시간을 짧게 주면 매번 다시 인증 과정을 거쳐야 하는 문제가 있기 때문에 도입된 개념

### 인증

![](https://static.blex.me/images/content/2023/11/30/202311301_dHbpAypSYmIndOuzREVP.png)

1. `ID`와 `Password` 정보와 함께 `로그인 요청`
2. 데이터베이스에서 ID와 Password 대조
    - 일치할 경우, `Access Token`과 `Refresh Token` 응답
    - 클라이언트에서 두 토큰을 저장

### 인가

![](https://static.blex.me/images/content/2023/11/30/202311301_AY6YBJvNB2QpP8ygbetC.png)

1. `Header`에 `Access Token`을 포함시켜 `API 요청`
2. 토큰의 유효성 검증
    - Access Token 만료가 되지 않고 토큰 유효성 검증 완료 시 API 요청을 처리하고 응답

### 재발급

![](https://static.blex.me/images/content/2023/11/30/202311301_PlGf71yJzXbg00T2XNx3.png)

1. `Access Token`이 `만료`될 경우, 만료가 되었음을 응답
2. `Header`에 `Refresh Token`을 포함시켜 `Access Token 재발급 요청`
3. `Refresh Token`의 유효성 검증하고, 새로운 `Access Token`을 응답
    - 클라이언트에서 `Access Token` 저장

## NestJS에서 JWT 사용

> 프로젝트에서는 `Access Token` 발급하고 사용하는 과정만 진행하였습니다!

- JWT 모듈 설치

```shell
npm install @nestjs/passport passport passport-local # 인증 미들웨어 관련 모듈
npm install @nestjs/jwt passport-jwt # JWT 관련 모듈
npm install cookie-parser # 쿠키 관련한 모듈
npm install -D @types/passport-local @types/passport-jwt # Typescript 타입 정의 모듈
```

- 쿠키를 사용하기 위해 `cookie-parser` Import
```typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser()); // cookieParser() 사용
	
  // ...
}
bootstrap();
```

- 인증 모듈에 `JWT 모듈` Import

```typescript
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.registerAsync({
      useFactory: (configService: ConfigService) => {
        return {
          // JWT 비밀키 값  (추후에 .env 파일에 저장하여 가져와주는 것이 좋다.)
          secret: 'secret',
          // JWT 서명 옵션  
          signOptions: {
            // 토큰 만료 시간, 단위가 없으면 단위 ms(ex: 24h, 120)
            expiresIn: '24h',
            // algorithm: 사용할 알고리즘(default: HS256)
            // notBefore: 토큰 발급 후 사용을 허용하는 시간, 단위가 없으면 단위 ms
            // audience: 토큰 발급 대상
            // issuer: 토큰 발급자
            // jwtid: jwt 식별자
            // subject: 토큰의 주제
            // noTimestamp: 토큰에 타임스탬프 포함 여부
            // header: JWT 헤더 설정
            // keyid: 키 식별자 설정
          },
        };
      },
      inject: [ConfigService],
    }),
  ],
  // ...
})
export class AuthModule {
}
```

- 로그인 시 `JWT Access Token` 발급 및 인증

```typescript
// auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  // ...

  /** 사용자 로그인
   * @Body signInUserDto 로그인 정보 */
  @Post('/login')
  @HttpCode(HttpStatus.OK)
  async signIn(@Body() signInUserDto: SignInUserDto, @Res() res: Response) {
    // 동록된 사용자 확인  
    const verifiedUser = await this.authService.verifyUser(signInUserDto);

    // JWT Token 발급  
    const payload = { id: verifiedUser.id, username: verifiedUser.username };
    const accessToken = await this.authService.getAccessToken(payload);

    // Set-Cookie 헤더로 JWT 토큰 & 응답 body로 사용자 정보 반환  
    return res.cookie('accessToken', accessToken, { httpOnly: true }).json({
      message: SuccessType.USER_SIGN_IN,
      data: payload,
    });
  }
}
```

```typescript
// auth/auth.service.ts
@Injectable()
export class AuthService {
	/**
   * 사용자 검증
   * @return 검증된 User 객체
   * @param signInUserDto LoginDto
   */
  async verifyUser(signInUserDto: SignInUserDto): Promise<User> {
    const user = await this.userRepository.findOneBy({
      username: signInUserDto.username,
    });
    if (!user) {
      throw new UnauthorizedException(ErrorType.USER_NOT_EXIST);
    }

    const isMatch = await this.comparePassword(
      signInUserDto.password,
      user.password,
    );
    if (!isMatch) {
      throw new UnauthorizedException(ErrorType.PASSWORD_MISMATCH);
    }

    return user;
  }
	
  // ...

  /** JWT Access Token 발급
   * @param payload payload 요소 */
  async getAccessToken(payload: any) {
    return await this.jwtService.signAsync(payload);
  }
}
```

- `JWT 인가` 과정

```typescript
@UseGuards(JwtAuthGuard) // 전역으로 사용할 수 있고, 메소드에 따로 사용할 수 있음
@Controller('users')
export class UserController {
  // ...
}
```

```typescript
// auth/guard/jwtAuth.guard.ts
@Injectable()
// AuthGuard(타입명)을 상속받는 것으로, 타입명을 설정해주면 그에 맞는 strategy를 따라감
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err: any, user: any) {
    if (err || !user) {
      throw err || new UnauthorizedException('유효하지 않은 토큰입니다.');
    }
    return user;
  }
}
```

```typescript
// auth/strategy/jwt.strategy.ts
@Injectable()
// PassportStrategy(Strategy, '타입명, (default: jwt)')을 상속 받는 것으로 타입명에 그에 대한 strategy
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly userLib: UserLib,
    private readonly configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req: Request) => {
          return req?.cookies?.accessToken;
        },
      ]),
      ignoreExpiration: false,
      secretOrKey: 'secret',
    });
  }

  // 사용자에 대한 유효성 확인
  async validate(payload: { id: number; username: string }) {
    const user = await this.userLib.getUserById(payload.id);

    if (!user) {
      throw new UnauthorizedException('유효하지 않는 사용자입니다.');
    }

    // req.user에 사용자 정보를 담음
    return {
      id: user.id,
      username: user.username,
    };
  }
}
```

## 관련 Repository
- [[ FeedMoa ] 소셜 미디어 통합 Feed 서비스 RESTful API 서버, laetipark, github](https://github.com/laetipark/feed-moa)
- [[ 여기가자 ] 지리기반 맛집 추천 서비스 RESTful API 서버, laetipark, github](https://github.com/laetipark/lets-go-here)
- [[ 돈이머니 ] 예산 관리 서비스 RESTful API 서버, laetipark, github](https://github.com/laetipark/money-is-money)

## 참고 자료

- [@nestjs/jwt, Github](https://github.com/nestjs/jwt)
- [node-jsonwebtoken, Github](https://github.com/auth0/node-jsonwebtoken#jwtsignpayload-secretorprivatekey-options-callback)
- [AuthGuard는 어떻게 JwtStrategy를 찾는걸까?, Fitware Jay, Tistory](https://jay-ji.tistory.com/94)
- [토큰 기반의 로그인 인증 방법 이해하기, HomieKim, Tistory)](https://hoime.tistory.com/94)
- [인증과 인가 - JWT 토큰 인증, 재쿵, Tistory](https://sjh9708.tistory.com/46)
- [JSON Web Token 소개 및 구조, VELOPERT](https://velopert.com/2389)
- [JWT accessToken 과 refreshToken 을 어떻게 활용해야할까?, velog)](https://velog.io/@yhlee9753/JWT-accessToken-%EA%B3%BC-refreshToken-%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%99%9C%EC%9A%A9%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C)
