[간병인 프로젝트 리팩토링] 사용자 인증 (NestJS)

윤학·2023년 8월 2일
0

간병인 프로젝트

목록 보기
4/8
post-thumbnail

지금까지 앱을 이용하기 위한 로그인, 회원가입 로직들을 리팩토링하였다.

이번 글에서는 회원가입과 로그인을 성공적으로 수행하면 발급해주는 토큰으로 인증을 거치는 과정을 리팩토링 해볼 예정이다.

기존 코드들에서는 사용자의 역할을 가지고 인가를 진행하는 로직들이 있는데 뒷 부분들은 아직 리팩토링을 진행하지 않아 추후에 작성을 할 예정이다.

로직설명

큰 순서는 다음과 같다.
1. Guard를 통해 토큰으로 사용자를 인증한다.
2. 만료된 토큰으로 인한 인증 실패 사용자만 RefreshToken으로 새로운 토큰을 발급 받는 과정을 거친다.
3. RefreshToken으로 새로 발급 받는데 성공하면 해당 토큰으로 서비스를 이용하고, 실패하면 로그인 되어 있는 사용자는 로그아웃된다.

그럼 한번 살펴보자.

기존 코드

먼저, 실질적으로 인증 과정을 수행해주는 JwtToken에 대한 인증 전략을 봐보자.

jwt.strategy.ts

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {

    constructor (
        private configService: ConfigService,

        @Inject('USER_REPOSITORY')
        private userRepository: Repository<User>,
    ) {
        super({
            //토큰이 유효한지 파악하기 위해 설정할 당시 토큰 secretkey값 포함
            secretOrKey: configService.get('jwt.accessToken.secretKey'),
            ignoreExpiration: false,
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
        })
    }

    async validate(payload) {

        const { userid } = payload;
        
        const user = await this.userRepository.findOne({ 
            select: [
                'id', 
                'email', 
                'name', 
                'purpose', 
                'isCertified', 
                'profile_off', 
                'warning', 
                'token_index'
            ],
            where: {
                id: userid
            }
         });

        if(!user) {
            throw new HttpException(
                '사용자를 찾을 수 없습니다.',
                HttpStatus.NOT_FOUND
            )
        }
        return user;
    }
}

토큰을 Header에서 추출하고 형식이 올바른지, 시간이 만료되지 않았는지 검사를 passport 라이브러리에서 수행해준다.

필자는 ignoreExpiration 옵션을 false로 주었기 때문에 만료된 토큰이면 오류를 던져준다.

Nest에서는 validate() 메서드의 인자로 디코딩된 토큰의 payload가 전달되기 때문에 해당 값을 가지고 추가 로직을 작성할 수 있다.

그래서 토큰의 payload에 있는 userId로 해당 사용자를 찾고 없으면 오류를 던지고 있으면 user를 반환하고, 반환한 user는 자동으로 request의 user필드로 들어가고 Guard로 넘어간다.

jwt.guard.ts

@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
    handleRequest(err, user, info: Error): any {
        if( user )
            return user;
        //토큰 시간 만료된 경우
        if (info.name === 'TokenExpiredError') {
            throw new HttpException(
                '만료된 토큰 입니다.',
                HttpStatus.UNAUTHORIZED
            );
        }
        // 토큰의 형식이 잘못된 경우
        else if (info.name === 'JsonWebTokenError')
            throw new HttpException(
                '유효하지 않은 토큰입니다.',
                HttpStatus.NOT_FOUND
            )
        //헤더에 토큰이 없는 경우
        else
            throw new HttpException(
                '토큰 정보가 없습니다.',
                HttpStatus.BAD_REQUEST
            )
    }
}

Guard에서는 별도의 로직 없이 어떤 오류인지 Custom Error Messsage를 던지기 위해 위와 같이 작성했다.

그래서 strategy에서 인증에 성공하고 해당하는 사용자도 찾아 user를 반환했으면 handleRequest()의 인자 중 user에서 해당 값을 확인할 수 있어 이런 경우에만 요청을 통과시키면서 인증을 마무리했다.

