카테고리 없음

[nestjs] flutter + nestjs 소셜로그인 인증 -1(nestjs 코드)

STUFIT 2024. 4. 11. 09:04
반응형

이번에 앱을 만들면서 인증전략에 대해 jwt 토큰 기반 인증을 채택하였다.

먼저, 로직구조는 다음과 같다.

백엔드에서는 플러터에서 소셜로그인 요청을 해서 소셜 서버에서 받은 인가코드(카카오) 또는 액세스토큰(네이버,구글) 을 받아서 해당 엑세스 토큰으로 유저 검색을 한다.

만약 유저가 null 이면 DB에 유저 정보를 담아서 회원가입을 시켜준 후에 엑세스 토큰과 리프레시 토큰을 발급하여 플러터로 전송해준다.

그리고, 엑세스 토큰이 만료되면 플러터 측에서는 TokenStorage에서 리프레시토큰과 엑세스 토큰을 관리하기 때문에 리프레시 토큰을 백엔드 서버로 보내서 백엔드에서는 리프레시 토큰의 유효성을 체크한 뒤 새로운 엑세스토큰을 플러터 측으로 전달해주는 로직이다.

코드는 다음과 같다.

/* 유저쪽 컨트롤러와 서비스로서 앤드포인트 담당 */
// app/src/user/user.controller.ts

import {Body, Controller, Get, Param, Post, Res, UseGuards} from '@nestjs/common';
import { UserService } from './user.service';
import { LoginRequestDto } from '../auth/dto/login.request.dto';
import { TokenResponseDto } from '../auth/dto/token.response.dto';
import {JwtAccessTokenGuard} from "../auth/guard/accessToken.guard";

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  /**
   * 소셜로그인
   * @param data
   * @param res
   */
  @Post('login/social')
  async socialLogin(
    @Body() data: LoginRequestDto,
    @Res({ passthrough: false }) res,
  ): Promise<TokenResponseDto> {
    return await this.userService.socialLogin(data, res);
  }

}


// app/src/user/user.service.ts

import {forwardRef, Inject, Injectable} from '@nestjs/common';
import {UserRepository} from "./user.repository";
import {CreateUserDto} from "./dto/createUser.dto";
import {ProfileEntity} from "./entity/Profile.entity";
import {UserEntity} from "./entity/User.entity";
import {LoginRequestDto} from "../auth/dto/login.request.dto";
import {TokenResponseDto} from "../auth/dto/token.response.dto";
import {UserInfoDto} from "./dto/userInfo.dto";
import {AuthService} from "../auth/auth.service";

@Injectable()
export class UserService {
    constructor(
        @Inject(forwardRef(() => AuthService))
        private  authService: AuthService,
        private  readonly userRepository: UserRepository
    ) {
    }

    /**
     * provider 핸들러
     * @private
     */
     private loginProviderHandlers = {
        'kakao':(accessToken:string)=> this.authService.getUserByKakaoAccessToken(accessToken),
        'google':(accessToken:string)=> this.authService.getUserByGoogleAccessToken(accessToken),
        'naver': (accessToken:string)=> this.authService.getUserByNaverAccessToken(accessToken),
    };

    /**
     * 소셜 로그인
     * @param data
     * @param res
     */
    async socialLogin(data: LoginRequestDto, res: any): Promise<TokenResponseDto> {
        // 소셜 로그인 유형 체크
        const socialUser = await this.oAuthLogin(data);
        // accessToken, refreshToken 발급
        const [accessToken, refreshToken] = await Promise.all([
            this.authService.generateAccessToken(socialUser),
            this.authService.generateRefreshToken(socialUser),
        ]);
        await this.updateRefreshToken(socialUser.userNo, refreshToken);
        res.cookie('refreshToken', refreshToken, {
            path: '/',
            httpOnly: true,
        });
        return res.json(new TokenResponseDto({ accessToken, refreshToken }));
    }

    /**
     * 소셜 로그인 처리기
     * @param data
     */
    async oAuthLogin(data: LoginRequestDto): Promise<UserInfoDto> {
        const loginHandler = this.loginProviderHandlers[data.provider];
        return await loginHandler(data.accessToken);
    }


