authentication & authorization

유지민·2023년 11월 1일
0

JWT 기능을 구현하면서 인증과 인가에 대해 공부했다.

authentication(인증) : 로그인을 하는 것(로그인을해서 토큰을 받아오는 과정)
authorization(인가) : 로그인한 후, 로그인이 필요한 서비스들을 사용할 때 해당 유저임을 확인하는 것(리소스에 접근할 수 있도록 토큰을 확인하는 과정)

단순히 생각해 어떤 사이트를 이용하기 위해 우리가 흔히 하는 로그인하는 것이 인증.
로그인 후 특정 서비스를 이용할 때 권한이 있는것을 확인하는것임이 인가.

이전 프로젝트에서 필요한 인증&인가 기능을 구현이 되있지만 다시 공부할겸 플로우가 인증과 인가가 어떻게 흘러가는지 그려보았다.

  1. 클라이언트가 Id, pw를 입력하고 로그인 요청(회원가입 했다는 가정)
  2. 서버 로그인 API에서 해당 Id가 있는지 확인
  3. 입력한 password와 db password 일치하는지 비교(db에 암호화 되어 저장되어있으니 유저가 보낸 pw를 암호화해 비교(보안상 단방향 암호화))
  4. 3번을 통과했다면 유저의 일부 정보(민감하지 않은 정보 ex.계정명)를 넣어 토큰 생성후 클라이언트에게 응답
  5. 이후 클라이언트는 서버에 api 요청시 header에 발급받은 token을 넣어 요청
  6. 서버에 요청이 오면 토큰 검증 후 요청 실행

토큰 생성

//auth.service.ts

 async signIn(signInDto: SignInDto): Promise<{ accessToken: string }> {
    try {
      const { username, password } = signInDto

      const user = await this.usersRepository.findOne({ where: { username } })

      if (!user) throw new UnprocessableEntityException('해당 유저가 없습니다.')

      const isAuth = await bcrypt.compare(password, user.password)

      if (!isAuth) throw new UnauthorizedException('비밀번호가 틀렸습니다.')

      const payload = { username: user.username }
      const accessToken = this.jwtService.sign(payload)

      return { accessToken }
    } catch (error) {
      console.log(error)
      throw new InternalServerErrorException(error)
    }
  }

토큰을 만들때는 jwt라이브러리에 JwtService를 임포트해와 sign함수를 이용해 만든다.
이 떄 jwt의 secret key와 만료기간을 설정해야하는데 sign함수를 만들면서 설정해도 되지만 보안과 편의를 위해 jwt라이브러리의 모듈을 임포트해 올 때 설정해줬다.

// auth.module.ts
@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: {
        expiresIn: 3600,
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

또한 sign함수를 이용하여 jwt를 만들때 기본적으로 HS256 (HMAC SHA-256)을 이용해 아까 사용한 시크릿키를 이용해 해싱한다.

토큰 검증

인가 과정에서 토큰을 검증할때 passport모듈을 사용하면 편하다.
passport는 전략 패턴을 이용하여 사용하는데 레고 부품을 전략이라고 생각하고 필요한 레고부품을 끼운다고 생각하면 된다.

// jwt.startegy.ts passport 전략을 위한 파일
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { Injectable } from '@nestjs/common'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      secretOrKey: 'Secret1234',
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    })
  }

  async validate(payload) {
    const { username } = payload

    return username
  }
}

우리는 jwt 전략을 사용할것이기 때문에 'passport-jwt' 라이브러리에서 받아와 전략을 사용한다(Strategy). 이 임포트해온 Strategy라는 전략을 상속받은 PassportStrategy에 인자로 넣어주면 jwt 전략을 사용하는 passport이 된다. 또 super를 이용해 부모인 PassportStrategy에 시크릿키와 jwt를 넘겨주면 passport모듈이 알아서 토큰을 검증해준다.

ExtractJwt.fromAuthHeaderAsBearerToken()은 header에 있는 토큰 모양이
=> Beaerer 해싱된 토큰
모양으로 되어있는걸 Beaerer 을 뺴고 온전한 토큰형태로 바꿔줌

검증이 되지 않으면 에러를 던져주고 검증이 완료되면 validate으로 넘어가 원하는 실행을 시키면 된다(validate 이름 맞춰줘야함)

이 때 validate에서 return 되는 값은 request의 user라는 값으로 들어감(req.user)

이제 auth.module에 임포트해주고

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: process.env.JWT_SECRET || 'Secret1234',
      signOptions: {
        expiresIn: 3600,
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})

사용하고 싶은 api나 controller에 useguard, authguard를 넣어주면 된다.

import {
  Controller,
  Post,
  Get,
  Body,
  ValidationPipe,
  UseGuards,
  Req,
} from '@nestjs/common'
import { AuthService } from './auth.service'
import { SignInDto } from './dto/signin.dto'
import { AuthGuard } from '@nestjs/passport'
import { Request } from 'express'

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('/signin')
  signIn(
    @Body(ValidationPipe) signInDto: SignInDto,
  ): Promise<{ accessToken: string }> {
    return this.authService.signIn(signInDto)
  }

  @UseGuards(AuthGuard())
  @Get('/test')
  test(@Req() request): string {
    console.log(request.user)
    return this.authService.test(request)
  }
}

Access Token & Refresh Token

jwt토큰의 취약점이 있다면 토큰 위변조에는 강하지만 토큰이 탈취당했을 때 어쩔수 없다는 약점이 있다. 위변조에 강한 이유는 서버에서만 알고있는 시크릿 키와 알고리즘을 이용해 암호화하기 때문에 데이터 하나라도 달라지면 완전히 바뀐값이 된다. 그러나 토큰 자체를 탈취당하면 검증단에서 서버에서 발급한 토큰이라고 인식해 문제없이 넘어간다.

이런 문제로 토큰 자체에 유효기간을 짧게 두기를 권장하는데 만료기간을 짧게 설정한다면 보안 자체는 강화되지만 클라이언트에서 주기적으로 로그인을 다시해야하는 문제가 생긴다.

그래서 보안 문제와 유저 편리함 문제를 해결하는 방법이 refresh 토큰을 같이 발급하는 것이다.
access token의 만료기간을 30분정도로 짧게하고 refresh 토큰을 2주 정도로 갈게 잡는다. 클라이언트에서 토큰과 함께 요청을 하면 서버에서 토큰을 검증하고 만약 access token의 만료기간이 다됐으면 유저에게 access토큰이 만료됬다는 메세지를 응답하면 또 클라이언트에서 access token을 다시 요청하게 된다. 서버에서는 refresh token을 확인해 토큰을 다시 발급해주거나 refresh token도 기간이 지났으면 다시 로그인을 하라는 에러를 응답한다.

refresh Token은 구현 방법이 다양해 좀 더 공부후 오늘이나 내일 가능하면 구현 예정이다.

profile
개발 취준생

0개의 댓글