Skip to content

로그인

wlgh1553 edited this page Dec 3, 2024 · 4 revisions

📄 로그인

Ask-It 서비스의 인증 시스템은 접근성과 보안성의 균형을 맞추기 위해 Access Token과 Refresh Token을 활용한 JWT 기반 인증으로 구현했습니다.

🧩 배경 및 필요성

'Ask-It' 은 기본적으로 누구나 쉽게 접근할 수 있는 Q&A 플랫폼을 지향합니다. 로그인하지 않은 사용자도 Q&A 세션(이하 세션)에 참여하여 질문을 올리고 답변을 확인할 수 있도록 하여 진입 장벽을 최소화했습니다.

그러나 세션의 품질과 안정성을 보장하기 위해, 특정 기능들은 로그인된 사용자로 제한하기로 결정했습니다. 특히 세션을 생성하고 관리하는 호스트의 경우, 책임감 있는 운영을 위해 명확한 식별이 필요했습니다.

Ask-It의 사용자는 아래 표와 같이 네 가지 유형으로 구분됩니다:

사용자 유형 역할 로그인 필요 여부
슈퍼 호스트 세션을 생성하고 전체적인 관리를 담당
서브 호스트 슈퍼 호스트로부터 관리 권한을 부여받아 세션 운영을 도움
기명 참가자 고유한 닉네임으로 참여
익명 참가자 별도의 로그인 없이 자유롭게 참여

요구 사항으로는 아래 내용이 있었습니다:

  1. 세션 생성자(슈퍼 호스트)는 반드시 로그인된 사용자여야 하며, 세션 종료나 호스트 권한 관리 등 중요 기능을 책임감 있게 수행할 수 있어야 한다.
  2. 서브 호스트 역시 질문/답변 삭제, 답변 고정 등의 권한을 가지므로 로그인된 사용자로 제한되어야 한다.
  3. 기명으로 참여하고 싶은 사용자들이 일관된 닉네임으로 활동할 수 있어야 한다.
  4. 사용자들이 자신이 참여했던 세션들을 모아볼 수 있어야 한다.

이러한 요구사항들을 충족하면서도, 서비스의 핵심 가치인 '쉬운 접근성'을 해치지 않는 로그인 시스템이 필요했습니다.

🔍 기술적 분석 및 비교

Passport를 사용하는 대신 직접 커스텀 인증 시스템을 구현한 이유

  • Passport는 다양한 인증 전략을 제공하는 강력한 미들웨어지만, 현재는 이메일/패스워드 기반의 단순한 인증만 필요했습니다.
  • 추후 OAuth 등 인증 방식 확장 시에 Passport 도입을 고려해보고자 합니다.

Cookie & Session 방식 대신 Access & Refresh Token을 사용한 이유

  1. 기존 Cookie & Session 방식의 한계

    Cookie & Session 방식은 보호된 API 요청마다 서버에서 세션 조회를 통해 검증하므로 서버 부하 문제가 발생합니다.

  2. JWT 방식의 이점과 단일 JWT의 한계

    JWT는 인증 정보를 토큰에 포함하고 만료 기간을 설정할 수 있으므로, 세션 조회를 할 필요 없이 JWT 검증만으로 인증을 진행할 수 있습니다.

    그러나 JWT는 만료기간이 너무 길면 탈취의 위험이 있고, 너무 짧으면 사용자가 자주 재로그인 해야한다는 단점이 있습니다.

  3. Access Token & Refresh Token 조합

    이에 대한 해결책으로써, Access Token과 Refresh Token 조합을 도입했습니다.

    Access Token : 짧은 만료 시간을 가진 JWT로 보호된 자원 접근 시 사용됩니다.

    Refresh Token : 무작위 문자열로, 만료된 Access Token을 재발급 받을 때 사용됩니다. 서버에서 이 문자열들을 저장하고 관리합니다.

    Access Token이 만료된 시점에 Refresh Token이 없었다면 사용자는 재로그인을 해야했겠지만, Refresh Token을 통해 Access Token을 재발급 받을 수 있다는 편리함을 누릴 수 있습니다. 동시에 Access Token의 만료시간을 줄여 탈취 위험을 최소화시켰습니다.

🗺️ 문제 해결 과정

1. 로그인 로직 구현

로그인 과정에서 가장 먼저 해결해야했던 문제는 사용자의 유효성을 검증하고 토큰을 안전하게 발급하는 것이었습니다. AuthService를 통해 이메일과 비밀번호를 확인한 뒤, Access Token과 Refresh Token을 생성하여 반환하도록 했습니다.

//auth.controller.ts
@Post('login')
async login(@Body() loginDto: LoginDto, @Res({ passthrough: true }) response: Response) {
  const { userId, nickname } = await this.authService.validateUser(loginDto);
  const refreshToken = this.authService.generateRefreshToken(userId, nickname);
  const accessToken = await this.authService.generateAccessToken(refreshToken);

  response.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: false,
    maxAge: this.authService.getRefreshTokenExpireTime(),
  });

  return { accessToken, userId };
}

2. 인증 로직의 개선 가능성

현재 인증 로직은 Guard를 활용하여 개선할 수 있을 것 같다고 판단했습니다. AuthGuard를 활용해 다양한 인증 방식을 쉽게 구현할 수 있고, passport처럼 전략 패턴을 사용하면 코드의 가독성과 유지보수성이 향상될 수 있습니다.