그럼 인증에 실패하여 새로 토큰을 발급 받는 과정은 어떻게 될까?

기존에는 DB에 저장한 RefreshToken을 조회할 수 있는 index를 클라이언트에 넘겨주어 AsyncStorage에 저장해놨다가 인증 갱신이 필요할 때 해당 Api로 요청했다.

auth.controller.ts

   //토큰 만료되었을 때 refreshToken으로 토큰 재발급 요청  
    @Get('refreshToken/:refreshTokenIndex')
    async requestRefreshToken(@Headers() headers: any, @Param('refreshTokenIndex') index: number)
        : Promise<{ accessToken: string, user: UserDto }> {
        const jwt = headers.authorization.split(' ')[1];
        return await this.userService.requestRefreshToken(jwt, index);
    }

user.service.ts

    //토큰 만료됐을 경우 refreshToken으로 새로운 토큰 발급
    async requestRefreshToken(jwt: string, index: number): Promise<{ accessToken: string, user: UserDto }> {
        const payload = this.jwtService.decode(jwt);

        const userid = payload['userid']; //만료된 accessToken의 userid
        const user = await this.userRepository
            .createQueryBuilder('user')
            .innerJoinAndSelect(
                'user.token',
                'token'
            )
            .where('user.id = :id', { id: userid })
            .getOne();

        //token index값이 해당 유저의 refreshtoken index값하고 일치하면 
        //해당 아이디의 refreshToken 유효성 검사
        if (user.token_index == index) {
            const refreshToken = user.token.refreshToken;

            //refreshToken이 db에 없는경우
            if (refreshToken === null)
                throw new HttpException(
                    'refreshToken 없음',
                    HttpStatus.NOT_FOUND
                )
            //refreshToken이 db에 있는 경우
            try {
                const verifyResult = await this.jwtService.verify(
                    refreshToken, { secret: this.configService.get('jwt.refreshToken.secretKey') });

                const refreshTokenExp = verifyResult['exp']; //refreshToken의 남은 시간
                this.checkRefreshTokenExp(userid, refreshTokenExp);
                const accessToken = await this.setAccessToken(userid); //새로 발급 받은 accessToken
                const user = await this.findId(userid); //해당 유저
                return { accessToken: accessToken, user: user }
            }
            catch (err) {
                //signature가 잘못된 경우, 만료기간 지난경우 
                await this.dataSoucre.query(
                    `UPDATE token TOKEN INNER JOIN user USER 
                    ON TOKEN.index = USER.token_index 
                    SET TOKEN.refreshToken = null
                    WHERE USER.id = ?`, [userid]
                )
                throw new HttpException(
                    'refreshToken 권한 문제',
                    HttpStatus.UNAUTHORIZED
                )
            }
        }
    }

    //refreshToken의 유효 기간이 1주일 밑으로 남았을 경우 자동 갱신
    checkRefreshTokenExp(userid: string, refreshTokenExp: number) {
        const today = Date.now();
        const todaySecond = Math.floor(today / 1000);
        const refreshDay = 86400 * 7;
        //refreshToken 만료기간이 1주일 이하면 새로 발급
        if ((refreshTokenExp - todaySecond) < refreshDay)
            this.setRefreshToken(userid, true);
        return;
    }

	async setAccessToken(id: string): Promise<string> {
        const accessPayload = { userid: id, date: new Date() };

        const accessToken = this.jwtService.sign(accessPayload, {
            secret: this.configService.get('jwt.accessToken.secretKey'),
            expiresIn: this.configService.get('jwt.accessToken.expireTime')
        });
        return accessToken;
    }
    
    //refreshToken 발행
    async setRefreshToken(id: string, refresh?: boolean) {

        const refreshPayload = { userid: id, date: new Date() };
        const refreshToken: string = this.jwtService.sign(refreshPayload, {
            secret: this.configService.get('jwt.refreshToken.secretKey'),
            expiresIn: this.configService.get('jwt.refreshToken.expireTime')
        });

        //로그인에 성공하면 refreshToken 발급하고 유저정보를 넘겨준다.
        await this.dataSoucre.query(
            `UPDATE token TOKEN INNER JOIN user USER 
             ON TOKEN.index = USER.token_index 
             SET TOKEN.refreshToken = ?
             WHERE USER.id = ?`, [refreshToken, id]
        )

        //로그인에 성공시에만 유저 정보 넘겨주고, refreshToken 재발급시에는 업데이트만 해준다.
        if (refresh === undefined) {
            const user = await this.userRepository.findOne({
                select: ['id', 'email', 'name', 'purpose', 'isCertified', 'warning', 'token_index'],
                where: {
                    id: id
                }
            });
            return user;
        }
    };