    async createUser(userInfo:CreateUserDto):Promise<UserEntity>{
        const {provider,data}=userInfo
        const {id,email,...profileData}=data;
        const user =  this.userRepository.create({
            email:email,
            name:data.nickname,
            provider:provider,
            providerId:id.toString(),
        });
        // 프로필
        user.profile =  this.userRepository.manager.create(ProfileEntity,{
            age:profileData.ageRange?profileData.ageRange.toString():null,
            gender:profileData.gender?profileData.gender:null,
            nickname:profileData.nickname,
            profileImg:profileData.profileImage,
    });
        return await this.userRepository.save(user);
    }
}
/* 여기는 auth 담당 */
// app/src/auth/auth.controller.ts
import {Body, Controller, Get, Post, Query, Req, Res, UseGuards} from '@nestjs/common';
import { AuthService } from './auth.service';
import {JwtAccessTokenGuard} from "./guard/accessToken.guard";

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

  @Get('/kakao/callback')
  kakaoLogin(@Query('code') code: string) {
    return this.authService.kakaoLogin(code);
  }

  @Get('/validate')
  @UseGuards(JwtAccessTokenGuard)
  async getProfile(@Req() req) {
    // strategy 통과시 req.user 에 return 값이 저장된다.
    return req.user;
  }

  @Post('/refreshAccessToken')
  async refreshAccessToken(@Body('refreshToken') refreshToken:string) {
    // 사용자가 Guard를 통과했다면, req.user에 사용자 정보가 포함되어 있습니다.
    // 새로운 액세스 토큰 발급 로직을 여기에 구현합니다.
    const newAccessToken = await this.authService.refreshAccessToken(refreshToken);
    return { accessToken: newAccessToken };
  }

}

// app/src/auth/auth.service.ts

import { forwardRef, Inject, Injectable } from '@nestjs/common';
import axios from 'axios';
import { JwtService } from '@nestjs/jwt';
import { JwtSubjectType } from '../common/enum/jwtSubject.type';
import { CommonUserInfomationType } from '../common/enum/common.userInfomation.type';
import * as process from 'process';
import { UserEntity } from '../user/entity/User.entity';
import { UserInfoDto } from '../user/dto/userInfo.dto';
import { UserMapper } from '../user/dto/userMapper.dto';
import { UserService } from '../user/user.service';
import {ConfigService} from "@nestjs/config";
import {ExternalApi} from "../common/external_api/externalApi";
import {InvalidTokenException, KakaoOAuthFailedException} from "../../domain/errors/auth.errors";

@Injectable()
export class AuthService {
  constructor(
    @Inject(forwardRef(() => UserService))
    private userService: UserService,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    private readonly externalApi: ExternalApi,
  ) {}

  /**
   * 카카오 토큰 유효성 조회
   * @param accessToken
   */
  async getUserByKakaoAccessToken(accessToken: string): Promise<UserInfoDto> {
    try {
      const kakaoUrl = this.configService.get<string>('KAKAO_ACCESS_TOKEN_URL');
      // KAKAO LOGIN 회원조회 REST-API
      const userRes = await this.externalApi.fetchExternalApiAsHeaders(kakaoUrl, accessToken);
      const { id, kakao_account: kakaoAccount } = userRes.data;
      const kakaoInfo = { id: id, kakaoAccount: kakaoAccount };
      const userInfo = UserMapper.toCreateUserDto(
          CommonUserInfomationType.KAKAO,
          kakaoInfo,
      );
      // 유저 DB 저장정보 체크
      let user: UserEntity = await this.userService.checkUser(id);
      if (!user) {
        user = await this.userService.createUser(userInfo);
      }
      return UserMapper.toUserInfoDto(user);
    }catch (e){
      throw new KakaoOAuthFailedException;
    }
  }

  /**
   * 구글 로그인
   * @param userInfo
   */
  async getUserByGoogleAccessToken(accessToken: string): Promise<UserInfoDto> {
    const googleUrl = this.configService.get<string>('GOOGLE_USERINFO_API_URL');
    const userRes = await this.externalApi.fetchExternalApiAsParams(googleUrl, accessToken);
    const { sub, email, name, picture } = userRes.data;

    const userInfoDto = UserMapper.toCreateUserDto(
      CommonUserInfomationType.GOOGLE,userRes.data
    );
    let user: UserEntity = await this.userService.checkUser(sub);
    if (!user) {
      user = await this.userService.createUser(userInfoDto);
    }
    return UserMapper.toUserInfoDto(user);
  }

