cds는 디파이 서비스이므로 (당연하게도) 메타마스크를 활용한 web3 인증이 구현되어 있다. 기존에 express로 작성했던 api서버를 새로 배운 nest.js로 작성해보고 있다. express에서 직접 구현했던 web3 인증을 nest.js에선 라이브러리를 사용해 개선해보고 싶어 passport.js를 사용하기로 결정하고 리서치를 해봤다.
그런데 passport-web3 라이브러리는 논스를 사용하지 않는 취약한 형태로 구현되어 있었다. 누군가 깃허브 이슈로 이 문제를 언급해서 개발자도 인지하곤 있지만 개선의 의지가 없어보였다.
다른 레퍼런스들도 찾아봤는데 대부분 nest.js에 바로 적용할만한 자료가 없었다. 믿고 있었던 ChatGPT도 엉뚱한 예제를 알려주더라.
그래서 직접 구현하고, 상세히 기록을 남기기로 했다.
(힘들었습니다..😂)
구현해야 하는 로직은 다음과 같이 3단계로 구성되어 있다.
eth-sig-util
의 recoverPesonalSignautre
메서드로 서명을 복호화한다.먼저 캐시DB 설정을 해야 한다.
이곳을 참고하면 된다.
아래의 네 가지 파일을 통해서 구현한다.
캐시모듈을 사용할 수 있게 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 {}
클라이언트가 상호작용할 수 있는 인터페이스(REST API)가 정의된 컨트롤러다.
기존의 구현과 동일하게 하기 위해서 라우팅을 다음과 같이 했는데, 지금 보니 RESTFul 하지 않게 설계되어 있어 아래와 같이 변경하는게 바람직하다고 생각된다.(하지만 클라이언트와 연동을 위해 예전의 설계대로 구현했다.)
아래와 같이 변경하는 것이 바람직하다고 생각된다.
@Get('/nonce')
를 Post로 변경해야 한다. 캐시DB를 업데이트하는 로직이 들어가 있고, 이는 서버의 상태를 변경하는 메서드이므로 Get요청은 부적절하다. Patch로 변경하는 것도 고려해볼만 한데, 해당 요청은 매번 새로운 uuid를 생성하므로 idempotent하지 않은 요청이므로 POST가 적절하다.
@Get('/logout')
역시 @Delete 요청으로 변경해야 한다. 캐시DB에 등록된 세션ID를 삭제해야 하므로 Delete가 적절하고, idempotent한 요청이므로 delete를 사용함이 적절해보인다.
@Get('/verify')
는 브라우저가 리프레쉬 되었을 때 로그인을 유지하는 메서드다. 해당 요청이 서버로 들어오면 캐시에 저장된 세션ID의 TTL을 연장하므로 Post나 Patch로 변경하는 것이 적절해 보인다.
데이터베이스, 캐시, 서명 검증하는 로직은 서비스 레이어에서 처리하도록했고, 쿠키를 설정하는 로직은 컨트롤러에서 처리한다. logout
과 verify
요청시에는 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');
}
}
실제 인증관련 로직이 있는 서비스 파일이다.
컨트롤러에서 로그인 호출시 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);
}
}
유저의 요청 쿠키에 담긴 세션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;
}
}
일반적인 가드처럼 구현하면 되니 손쉽게 구현할거라 생각했는데... 사소한 문제들에 막혀 제법 시간이 오래걸렸다.
npm i cookie-parser && npm i -D @types/cookie-parser
해주고 main.ts에 전역 미들웨어로 지정해줘야 했다.1000 * 60 * 60
으로 지정해줘서 60분동안 로그인 세션이 유지되도록 했다. @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 });
}
sample mocking comment for api test