[Nest.js] Web3 인증 구현하기

Donghun Seol·2023년 4월 24일
0

0. 배경

cds는 디파이 서비스이므로 (당연하게도) 메타마스크를 활용한 web3 인증이 구현되어 있다. 기존에 express로 작성했던 api서버를 새로 배운 nest.js로 작성해보고 있다. express에서 직접 구현했던 web3 인증을 nest.js에선 라이브러리를 사용해 개선해보고 싶어 passport.js를 사용하기로 결정하고 리서치를 해봤다.

그런데 passport-web3 라이브러리는 논스를 사용하지 않는 취약한 형태로 구현되어 있었다. 누군가 깃허브 이슈로 이 문제를 언급해서 개발자도 인지하곤 있지만 개선의 의지가 없어보였다.

다른 레퍼런스들도 찾아봤는데 대부분 nest.js에 바로 적용할만한 자료가 없었다. 믿고 있었던 ChatGPT도 엉뚱한 예제를 알려주더라.

그래서 직접 구현하고, 상세히 기록을 남기기로 했다.
(힘들었습니다..😂)

1. 로그인 로직

구현해야 하는 로직은 다음과 같이 3단계로 구성되어 있다.

서버로부터 nonce 얻기

  1. 사용자는 클라이언트를 통해 자신의 address가 담긴 요청을 서버로 보낸다.
  2. 서버는 해당 요청을 받으면 uuid를 활용해 nonce를 생성한다.
  3. 생성된 논스는 cache매니저를 통해 캐싱DB에 키는 address로 밸류는 논스로, ttl은 1분으로 저장한다.
  4. 서버는 nonce값을 클라이언트에 응답으로 돌려준다.

nonce를 서명해서 서버에서 검증하기

  1. 클라이언트는 메타마스크를 통해 nonce를 서명한 값을 서버로 보낸다.
  2. 서버는 서명으로 들어온 입력값이 적절한지 먼저 검증한다.
  3. 이후 eth-sig-utilrecoverPesonalSignautre메서드로 서명을 복호화한다.
  4. 복호화한 결과와 캐싱DB에 저장된 nonce를 비교한다.
  5. 캐싱DB의 nonce를 지운다.

세션 ID전달 및 캐싱DB에 저장

  1. 인증이 성공했으면 세션 ID를 생성한다.
  2. 캐싱DB에 세션 ID를 킷값으로 로그인 정보를 담아서 추후 AuthGuard에서 활용한다.
  3. 쿠키에 세션ID를 실어 클라이언트로 보낸다.
  4. 이제 클라이언트는 로그인 정보가 담긴 요청을 서버로 보낼 수 있다.

2. 구현

먼저 캐시DB 설정을 해야 한다.
이곳을 참고하면 된다.

아래의 네 가지 파일을 통해서 구현한다.

auth.module.ts

캐시모듈을 사용할 수 있게 import 했다.
authService는 다른 모듈에서 사용하진 않을것이므로 export하진 않는다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { MyCacheModule } from 'cache/cache.module';