@Post('login')
@UseGuards(AuthGuard('local')) // 로컬 로그인
localLogin() {}

추후 다음과 같은 확장도 고려해볼 수 있겠다는 생각이 들었습니다.

@Get('google')
@UseGuards(AuthGuard('google')) // 구글 로그인
googleLogin() {}

@Get('profile')
@UseGuards(AuthGuard('jwt')) // JWT 인증
getProfile() {}

하지만 현재는 이메일 패스워드 기반의 인증 방식만을 쓰고 있으므로, 섣불리 확장성을 고려하는 것은 오버 엔지니어링이 될 수 있다고 판단하여 일단 기존 방식으로 유지하기로 했습니다.

3. Refresh Token 관리 방식

AuthService에서 Refresh Token을 관리하는 방식은 메모리에 토큰 목록을 저장하는 것입니다. 다음과 같은 구조로 Refresh Token 데이터를 관리하고 있습니다:

  • refreshTokens: 사용자 ID와 만료 일자가 포함된 Refresh Token 정보가 저장되는 객체입니다.

    private refreshTokens: Record<string, RefreshTokenData> = {};
    //auth.service.ts
    generateRefreshToken(userId: number, nickname: string) {
      const token = uuid4();
      this.refreshTokens[token] = {
        userId,
        nickname,
        expiredAt: new Date(Date.now() + this.REFRESH_TOKEN_CONFIG.EXPIRE_INTERVAL),
      };
      return token;
    }
  • cleanupExpiredTokens(): CLEANUP_INTERVAL(1시간)마다 만료된 Refresh Token을 삭제하는 주기적인 청소 작업을 수행합니다.

    청소를 해야하는 이유는 메모리 누수 우려 때문입니다. Refresh Token을 만들 때 cookie에 CLEANUP_INTERVAL만큼으로 만료시간을 설정해놨습니다. BE 측에서는 이 만료 사실을 알 수 없기 때문에 만료된 Refresh Token이 여전히 메모리에 저장되어 있을 수 밖에 없습니다. 이러한 일을 방지하기 위해 주기적으로 refreshTokens를 청소하는 작업을 진행했습니다.

    //auth.service.ts
    @Injectable()
    export class AuthService implements OnModuleInit {
    
      onModuleInit() {
        this.startPeriodicCleanup();
      }
      
      private startPeriodicCleanup() {
        setInterval(() => {
          this.cleanupExpiredTokens();
        }, this.REFRESH_TOKEN_CONFIG.CLEANUP_INTERVAL);
      }
      
      private cleanupExpiredTokens() {
        const now = new Date();
        const expiredTokens = Object.keys(this.refreshTokens).filter((token) => this.refreshTokens[token].expiredAt < now);
    
        expiredTokens.forEach((token) => {
          this.removeRefreshToken(token);
        });
      }
    }

4. Access Token 재발급 로직

Access Token이 만료된 경우, 사용자 경험을 해치지 않으면서 보안을 유지하기 위해 Refresh Token을 이용한 새로운 Access Token 재발급 방식을 도입했습니다.

//auth.controller.ts
@Post('token')
async token(@Req() request: Request) {
  const refreshToken = request.cookies['refreshToken'];
  const accessToken = await this.authService.generateAccessToken(refreshToken);
  const userId = this.authService.getInfo(refreshToken);
  return { accessToken, userId };
}

5. logout 로직

로그아웃 시에는 Refresh Token을 삭제하고 클라이언트 쿠키에서 해당 토큰을 제거하는 방식으로 구현했습니다.

//auth.controller.ts
@Post('logout')
logout(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
  const refreshToken = request.cookies['refreshToken'];
  this.authService.removeRefreshToken(refreshToken);
  response.clearCookie('refreshToken');
  return { message: '로그아웃 되었습니다.' };
}

6. 보호된 api 접근 시 Guard 동작 방식

JwtAuthGuard를 사용해 보호된 API에 접근할 때 JWT의 유효성을 검증합니다. canActivate() 메서드를 통해 토큰을 추출하고, 유효하지 않으면 예외를 발생시킵니다.

//sessions.controller.ts
//보호된 API
@Post()
@UseGuards(JwtAuthGuard)
async create(@Body() createSessionDto: CreateSessionDto, @Req() request: Request) {
  //생략
}
//auth.jwt-auth.guard.ts
async canActivate(context: ExecutionContext) {
  const request = context.switchToHttp().getRequest<Request>();
  const token = this.extractToken(request);
  if (!token) throw JwtAuthException.missingAuthHeader();

  try {
    const payload = await this.jwtService.verifyAsync(token, { secret: process.env.JWT_ACCESS_SECRET });
    request['user'] = payload;
    return true;
  } catch (error) {
    throw JwtAuthException.invalidAccessToken();
  }
}

📈 결과 및 성과

구현 결과

  1. JWT와 Refresh Token을 활용하여 보안성과 사용성을 모두 갖춘 인증 시스템을 구축했습니다.
  2. 주기적 cleanup을 통한 메모리 기반의 효율적인 Refresh Token 관리 체계를 마련했습니다.

향후 개선 방향

  1. 추후 다양한 OAuth 인증 방식을 지원하게 된다면 전략 패턴을 도입하거나 Passport 도입을 검토해보고자 합니다.
  2. Redis를 활용하여 세션 관리 및 가비지 컬렉션을 개선해보고자 합니다.
Clone this wiki locally