로직을 살펴보면, Header로부터 추출한 토큰을 decoding하여 payload에 있는 userId로 사용자를 찾고 있다.

그리고 해당 사용자가 가지고 있던 RefreshToken의 유효성 검사를 수행하는데,

성공하더라도 해당 RefreshToken의 만료기간이 1주일 이내로 남았다면 RefreshToken을 새로 발급하여 DB에 업데이트를 하고, 아니라면 AccessToken만 새로 발급하여 반환하였다.

근데 왜 RefreshToken이 만료되었을 때나 형식이 잘못되었을 때도 새로 발급해서 업데이트를 수행했는지 모르겠다.

중복 로직도 많고, 제대로 정리가 되어있지 않으니 얼른 리팩토링 해보자.

리팩토링 코드

jwt.strategy.ts

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(
        private readonly userAuthCommonService: UserAuthCommonService,
        private readonly configService: ConfigService
        ) {
        super({
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            ignoreExpiration: false,
            secretOrKey: configService.get('jwt.accessToken.secret')
        });
    }

    async validate(payload: JwtPayload): Promise<User | never> { 
        const user = await this.userAuthCommonService.findUserById(payload.userId);
        if( !user )
            throw new UnauthorizedException(ErrorMessage.NotExistUser);
        return user;
    };
}

같은 라이브러리를 사용했기 때문에 전략 부분에선 크게 달라진건 없다.

다만, 기존 코드에서 사용자를 못찾으면 404에러를 던졌는데 변경하고는 인증에 실패하면 401에러로 통일하였다.

jwt.guard.ts

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    constructor(
        private readonly sessionService: SessionService,
        private readonly tokenService: TokenService,
        private readonly reflector: Reflector
    ) {
        super();
    };

    async canActivate(context: ExecutionContext): Promise<boolean> {
        /* Public Api인지 먼저 체크 */
        if( this.isPublicApi(context) ) return true;

        /* 먼저 request.user의 상태를 확인해서 토큰 검사 통과했는지 확인 */
        await super.canActivate(context) 
        
        const request = context.switchToHttp().getRequest();

        const requestToken = this.tokenService.extractTokenFromHeader(request);

        return await this.validateUserInSessionList(request.user.getId(), requestToken);
    };

    handleRequest(err: any, user: User, info: any, context: ExecutionContext, status?: any): any {
        if( user ) return user;

        if( info.name === 'TokenExpiredError' )
            throw new UnauthorizedException(ErrorMessage.ExpiredToken);
        
        return super.handleRequest(err, user, info, context, status);
    }

    private isPublicApi(context: ExecutionContext) {
        return this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass()
        ]);
    }

    /* 
        토큰은 유효하지만 로그아웃을 진행한 사용자와같이 
        현재 로그인정보에 없는 사용자의 토큰이면 오류 
    */
    private async validateUserInSessionList(userId: number, requestToken: string): Promise<boolean> {
        const validToken = await this.sessionService.getUserFromList(userId);
        
        if( !validToken || (requestToken !== validToken) ) 
            throw new UnauthorizedException(ErrorMessage.NotExistUserInSessionList);
        
        return true;
    }
};