  async getUserByNaverAccessToken(accessToken: string): Promise<UserInfoDto> {
    const naverUrl = this.configService.get<string>('NAVER_USERINFO_API_URL');
    const userRes = await this.externalApi.fetchExternalApiAsHeaders(naverUrl, accessToken);
    const userInfoDto = UserMapper.toCreateUserDto(
        CommonUserInfomationType.NAVER,userRes.data.response
    );
    let user: UserEntity = await this.userService.checkUser(userRes.data.response.id);
    if (!user) {
      user = await this.userService.createUser(userInfoDto);
    }
    return UserMapper.toUserInfoDto(user);
  }

  // AccessToken 생성
  generateAccessToken(userInfo: UserInfoDto): Promise<string> {
    return this.generateToken(userInfo, JwtSubjectType.ACCESS);
  }

  // RefreshToken 생성
  generateRefreshToken(userInfo: UserInfoDto): Promise<string> {
    return this.generateToken(userInfo, JwtSubjectType.REFRESH);
  }

  // 토큰 생성 처리기
  protected async generateToken(
    userInfo: UserInfoDto,
    subject: JwtSubjectType,
  ): Promise<string> {
    const payload =
      subject === JwtSubjectType.ACCESS
        ? { userDto: userInfo }
        : { userNo: userInfo.userNo };
    return this.jwtService.signAsync(payload, {
      secret: process.env.TRIPSEAL_JWT_SECRET,
      expiresIn:
        subject === JwtSubjectType.ACCESS
          ? process.env.TRIPSEAL_JWT_EXPIRES_IN
          : process.env.TRIPSEAL_JWT_REFRESH_EXPIRES_IN,
      subject,
    });
  }

  // AccessToken 재발급
  // cookie에 담긴 토큰과 DB에 담긴 토큰 비교 후, 재발급함
  async refreshAccessToken(refreshToken: string) {
    console.log('쿠아아아:',refreshToken)
    // const payload = this.jwtService.decode(refreshToken.split('=')[1]);
    const payload = this.jwtService.decode(refreshToken);
    if (!payload || !payload.userNo) {
      throw new InvalidTokenException; // 또는 적절한 예외 처리
    }
    // 유저 정보 조회
    const user = await this.userService.userDetail(payload.userNo);
    if (!user) {
      throw new Error('User not found'); // 또는 적절한 예외 처리
    }

    // DB에 저장된 refreshToken과 비교
    if (refreshToken !== user.refreshToken) {
      throw new Error('Token mismatch'); // 또는 적절한 예외 처리
    }
    // 새 AccessToken 생성
    return this.generateAccessToken(this.formatUserInfo(user));
  }

  // 토큰 만료 여부 확인
  compareTokenExpiration(exp: number) {
    const time = new Date().getTime() / 1000;
    const isExpired = exp < time ? true : false;
    return isExpired;
  }

  private formatUserInfo(user: UserEntity): UserInfoDto {
    return {
      userNo: user.userNo,
      provider: CommonUserInfomationType.KAKAO,
      email: user.email,
      name: user.profile.nickname,
      profile: {
        age: user.profile.age,
        gender: user.profile.gender,
        nickname: user.profile.nickname,
        phoneNumber: user.profile.phoneNumber,
        profileImg: user.profile.profileImg,
      },
    };
  }

  async kakaoLogin(code) {
    const kakao_api_url = `https://kauth.kakao.com/oauth/token
  &redirect_url=http://localhost:3001/auth/kakao/callback
  &code=${code}`;
    const toekn_res = await axios.post(kakao_api_url);
    const accessToken = toekn_res.data.access_token;
    return accessToken;
  }
}

그리고 jwt토큰을 통해 guard를 적용시켰다.

guard를 통해, 엑세스토큰이 없거나 만료된 경우에는 유효하지 않는 토큰이어서 앤드포인트에 접근할 수 없다.

guard를 사용하기 위해는 먼저 토큰에 대한 strategy를 만들어줘야 한다.

// app/src/auth/strategy/accessToken.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
import { UserService } from '../../user/user.service';
import {UserInfoDto} from "../../user/dto/userInfo.dto";