@Module({
  imports: [MyCacheModule],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

auth.controller.ts

클라이언트가 상호작용할 수 있는 인터페이스(REST API)가 정의된 컨트롤러다.
기존의 구현과 동일하게 하기 위해서 라우팅을 다음과 같이 했는데, 지금 보니 RESTFul 하지 않게 설계되어 있어 아래와 같이 변경하는게 바람직하다고 생각된다.(하지만 클라이언트와 연동을 위해 예전의 설계대로 구현했다.)

아래와 같이 변경하는 것이 바람직하다고 생각된다.

  1. @Get('/nonce') 를 Post로 변경해야 한다. 캐시DB를 업데이트하는 로직이 들어가 있고, 이는 서버의 상태를 변경하는 메서드이므로 Get요청은 부적절하다. Patch로 변경하는 것도 고려해볼만 한데, 해당 요청은 매번 새로운 uuid를 생성하므로 idempotent하지 않은 요청이므로 POST가 적절하다.

  2. @Get('/logout') 역시 @Delete 요청으로 변경해야 한다. 캐시DB에 등록된 세션ID를 삭제해야 하므로 Delete가 적절하고, idempotent한 요청이므로 delete를 사용함이 적절해보인다.

  3. @Get('/verify')는 브라우저가 리프레쉬 되었을 때 로그인을 유지하는 메서드다. 해당 요청이 서버로 들어오면 캐시에 저장된 세션ID의 TTL을 연장하므로 Post나 Patch로 변경하는 것이 적절해 보인다.

데이터베이스, 캐시, 서명 검증하는 로직은 서비스 레이어에서 처리하도록했고, 쿠키를 설정하는 로직은 컨트롤러에서 처리한다. logoutverify 요청시에는 AuthGuard에서 쿠키에 담긴 세션 ID를 검증하고, 검증결과가 유효한 경우 req.user.verifiedAddress에 사용자의 주소값을 담아서 컨트롤러로 넘긴다.

import { Controller, Get, Post, Body, Query, Req, Res } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { GetNonceDto } from './dto/get-nonce.dto';
import { AuthGuard } from 'guard/auth.guard';
import { Web3AuthGuard } from './web3-auth.guard';
import * as uuid from 'uuid';
import { Response } from 'express';

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

  @Post('/login')
  async login(@Body() loginDto: LoginDto, @Res() res: Response) {
    console.log('post login called', loginDto);
    const { address, signature } = loginDto;
    const sessionId = uuid.v1();
    const verifiedAddress = await this.authService.getVerifiedAddress(
      address,
      signature,
    );
    this.authService.setSessionId(sessionId, verifiedAddress);
    this.authService.createUser(verifiedAddress);
    res.cookie('sessionId', sessionId, { httpOnly: true });
    return res.json({ verifiedAddress });
  }

  @Get('/nonce')
  getNonce(@Query() getNonceDto: GetNonceDto) {
    const { address } = getNonceDto;
    return this.authService.getNonce(address);
  }

  @AuthGuard(Web3AuthGuard)
  @Get('/logout')
  logout(@Req() req: Request, @Res() res: Response) {
    const { verifiedAddress } = req;
    this.authService.delSessionId(verifiedAddress);
    res.clearCookie('sessionId');
    return res.json();
  }

  @AuthGuard(Web3AuthGuard)
  @Get('/verify')
  verify(@Res() res: Response) {
    return res.status(200).json('Login Verfied');
  }
}

auth.service.ts

실제 인증관련 로직이 있는 서비스 파일이다.

컨트롤러에서 로그인 호출시 getVerifiedAddress 메서드가 호출된다. 이 메서드는 verfiySignature 프라이빗 메서드를 호출해서 서명을 검증한 뒤, 검증이 유효하면 검증된 주소를 반환하는 메서드다.

서명의 검증은 캐시DB에 저장된 논스를 조회하고, 해당 논스를 활용한 서명값이 유효한지 확인하는 방식으로 진행된다. recoverPersonalSignature에서 오류를 뱉거나, 바로 다음의 if문에 해당되면 유효하지 않은 인증값을 반환한다.

setSessionId와 delSessionId는 세션ID를 캐시DB에 저장하는 유틸리티함수다. AuthService클래스와 Web3AuthGuard에서 사용하게 된다.

마지막으로 createUser 메서드가 있는데 완전히 구현되진 않았다. 먼저 web3 로그인이므로 일반적인 절차와 다르게 로그인이 된 유저는 항상 DB에 저장한다. 그리고 신규 접속자에겐 웰컴 이더와 토큰을 지급해야 하는 부분이 추가되어야 한다. 기존의 구현에서는 컨트롤러에서 바로 블록체인 트랜잭션함수를 호출했는데, 이후 이 부분은 이벤트를 emit하는 방식으로 구현할 계획이므로 비워뒀다.

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import * as uuid from 'uuid';
import { recoverPersonalSignature } from 'eth-sig-util';
import { bufferToHex } from 'ethereumjs-util';

import { Users } from 'entities/users.entity';
import { MyCacheService } from 'cache/cache.service';

@Injectable()
export class AuthService {
  constructor(
    private myCacheService: MyCacheService,
    @InjectRepository(Users)
    private userRepository: Repository<Users>,
  ) {}

  async getVerifiedAddress(address: string, signature: string) {
    const result = await this.verifySignature(address, signature);
    if (!result) {
      throw new UnauthorizedException();
    }
    return result;
  }

  async getNonce(address: string) {
    const nonce = uuid.v1();
    await this.myCacheService.set(address, nonce, { ttl: 1000 * 60 });    return { nonce };
  }

  async createUser(address: string) {
    const user = this.userRepository.findOne({ where: { address } });
    if (user) return;
    // TODO DB에서 유저를 조회하고, 저장된 유저가 없으면 새로 만든다.
    // TODO 새로 만드는 경우, faucet으로 이더와 토큰을 준다.
    return;
  }

  async verifySignature(address: string, signature: string) {
    const nonce = (await this.myCacheService.get(address)) as string;

    const parsedAddress = recoverPersonalSignature({
      data: bufferToHex(Buffer.from(`sign: ${nonce}`)),
      sig: signature,
    });

    if (parsedAddress.toLowerCase() !== address.toLowerCase()) return null;

    return parsedAddress.toLowerCase();
  }

  async setSessionId(sessionId: string, verifiedAddress: string) {
    await this.myCacheService.set(sessionId, verifiedAddress);
  }

  async delSessionId(sessionId: string) {
    await this.myCacheService.del(sessionId);
  }
}

web3-auth.guard.ts

유저의 요청 쿠키에 담긴 세션ID를 캐시DB와 비교해서 로그인 여부를 판별하는 가드다. 만약 로그인된 사용자인 경우 req.user.verifiedAddress에 유저의 주소를 담아서 다음 핸들러로 넘긴다. 로그인되지 않은 사용자인 경우 401에러를 뱉는다.

이 가드는 유저 컨트롤러에도 사용된다. 내 정보만을 조회하고 업데이트하는 GET /users/my, POST /users/my,등의 컨트롤러에서 유용하게 재사용할 수 있다. 크크크 이것이 바로 횡단관심사를 분리한 AOP?!!

import {
  BadRequestException,
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Request } from 'express';
import { MyCacheService } from 'cache/cache.service';

@Injectable()
export class Web3AuthGuard implements CanActivate {
  constructor(private myCacheService: MyCacheService) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return this.validateRequest(request);
  }

  private async validateRequest(request: Request) {
    if (!request.cookies.sessionId) {
      throw new UnauthorizedException('No SessionId included');
    }
    const sessionId = request.cookies.sessionId;
    const verifiedAddress = await this.myCacheService.get(sessionId);

    if (!verifiedAddress) {
      throw new UnauthorizedException('No such key in sessionDB');
    }
    request['user'] = { verifiedAddress };
    return true;
  }
}