Guard 로직이 약간 달라졌는데 JwtGuard를 전역으로 설정해 놓았기 때문에 아래와 같이 @Public() 데코레이터가 달린 Router들은 그냥 통과시키기 위해 isPublicApi() 메서드를 통해 검사를 먼저 진행한다.

    /* 로그인 */
    @Public()
    @UseGuards(PhoneAuthenticationSendGuard)
    @Post('login')
    async login(@Body('phoneNumber') phoneNumber: string): Promise<'newuser' | 'exist'> {
        return await this.authService.login(phoneNumber);
    }

handleRequest() 메서드에서 만료된 토큰으로 인한 오류만 Custom Message로 던진 이유는 세션리스트에 사용자마다 유효한 하나의 토큰을 저장시켜놓았는데 Exception Filter에서 해당 메시지를 잡아 리스트에서 해당 사용자를 삭제하기 위해서이다.

해당 부분은 아래에서 더 알아보고 지금은 넘어가자.

그리고 나머지 Error들은 super.handleRequeset()를 통해 기본으로 내장되어 있는 AuthGuard에게 처리를 맡긴다(그냥 401에러를 던진다).

위의 과정들을 모두 통과한다면 request의 user 필드에 들어있는 사용자가 validateUserInSessionList() 메서드를 통해 세션 리스트에 존재하는지 검사하고, 세션 리스트에 존재하는 토큰을 가지고 요청을 했는지 검사한다.

컨트롤러에 요청이 도착하고 사용자 데이터가 필요한 라우터는 아래와 같이 파라미터 데코레이터로 request의 user필드에 있는 사용자 데이터를 가지고 이후 로직들을 수행한다.

    /* 휴대폰 인증코드 검사 */
    @Public()
    @UseGuards(PhoneAuthenticationCodeGuard)
    @Post('code/sms')
    async validateSmsCode(@AuthenticatedUser() user: User): Promise<ClientDto | void> {
        if( user ) return await this.authService.refreshAuthentication(user);
    }

그럼 토큰이 만료되어 새로 발급 받는 과정은 어떻게 변했을까?

auth.controller.ts

	@Public()
    @UseGuards(RefreshAuthenticationGuard)
    @UseFilters(TokenExpiredExceptionFilter)
    @Post('refresh')
    async refreshAuthentication(@AuthenticatedUser() user: User): Promise<ClientDto> {
        return await this.authService.refreshAuthentication(user);
    }

프론트엔드에서 Api를 호출하는 로직을 한 곳에 공통적으로 작성해서 AsyncStorage에 토큰이 존재하면 항상 Header에 토큰을 포함시켜서 보낸다.

이미 만료된 토큰임을 알고 인증을 갱신하려고 해당 라우터에 요청을 보내는 것이기 때문에 @Public() 데코레이터로 토큰 검사 없이 통과시켰다.

그리고 전역 Guard 이후에 수행되는 라우터에 적용된 RefreshAuthenticationGuard를 봐보자.

refresh-authentication.guard.ts

@Injectable()
export class RefreshAuthenticationGuard implements CanActivate{
    private refreshTokenSecret: string;
    
    constructor(
        @InjectRepository(User)
        private readonly userRepository: UserRepository,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService
    ) {
        this.refreshTokenSecret = configService.get('jwt.refreshToken.secret')
    }
    
    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest();

        /* 해당 RefreshKey를 가지고있는 User 조회 */
        const user = await this.getUserByRefreshKey(request.body.refreshKey); 

        /* 조회된 유저의 RefreshToken 검증 */
        await this.jwtService.verifyAsync( user.getAuthentication().getRefreshToken(), { 
            secret: this.refreshTokenSecret,
            ignoreExpiration: false
        });

        request.user = user;
        return true;
    };

    private async getUserByRefreshKey(refreshKey: string): Promise<never | User>  {
        this.validateRefreshKey(refreshKey);
    
        const user = await this.userRepository.findByRefreshKey(refreshKey);
        
        if( !user )
            throw new UnauthorizedException(ErrorMessage.InvalidRefreshKey);

        return user;
    };

    /* 요청에 RefreshKey가 존재하는지 */
    private validateRefreshKey(refreshKey: string): never | void {
        if( !refreshKey )
            throw new UnauthorizedException(ErrorMessage.NotExistRefreshKeyInRequest);
    }
}