@Injectable()
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy, 'accessToken',) {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
    private readonly userService: UserService,
  ) {
    super({
      // request의 쿠키에서 refresh token을 가져옴
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // access toke  n secret key
      secretOrKey: configService.get<string>('TRIPSEAL_JWT_SECRET'),
      // 만료된 토큰은 거부
      ignoreExpiration: false,
      // validate 함수에 첫번째 인자에 request를 넘겨줌
      passReqToCallback: true,
    });
  }

  async validate(
    req: Request,
    payload: {
        userDto:UserInfoDto,
      exp: number;
      lat: number;
      sub: string;
    },
  ) {
    if (!this.authService.compareTokenExpiration(payload.exp)) {
      //req.user에 값이 저장된다.
      return this.userService.userDetail(payload.userDto.userNo);
    } else {
      throw new UnauthorizedException('Access token has been expired.');
    }
  }
}

위에서 return this.userService.userDetail() 로 해줌으로써, 컨트롤러에서 @Req() req 를 통해 req 에서는 앞으로 유저 상세정보를 확인할 수 있다. 만약 다른 정보를 주고 싶다면 DTO를 만들어서 다른 걸로 구성해줄 수 있다.

// app/src/auth/strategy/refreshToken.ts
import {Injectable, UnauthorizedException} from "@nestjs/common";
import {PassportStrategy} from "@nestjs/passport";
import {ExtractJwt, Strategy} from "passport-jwt";
import {ConfigService} from "@nestjs/config";
import {AuthService} from "../auth.service";
import {UserService} from "../../user/user.service";
import {UserProfileDto} from "../../user/dto/userProfile.dto";

@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, "refreshToken") {

    constructor(
        private readonly configService: ConfigService,
        private readonly authService: AuthService,
        private readonly userService: UserService
    ) {
        super({
            // access token strategy와 동일
            jwtFromRequest: ExtractJwt.fromExtractors([
                (request) => {
                    return request?.cookies?.refreshToken}
            ]),
            secretOrKey: configService.get<string>("TRIPSEAL_JWT_SECRET"),
            ignoreExpiration: false,
            passReqToCallback: true
        });
    }

    //jwt 토큰 검사 완료 후
    async validate(
        req: Request,
        payload: {
            userNo?: number;
            email: string;
            name: string;
            provider: string;
            profile: UserProfileDto;
            exp: number;
        },
    ) {
        if (!this.authService.compareTokenExpiration(payload.exp)) {
            return this.userService.userDetail(payload.userNo);
        } else {
            throw new UnauthorizedException('Refresh token has been expired.');
        }
    }
}

리프레시토큰 전략은 액세스토큰을 새로 갱신할 때 사용된다.

이렇게 전략을 생성했으면 guard를 만들어서 컨트롤러에서 데코레이터로 사용할 수 있게끔 해주자.

// app/src/auth/guard/accessToken.guard.ts

import {AuthGuard} from "@nestjs/passport";
import {ExecutionContext, Injectable, Logger} from "@nestjs/common";

@Injectable()
export class JwtAccessTokenGuard extends AuthGuard("accessToken") {
    private readonly logger = new Logger(JwtAccessTokenGuard.name);
    async canActivate(context: ExecutionContext): Promise<boolean> {
        this.logger.log('JwtAccessTokenGuard start');
        // LocalStrategy 실행
        await super.canActivate(context);
        this.logger.log('JwtAccessTokenGuard end');
        return true;
    }
}

// app/src/auth/guard/refreshToken.guard.ts

import {ExecutionContext, Injectable, Logger} from "@nestjs/common";
import {AuthGuard} from "@nestjs/passport";

@Injectable()
export class JwtRefreshTokenGuard extends AuthGuard("refreshToken") {
    private readonly logger = new Logger(JwtRefreshTokenGuard.name);
    async canActivate(context: ExecutionContext): Promise<boolean> {
        this.logger.log('JwtRefreshTokenGuard start');
        // LocalStrategy 실행
        await super.canActivate(context);
        this.logger.log('JwtRefreshTokenGuard end');
        return true;
    }
}

이렇게 생성된 guard는 컨트롤러에서 다음과 같이 사용된다.

  @Get('/validate')
  @UseGuards(JwtAccessTokenGuard)
  async getProfile(@Req() req) {
    // strategy 통과시 req.user 에 return 값이 저장된다.
    return req.user;
  }
반응형