일반적인 가드처럼 구현하면 되니 손쉽게 구현할거라 생각했는데... 사소한 문제들에 막혀 제법 시간이 오래걸렸다.

3. 맞닥뜨린 문제들과 해결

  1. req 객체에서 쿠키를 읽어오지 못했다.
    알아보니 네스트가 자동으로 쿠키를 파싱해주지는 않았다. 따라서 express 스타일로 npm i cookie-parser && npm i -D @types/cookie-parser 해주고 main.ts에 전역 미들웨어로 지정해줘야 했다.
  2. 캐싱해놓은 세션아이디가 자꾸 날아갔다.
    처음엔 세션 아이디가 날아가서 생긴 문제일거라 생각도 못해서 한참을 헤멨었다. 문제는 TTS의 단위였다 TTS는 ms단위인데 초라고 생각해서 캐시가 1초만에 날아가 버려서 제대로 인증이 안된 문제였다. 따라서 TTS를 1000 * 60 * 60으로 지정해줘서 60분동안 로그인 세션이 유지되도록 했다.
  3. 컨트롤러에 응답이 묶여있어 클라이언트에 반환되지 않았다.
    공식문서를 참조하니 서비스에 있는 함수 호출을 컨트롤러에서 반환하는 경우에만 자동으로 응답이 전달되는 것이었다. login 컨트롤러의 경우엔 쿠키나 세션을 조작하기 위해 서비스단에 있는 여러 메서드를 호출하게 되어 명시적으로 반환해주어야 했다.
  @Post('/login')
  async login(@Body() loginDto: LoginDto, @Res() res: Response) {
    const { address, signature } = loginDto;
    const sessionId = uuid.v1();
    const verifiedAddress = await this.authService.getVerifiedAddress(
      address,
      signature,
    );
    await this.authService.setSessionId(sessionId, verifiedAddress);
    await this.authService.createUser(verifiedAddress);
    res.cookie('sessionId', sessionId, { httpOnly: true });
    console.log('login successful', verifiedAddress, sessionId);
    return res.json({ verifiedAddress });
  }
profile
I'm going from failure to failure without losing enthusiasm

1개의 댓글

comment-user-thumbnail
2023년 5월 13일

sample mocking comment for api test

답글 달기