RefreshToken을 통해서 새로운 AccessToken을 발급 받기 위해선 클라이언트에게 로그인, 회원가입 성공 시 전달해주었던 UUID형식의 RefreshKey를 Body에 포함시켜 보내주어야 한다.

포함되어 있지 않거나 해당 RefreshKey를 가진 사용자가 존재하지 않는다면 Error를 던진다.

이후에 해당 RefreshKey를 가진 사용자를 조회하여 해당 사용자의 RefreshToken을 verifyAsync()로 검증하는데

Nest의 jwtService가 jwtwebtoken 라이브러리 기반으로 만들어졌기에 해당 라이브러리의 오류 내용을 참고하여

    handleRequest(err: any, user: User, info: any, context: ExecutionContext, status?: any): any {
        if( user ) return user;

        if( info.name === 'TokenExpiredError' )
            throw new UnauthorizedException(ErrorMessage.ExpiredToken);
        
        return super.handleRequest(err, user, info, context, status);
    }

위의 코드에서 던지는 메시지와 verifyAsync()의 토큰 만료 오류 메시지가 같도록 하였다.(이후에 나올 Exception Filter 때문에)

만약 DB에 저장되어 있던 RefreshToken도 만료된 토큰이라면?

그럼 위에서 라우터에 적용해놓았던 Exception Filter에 잡히게 해놓았다.

token-expired-exception.filter.ts

@Catch(UnauthorizedException)
export class TokenExpiredExceptionFilter implements ExceptionFilter {
  constructor(
    private readonly sessionService: SessionService,
    private readonly jwtService: JwtService
  ) { }

  async catch(exception: UnauthorizedException, host: ArgumentsHost) {
    const [request, response] = [
      host.switchToHttp().getRequest(),
      host.switchToHttp().getResponse()
    ];

    /* 만료된 토큰으로 인한 오류면 세션리스트에서 사용자 삭제 */
    if (exception.message === ErrorMessage.ExpiredToken)
      await this.deleteTokenFromSessionList(request);

    response.status(exception.getStatus())
            .send({ statusCode: exception.getStatus(), message: exception.message });
  }

  /* 세션리스트에서 해당 토큰 삭제 */
  private async deleteTokenFromSessionList(request: any) {
    const accessToken = this.extractTokenFromHeader(request);
    const { userId } = this.decodeToken(accessToken);
    await this.sessionService.deleteUserFromList(userId);
  };

  /* 헤더에서 토큰 추출 */
  private extractTokenFromHeader(request: any): string {
    const { authorization } = request.headers;
    return authorization.split(" ")[1];
  };

  /* 토큰을 검증하지 않고 단순 decode */
  private decodeToken(accessToken: string): any {
    return this.jwtService.decode(accessToken, {
      complete: false,
      json: true
    })
  };
}

현재 모든 예외를 Catch하여 처리하는 전역 Filter가 적용되어 있는데, 2가지의 전역 Filter를 사용하면 작성한 순서대로 적용이 되는 줄 알았다.

하지만 테스트를 해본 결과 2개가 적용이 되진 않아서 라우터에 적용되는 Filter가 전역 Filter보다 먼저 수행되는 Nest의 request cycle을 이용하여 필요한 라우터에만 적용하였다.

그래서 토큰이 Header에 없거나, 형식이 잘못되어도 401 에러로 넘어와 Catch가 되기에 만료된 토큰에 대해서만 로직을 수행하기 위해 Custom Message를 지정해서 오류를 던졌다.

RefreshToken까지 만료된 사용자는 세션리스트에서 사라지고 프론트엔드에서는 로그인창으로 이동하게 된다.

RefreshToken이 유효하다면?

AccessToken, RefreshKey, RefreshToken을 새로 발급하는데 처음에는 RefreshKey로 사용자의 PK랑 같은 값을 줄 생각이였다.

하지만 그렇게 되면 계속 같은 RefreshKey로 고정이 되어 있고, 사용자 아이디와 같다는 이유로 유추하기가 쉬울 것이라 생각했다.(토큰을 쉽게 디코딩하여 userId를 알아낼 수 있으니..)

그래서 RefreshToken으로 새로 갱신할 때마다 새로운 RefreshKey와 RefreshToken을 새로 생성하기로 했다.

auth.service.ts

    /* 만료된 토큰일 경우 RefreshToken으로 새로 갱신 */
    async refreshAuthentication(user: User): Promise<ClientDto> {
        const refreshedAuthentication = await this.generateAuthentication(user);
        user.refreshAuthentication(refreshedAuthentication);
        await this.userRepository.save(user);
        return await this.addToSessionListAndMapToDto(user, refreshedAuthentication.accessToken);
    }
    
    /* 새로운 전체 인증(AccessToken, RefreshToken)을 생성하는 메서드 */
    private async generateAuthentication(user: User): Promise<NewUserAuthentication> {
        return await this.tokenService.generateNewUsersToken(user);
    }
    
    /* 세션 리스트에 사용자를 추가하고 사용자에게 넘겨줄 Dto로 변환 */
    private async addToSessionListAndMapToDto(user: User, newAccessToken: string): Promise<ClientDto> {
        await this.sessionService.addUserToList(user.getId(), newAccessToken);
        return this.authMapper.toDto(user);
    }

token.service.ts

    /* 새로운 사용자의 인증(토큰) 발급 */
    async generateNewUsersToken(user: User): Promise<NewUserAuthentication> {
        const [accessToken, refreshToken] = await Promise.all([this.generateAccessToken(user), this.generateRefreshToken(user)])
        return new NewUserAuthentication(accessToken, refreshToken);
    };

    async generateAccessToken(user: User): Promise<string> {
        return await this.jwtService.signAsync( this.generateJwtPayload(user), {
            secret: this.accessTokenSecret,
            expiresIn: this.accessTokenExpiresIn
        });
    };

    async generateRefreshToken(user: User): Promise<RefreshToken> {
        const [uuid, refreshToken] = [
            UUIDUtil.generateOrderedUuid(),
            await this.jwtService.signAsync( this.generateJwtPayload(user), {
                secret: this.refreshTokenSecret,
                expiresIn: this.refreshTokenExpiresIn
            })
        ];
        return new RefreshToken(uuid, refreshToken);   
    };

그리고 UUID는 MySQL에 바이너리 타입으로 저장하기위해 TypeORM의 Transformer를 적용하였다.

transformer.ts

export class UUIDTransformer implements ValueTransformer {
    to(entityValue: string): Buffer {
        return UUIDUtil.toBinaray(entityValue);
    };

    from(databaseValue: Buffer): string {
        return UUIDUtil.toString(databaseValue);
    }
};

아래와 같이 잘 변환되어 들어간다.

이렇게 앱 내에서 토큰을 통해 인증을 수행하고, 만료되었으면 갱신을 하는 로직까지 리팩토링을 완료했다.

추가로 개선할 점...

위의 JwtGuard를 잠시 보면 문제가 하나 있다.

토큰의 payload에 들어있는 userId로 세션리스트를 검사하여 해당 사용자가 있다면 요청을 통과시키는데

현재 로직은 리스트에 사용자가 존재하는지만 검사하여 만약 해당 사용자 아이디가 포함되고 만료되지 않은 토큰들이 존재하고, 해당 사용자는 다른 토큰을 이용하고 있다면 탈취한 토큰으로 계속 이용을 할 수 있을 것이다.

때문에 세션리스트에 존재하면서 요청에 들어온 토큰과 세션리스트에 존재하는 토큰을 같이 비교하여 처리해야 이러한 문제를 해결할 수 있을 것 같다.

profile
해결한 문제는 그때 기록하자

2개의 댓글

comment-user-thumbnail
2023년 8월 2일

많은 도움이 되었습니다, 감사합니다.

1개의 답글