[NestJS] Passport/OAuth/JWT

맛없는콩두유·2025년 7월 16일

아아아.. 과연 나는 Auth를 처음부터 만들 수 있을 지에 대해서 고민해보았다. 무엇인가 정리가 안된 느낌이 들어 처음 세팅환경부터 새롭게 정리를 해보았다. 이것을 두고 두고 보면서 뇌에 새기도록 하겠다!!

먼저, 가장 기본이 되는 local에서의 로그인부터 JWT 인증과 OAuth 로그인까지 진행하려고한다.

첫 번쨰 단계: 프로젝트 생성부터 고고씽~

  1. NestJS CLI 설치: npm install -g @nestjs/cli
  2. 새 프로젝트 생성: nest new nestjs-practice
  3. 프로젝트 폴더로 이동: cd nestjs-practice
  4. 기본 의존성 설치 시작

의존성 설치

DB 관련:

npm install @nestjs/typeorm typeorm pg
npm install -D @types/pg

인증 관련:

npm install @nestjs/jwt @nestjs/passport passport passport-local passport-jwt bcrypt
npm install -D @types/passport-local @types/passport-jwt @types/bcrypt

환경 변수 관리:

npm install @nestjs/config

유효성 검사:

npm install class-validator class-transformer

Swagger 관련:

npm install @nestjs/swagger swagger-ui-express
  • src/main.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 전역 유효성 검사 파이프 설정
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  // Swagger 설정
  const config = new DocumentBuilder()
    .setTitle('NestJS Practice API')
    .setDescription('기업 사전과제 연습용 API 문서')
    .setVersion('1.0')
    .addBearerAuth(
      {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT',
        name: 'JWT',
        description: 'JWT 토큰을 입력하세요',
        in: 'header',
      },
      'access-token',
    )
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
  console.log('🚀 Server is running on http://localhost:3000');
  console.log('📚 Swagger documentation: http://localhost:3000/api');
}
bootstrap();

환경변수 설정

New-Item -Path .env -ItemType File
notepad .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_password
DATABASE_NAME=nestjs_practice

JWT_SECRET=your_jwt_secret_key_here
JWT_EXPIRES_IN=1h

PORT=3000

Docker Desktop 설정

  1. 설치확인:
docker --version
docker-compose --version
  1. docker-compose.yml 파일 생성:
New-Item -Path docker-compose.yml -ItemType File
notepad docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15
    container_name: nestjs_postgres
    restart: always
    environment:
      POSTGRES_DB: ~~
      POSTGRES_USER: ~~
      POSTGRES_PASSWORD: ~~
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
  1. PostgreSQL 컨테니이너 시작
docker-compose up -d
  1. 컨테이너 상태 확인
docker-compose ps
  1. DB 연결 확인
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
  1. postgreSQL 콘솔에서 테스트
-- 현재 데이터베이스의 테이블 목록 확인
\dt

-- 현재 연결된 데이터베이스 정보 확인
\conninfo

-- 사용자 목록 확인
\du

-- 도움말 보기
\?

-- PostgreSQL 콘솔 종료
\q

Docker 관리 명령어

# 컨테이너 시작
docker-compose up -d

# 컨테이너 중지
docker-compose down

# 컨테이너 상태 확인
docker-compose ps

# 로그 확인
docker-compose logs postgres

TypeORM 설정

  1. src/app.module.ts 파일 수정
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DATABASE_HOST,
      port: parseInt(process.env.DATABASE_PORT),
      username: process.env.DATABASE_USERNAME,
      password: process.env.DATABASE_PASSWORD,
      database: process.env.DATABASE_NAME,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true, // 개발 환경에서만 사용
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  1. 기본 Entity 생성
  • (src/entites 폴더 생성)
mkdir src/entites
  • User Entity 생성(user.entity.ts)
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column()
  username: string;

  @Column()
  password: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}
  1. 서버 실행 및 테이블 생성 확인
  • 서버 실행
npm run start:dev
  • 다른 터미널에서 PostgreSQL 접속
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
  • 테이블 확인
\dt

인증 시스템 구축

  • 인증모듈 생성
nest generate module auth
nest generate service auth
nest generate controller auth
  • 사용자 모듈 생성
nest generate module user
nest generate service user
nest generate controller user
  • DTO 설정
  1. 회원가입 DTO(src/dto/auth/register.dto.ts)
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';

export class RegisterDto {
  @IsEmail({}, { message: '올바른 이메일 형식을 입력해주세요.' })
  email: string;

  @IsString()
  @MinLength(2, { message: '사용자명은 최소 2자 이상이어야 합니다.' })
  @MaxLength(20, { message: '사용자명은 최대 20자까지 가능합니다.' })
  username: string;

  @IsString()
  @MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
  @MaxLength(20, { message: '비밀번호는 최대 20자까지 가능합니다.' })
  @Matches(
    /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
    { message: '비밀번호는 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.' }
  )
  password: string;
}
}
  1. 로그인 DTO(src/dto/auth/login.dto.ts)
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginDto {
  @ApiProperty({
    example: 'user@example.com',
    description: '사용자 이메일',
  })
  @IsEmail({}, { message: '올바른 이메일 형식을 입력해주세요.' })
  email: string;

  @ApiProperty({
    example: 'Test123!',
    description: '비밀번호',
  })
  @IsString()
  @MinLength(1, { message: '비밀번호를 입력해주세요.' })
  password: string;
}

인증시스템 구현

  1. User
  • User Service
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { RegisterDto } from '../dto/auth/register.dto';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}

  async create(registerDto: RegisterDto): Promise<User> {
    const { email, username, password } = registerDto;

    // 이메일 중복 확인
    const existingUser = await this.userRepository.findOne({
      where: { email },
    });

    if (existingUser) {
      throw new ConflictException('이미 존재하는 이메일입니다.');
    }

    // 비밀번호 암호화
    const hashedPassword = await bcrypt.hash(password, 10);

    // 사용자 생성
    const user = this.userRepository.create({
      email,
      username,
      password: hashedPassword,
    });

    return await this.userRepository.save(user);
  }

  async findByEmail(email: string): Promise<User | null> {
    return await this.userRepository.findOne({ where: { email } });
  }

  async findById(id: number): Promise<User | null> {
    return await this.userRepository.findOne({ where: { id } });
  }
}
  • User Module
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService],
  controllers: [UserController],
  exports: [UserService], // AuthService에서 사용하기 위해 export
})
export class UserModule {}
  1. Auth
  • Auth Service
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { LoginDto } from 'src/dto/auth/login.dto';
import { RegisterDto } from 'src/dto/auth/register.dto';
import { UserService } from 'src/user/user.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private userService: UserService,
    private jwtService: JwtService,
  ) {}

  async register(registerDto: RegisterDto) {
    const user = await this.userService.create(registerDto);

    const { password, ...userData } = user;

    return {
      message: '회원가입이 완료되었습니다.',
      user: userData,
    };
  }

  async login(loginDto: LoginDto) {
    const { email, password } = loginDto;

    const user = await this.userService.findByEmail(email);

    if (!user) {
      throw new UnauthorizedException(
        '이메일 또는 비밀번호가 올바르지 않습니다.',
      );
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);

    if (!isPasswordValid) {
      throw new UnauthorizedException(
        '이메일 또는 비밀번호가 올바르지 않습니다.',
      );
    }

    const payload = { sub: user.id, email: user.email };
    const accessToken = this.jwtService.sign(payload);

    return {
      message: '로그인이 완료되었습니다.',
      accessToken,
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
      },
    };
  }
}
  • Auth Module
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';

@Module({
  imports: [
    UserModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: configService.get<string>('JWT_EXPIRES_IN') },
      }),
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

현재 사용자가 로그인 시 accessToken을 return해주는데, 한 가지 더 보안해야할 사항이 있습니다. 바로 refreshToken도 같이 return을 해주어야 합니다.

왜?? refreshToken이 필요할까~~?

사용자가 앱을 사용하는 중에 토큰이 만료되면?
→ 갑자기 로그인 페이지로 이동
→ 작업 중이던 내용 손실 위험

토큰 수명을 짧게 하면 → 보안 UP, 사용자 경험 DOWN
토큰 수명을 길게 하면 → 보안 DOWN, 사용자 경험 UP

{
  accessToken: "짧은 수명 토큰",    // 15분-1시간
  refreshToken: "긴 수명 토큰"     // 7일-30일
}
  • 동작 원리
// 1. 로그인 성공
// → accessToken(15분) + refreshToken(7일) 발급

// 2. API 요청
// → accessToken으로 인증

// 3. accessToken 만료
// → refreshToken으로 새 accessToken 발급

// 4. 사용자는 모르게 자동 갱신
// → 끊김 없는 사용자 경험
  • 보안 강화
// accessToken 탈취 시
// → 15분 후 자동 만료, 피해 최소화

// refreshToken 탈취 시
// → 서버에서 즉시 무효화 가능
// → 로그아웃 시 DB에서 삭제
  • User Entity에 refreshToken 추가
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

	... 

  @Column({ type: 'varchar', nullable: true, default: null })
  refreshToken: string | null;

}
  • Auth Service 수정(refreshToken 로직 추가)
  async login(loginDto: LoginDto) {
    const { email, password } = loginDto;

	... 

    // 토큰 생성
    const tokens = await this.generateTokens(user.id, user.email);

    // refreshToken을 데이터베이스에 저장
    await this.userService.updateRefreshToken(user.id, tokens.refreshToken);

    return {
      message: '로그인이 완료되었습니다.',
      ...tokens,
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
      },
    };
  }

  async logout(userId: number) {
    // refreshToken 무효화
    await this.userService.updateRefreshToken(userId, null);

    return {
      message: '로그아웃이 완료되었습니다.',
    };
  }

  async refresh(refreshToken: string) {
    try {
      // refreshToken 검증
      const payload = this.jwtService.verify(refreshToken);

      // 사용자 찾기
      const user = await this.userService.findById(payload.sub);
      if (!user || user.refreshToken !== refreshToken) {
        throw new UnauthorizedException('유효하지 않은 토큰입니다.');
      }

      // 새 토큰 발급
      const tokens = await this.generateTokens(user.id, user.email);

      // 새 refreshToken 저장
      await this.userService.updateRefreshToken(user.id, tokens.refreshToken);

      return {
        message: '토큰이 갱신되었습니다.',
        ...tokens,
      };
    } catch (error) {
      console.log(error);
      throw new UnauthorizedException('유효하지 않은 토큰입니다.');
    }
  }

  private async generateTokens(userId: number, email: string) {
    const payload = { sub: userId, email };

    return {
      accessToken: this.jwtService.sign(payload, { expiresIn: '15m' }),
      refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
    };
  }
  • User Service 수정(refreshToken 메서드 추가)
...

async updateRefreshToken(userId: number, refreshToken: string | null): Promise<void> {
  await this.userRepository.update(userId, { refreshToken });
}

여기까지 인증에 대한 Service 구축은 완료했고 실제로 API 엔드포인트를 만들어서 테스트를 진행해보자규~

  • Auth Controller 구현!!
  1. 토큰 갱신 DTO 생성(src/dto/auth/refresh.dto.ts)
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class RefreshDto {
  @ApiProperty({
    example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
    description: 'Refresh Token',
  })
  @IsString()
  refreshToken: string;
}
  1. 로그아웃 DTO 생성(src/dto/auth/logout.dto.ts)
import { IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LogoutDto {
  @ApiProperty({
    example: 1,
    description: '사용자 ID',
  })
  @IsNumber()
  userId: number;
}

실제로 DTO의 property가 한 개일 때는 DTO를 사용하지 않고 매개변수에 원시 타입으로 적는다. 하지만 swagger에서 API 테스트를 위해 한 개 일지라도 DTO를 사용했다.

  1. Auth Controller
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from 'src/dto/auth/register.dto';
import { ApiOperation } from '@nestjs/swagger';
import { LoginDto } from 'src/dto/auth/login.dto';
import { RefreshDto } from 'src/dto/auth/refresh.dto';
import { LogoutDto } from 'src/dto/auth/logout.dto';

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

  @Post('register')
  @ApiOperation({ summary: '회원가입' })
  async register(@Body() registerDto: RegisterDto) {
    return await this.authService.register(registerDto);
  }

  @Post('login')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '로그인' })
  async login(@Body() loginDto: LoginDto) {
    return await this.authService.login(loginDto);
  }

  @Post('logout')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '로그아웃' })
  async logOut(@Body() { userId }: LogoutDto) {
    return await this.authService.logout(userId);
  }

  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '토큰 갱신' })
  async refresh(@Body() { refreshToken }: RefreshDto) {
    return await this.authService.refresh(refreshToken);
  }
}

간단한 테스트를 위해 일단은 useGuard를 사용하지 않고 API 테스트르 해보았다.

자, 현재 logout API를 보면 다른 누군가가 다른 사용자를 로그아웃 시킬 수 있는 구조다. 이것을 막기 위해 JWT Guard가 필요하다!

JWT Guard 구현

  • Passport 설치
npm install @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
  • JWT 전략(Strategy) 생성(src/auth/strategies/jwt.strategy.ts)
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../../user/user.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly userService: UserService,
  ) {
      super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET || 'park_secret_key',
    });
  }

  async validate(payload: any) {
    const user = await this.userService.findById(payload.sub);
    if (!user) {
      throw new UnauthorizedException('토큰이 유효하지 않습니다.');
    }

    return {
      id: user.id,
      email: user.email,
      username: user.username,
    };
  }
}

JWT Strategy는 "JWT 토큰으로 사용자의 신분을 확인하는 역할"
1. 토큰에서 사용자 ID 추출
2. 데이터베이스에서 사용자 찾기
3. 유효한 사용자인지 확인
4. 사용자 정보 반환

  • JWT Guard 생성(src/auth/jwt-auth.guard.ts)
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

JWT Guard는 "API 엔드포인트 앞에서 실제로 막는 역할"

@UseGuards(JwtAuthGuard)  // ← 문지기 배치!
@Post('logout')
async logout() {
  // 여기는 인증된 사용자만 들어올 수 있음!
}
  • Auth Module에 추가(src/auth/auth.module.ts)
import { Module } from '@nestjs/common';
...
import { PassportModule } from '@nestjs/passport';  // ← 추가
import { JwtStrategy } from './strategies/jwt.strategy';  // ← 추가

@Module({
  imports: [
    UserModule,
    PassportModule,  // ← 추가
    JwtModule.registerAsync({
      inject: [ConfigService],
      ...
      }),
    }),
  ],
  providers: [AuthService, JwtStrategy],  // ← JwtStrategy 추가
  controllers: [AuthController],
})
export class AuthModule {}
  • Auth Controller에 userGuards 추가
  @UseGuards(JwtAuthGuard) // 추가
  @ApiBearerAuth('access-token') // swagger에서 Authorize버튼
  @Post('logout')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '로그아웃' })
  async logOut(@Body() { userId }: LogoutDto) {
    return await this.authService.logout(userId);
  }

자, 근데 한가지 문제가 있다, Body로 userId를 받아서 처리하기 보다는 현재 인증된 Token의 Request 정보를 이용해 id를 추출해 그 사용자를 로그아웃 시키면 된다.

  @UseGuards(JwtAuthGuard) // 추가
  @ApiBearerAuth('access-token') // swagger에서 Authorize버튼
  @Post('logout')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '로그아웃' })
  async logOut(@Request() req: AuthRequest) {
    return await this.authService.logout(req.user.id);
  }
  • auth-request.dto.ts 생성
import { Request } from 'express';

// JWT에서 추출된 사용자 정보
export interface AuthUser {
  id: number;
  email: string;
  username: string;
}

// 인증된 Request 타입
export interface AuthRequest extends Request {
  user: AuthUser;
}

Strategy가 해석한 토큰의 정보의 사용자를 반환해 request로 요청한 것을 DTO를 생성

자, 현재까지 Guard의 흐름을 요약해보면 다음과 같다.

  1. Guard가 요청 가로채기
사용자 요청 → JwtAuthGuard → "잠깐! 토큰부터 확인!"
  1. Strategy가 토큰 검증
Guard → JwtStrategy → "이 토큰 유효한가?"
Strategy → DB 조회 → 사용자 정보 반환
  1. req.user에 사용자 정보 주입
Strategy → Guard → Controller
req.user = { id: 1, email: '...', username: '...' }
  1. Controller에서 안전한 처리
async logout(@Request() req) {
  // req.user.id는 토큰에서 검증된 진짜 사용자 ID!
  // 다른 사용자를 로그아웃시킬 수 없음!
  return await this.authService.logout(req.user.id);
}

데이터 조회

자, 현재까지 Swagger에서 register 테스트를 해보았는데, 실제로 데이터가 DB에 삽입이 되었는지 확인해보기 위해서 PostgreSQL에 접속해서 콘솔로 확인해 보도록 하자!

  1. PostgreSQL 콘솔에서 데이터 조회
  • PostgreSQL 접속
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
  • 기본 조회 명령어
-- 모든 테이블 확인
\dt

-- users 테이블 구조 확인
\d users

-- 모든 사용자 조회
SELECT * FROM users;

-- 특정 사용자 조회
SELECT * FROM users WHERE email = 'test@example.com';

-- 사용자 수 확인
SELECT COUNT(*) FROM users;

-- 최근 생성된 사용자 5명
SELECT id, email, username, "createdAt" FROM users 
ORDER BY "createdAt" DESC 
LIMIT 5;

-- 로그아웃한 사용자 (refreshToken이 NULL)
SELECT id, email, username FROM users 
WHERE "refreshToken" IS NULL;

-- 로그인한 사용자 (refreshToken이 있음)
SELECT id, email, username FROM users 
WHERE "refreshToken" IS NOT NULL;

사용자가 잘 조회가 된다!

좀 더 편리한 방법으로는 GUI 도구를 사용하는 것이다.

  1. pgAdmin으로 데이터 조회

pgAdmin을 설치하여 다운로드 후 실행하여 Servers-> Resiter -> server -> General/connection에 입력 후 아래 정보를 서버로 연결한다.

  • Host: localhost
  • Port: 5433 (우리가 설정한 포트)
  • Database: ~~
  • Username: ~~
  • Password: ~~

자, 여기까지 Local 로그인 방식은 완료됐고, 다음으로 Oauth 소셜 로그인 기능까지 구현하고 마무리 해야겠다!

OAuth 소셜 로그인

  1. Google Oauth
  • 패키지 설치
npm install passport-google-oauth20
npm install -D @types/passport-google-oauth20
  • Google Cloud Console 설정
    https://console.cloud.google.com/ 접속
    새 프로젝트 생성: "NestJS Practice"
    API 및 서비스 → OAuth 동의 화면 설정
    사용자 인증 정보 → OAuth 2.0 클라이언트 ID 생성

승인된 리디렉션 URI:

http://localhost:3001/auth/google/callback
  • env 파일에 Google Oauth 추가
GOOGLE_CLIENT_ID=your-google-client-id-here
GOOGLE_CLIENT_SECRET=your-google-client-secret-here
  • Google Strategy 생성(src/auth/strategies/google.strategy.ts)
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(private authService: AuthService) {
     super({
      clientID: process.env.GOOGLE_CLIENT_ID || '',
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
      callbackURL:
        process.env.GOOGLE_CALLBACK_URL ||
        'http://localhost:4000/auth/google/callback',
      scope: ['email', 'profile'],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback,
  ) {
    const { name, emails, photos } = profile;
    const user = {
      email: emails[0].value,
      username: name.givenName + ' ' + name.familyName,
      profileImage: photos[0].value,
      provider: 'google',
      providerId: profile.id,
    };

    const jwtToken = await this.authService.validateOAuthUser(user);
    done(null, jwtToken);
  }
}

다음으로 Oauth용 DTO를 생성하겠다. 나는 google, kakao를 사용할 것 이기 때문에 provider에는 'google' or 'kakao'를 보내면 된다.

  • Oauth DTO
export class OAuthUserDto {
  email: string;
  username: string;
  profileImage?: string;
  provider: string;
  providerId: string;
}
  • User Entity 수정

Oauth를 사용하기 위해서 기존의 User Entity를 수정해야한다.
수정된 항목은 password는 null이 허용되어야한다.
Why?? -> Oauth 사용자는 로그인 시 비밀번호가 없을 수 있기 떄문이다.

추가된 항목은 provider, providerId, profileImage 컬럼이 추가되었다.

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  
  ...
  
  @Column({ nullable: true })
  password: string;
  
  @Column({ default: 'local' }) // 'local', 'google', 'kakao'
  provider: string;

  @Column({ nullable: true })
  providerId: string;

  @Column({ nullable: true })
  profileImage: string;

다음으로 Auth Service에 Oauth 로직을 추가해야한다.

Oauth 로그인 시 기존에 사용중인 이메일의 local 사용자가 있으면 해당 사용자를 Oauth 사용자로 update 해야하고, 그렇지 않으면 신규 Oauth 사용자로 create 하면 될 것이고. 그리고 JWT 토큰을 생성해서 return 하면 될 것이다.

  • Auth Service 수정
// 기존 imports에 추가
import { OAuthUserDto } from '../dto/auth/oauth.dto';

@Injectable()
export class AuthService {
  // ... 기존 메서드들

  // OAuth 사용자 검증/생성
  async validateOAuthUser(oauthUser: OAuthUserDto) {
    const { email, username, profileImage, provider, providerId } = oauthUser;

    // 기존 사용자 찾기 (이메일로)
    let user = await this.userService.findByEmail(email);

    if (user) {
      // 기존 사용자 - provider 정보 업데이트
      if (user.provider === 'local') {
        await this.userService.updateProvider(user.id, provider, providerId, profileImage);
      }
    } else {
      // 신규 사용자 - OAuth 사용자 생성
      user = await this.userService.createOAuthUser({
        email,
        username,
        provider,
        providerId,
        profileImage,
      });
    }

    // JWT 토큰 생성
    const tokens = await this.generateTokens(user.id, user.email);

    // refreshToken 저장
    await this.userService.updateRefreshToken(user.id, tokens.refreshToken);

    return {
      message: `${provider} 로그인이 완료되었습니다.`,
      ...tokens,
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
        profileImage: user.profileImage || null,
      },
    };
  }

  // ... 기존 generateTokens 메서드
}

그리고나서 UserService에서 Oauth 메서드를 추가해야한다.
OAuth로 로그인 한 사용자를 Create하는 메서드인 createOauthUser와
OAUth로 사용자 정보를 Update하는 메서드인 updateProvider 메서드를 구현해야한다.

  • User Service 수정
// 기존 imports에 추가
import { OAuthUserDto } from '../dto/auth/oauth.dto';

@Injectable()
export class UserService {
  // ... 기존 메서드들

  // OAuth 사용자 생성
  async createOAuthUser(oauthData: OAuthUserDto): Promise<User> {
    const user = this.userRepository.create({
      email: oauthData.email,
      username: oauthData.username,
      provider: oauthData.provider,
      providerId: oauthData.providerId,
      profileImage: oauthData.profileImage,
      password: null, // OAuth 사용자는 비밀번호 없음
    });

    return await this.userRepository.save(user);
  }

  // Provider 정보 업데이트
  async updateProvider(
    userId: number, 
    provider: string, 
    providerId: string, 
    profileImage?: string
  ) {
    const updateData: any = { provider, providerId };
    if (profileImage) {
      updateData.profileImage = profileImage;
    }

    await this.userRepository.update(userId, updateData);
  }

  // ... 기존 메서드들
}

지금 까지 작성한 코드 중 문제점이 하나 있다!
OAuth 로그인 시 password는 null인 상태이다.
현재 Auth Service에서 login 메서드를 보면


  // 비밀번호 확인
  const isPasswordValid = await bcrypt.compare(password, user.password);
  if (!isPasswordValid) {
    throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
  }

user의 password와 compare하고 있는데, user의 password는 optional하다. OAuth 사용자인 경우에는 null이기 떄문에 에러가 발생한다. 따라서, 아래의 코드를 추가해준다.

또, Resiter 메서드에서 OAUth 시 이미 가입된 Local 사용자 체크도 해준다.

  • Auth Service 수정
async login(loginDto: LoginDto) {
  ...

  // OAuth 사용자 체크 (비밀번호가 없는 경우)
  if (user.provider !== 'local') {
    throw new UnauthorizedException('소셜 로그인으로 가입된 계정입니다. 소셜 로그인을 이용해주세요.');
  }

  // 비밀번호가 null인 경우 체크
  if (!user.password) {
    throw new UnauthorizedException('비밀번호가 설정되지 않은 계정입니다.');
  }

  // 비밀번호 확인
  const isPasswordValid = await bcrypt.compare(password, user.password);
  if (!isPasswordValid) {
    throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
  }
  
async register(registerDto: RegisterDto) {
  const { email } = registerDto;

  const existingUser = await this.userService.findByEmail(email);

  if (existingUser) {
    if (existingUser.provider !== 'local' || existingUser.provider !== null) {
      throw new ConflictException('이미 존재하는 이메일입니다.');
    }
    throw new ConflictException('이미 존재하는 이메일입니다.');
  }
  
  ...
  • User Entity 수정
    현재 password: string | null이 TypeORM에서는 이를 "Object" 타입으로 인식하는데, PostgreSQL은 "Object" 타입을 지원하지 않기 떄문에 오류가 난다.

명시적 타입 지정으로 해결행야한다.

  @Column({ 
    type: 'varchar',     // ← 명시적 타입 지정
    nullable: true,      // ← NULL 허용
    default: null        // ← 기본값 설정
  })
  password: string | null;

  @Column({ 
    type: 'varchar',     // ← 명시적 타입 지정
    default: 'local' 
  })
  provider: string;

  @Column({ 
    type: 'varchar',     // ← 명시적 타입 지정
    nullable: true 
  })
  providerId: string | null;

  @Column({ 
    type: 'varchar',     // ← 명시적 타입 지정
    nullable: true 
  })
  profileImage: string | null;
  • Auth Module에 Google Strategy 등록
...
import { GoogleStrategy } from './strategies/google.strategy';

@Module({
...
providers: [AuthService, JwtStrategy, GoogleStrategy],
})

자, 전체적인 Google OAuth 로그인 플로우를 설명해보면 다음과 같다.

1. 사용자가 "Google 로그인" 클릭
   ↓
2. GET /auth/google 호출
   ↓
3. GoogleStrategy가 Google OAuth 페이지로 리디렉션
   ↓
4. 사용자가 Google에서 로그인/권한 승인
   ↓
5. Google이 /auth/google/callback으로 리디렉션
   ↓
6. GoogleStrategy가 사용자 정보 받아서 validateOAuthUser 호출
   ↓
7. AuthService에서:
   - 기존 사용자 있으면 → provider 정보 업데이트
   - 기존 사용자 없으면 → 새 사용자 생성 (자동 회원가입!)
   ↓
8. JWT 토큰 발급 후 응답

로그인 후 응답값이 잘 보이는 것이 확인이 된다!

  1. Kakao OAuth
  • 패키지 설치
npm install passport-kakao
npm install -D @types/passport-kakao


동의 항목 설정에서 필수 항목을 설정해주어야 닉네임, 프로필 사진을 가져올 수 있다.

앱-> 일반 -> 앱 키-> REST API 키
= KAKAO_CLIENT_ID
카카오 로그인-> 일반 -> Client Secret
= KAKAO_CLIENT_SECRET

KAKAO_CLIENT_ID=your-kakao-client-id
KAKAO_CLIENT_SECRET=your-kakao-client-secret
KAKAO_CALLBACK_URL=http://localhost:3001/auth/kakao/callback

.env에 위 항목을 작성하면 된다.

  • Kakao Strategy 생성(src/auth/strategies/kakao.strategy.ts)
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-kakao';
import { AuthService } from '../auth.service';

@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
  constructor(private authService: AuthService) {
    super({
      clientID: process.env.KAKAO_CLIENT_ID || '',
      clientSecret: process.env.KAKAO_CLIENT_SECRET || '',
      callbackURL:
        process.env.KAKAO_CALLBACK_URL ||
        'http://localhost:3001/auth/kakao/callback',
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: any,
  ) {
    const { id, username, _json } = profile;

    // Kakao에서 제공하는 정보
    const user = {
      email: _json.kakao_account?.email || `kakao_${id}@kakao.com`, // 이메일이 없을 수 있음
      username: username || _json.properties?.nickname || `KakaoUser_${id}`,
      profileImage: _json.properties?.profile_image || null,
      provider: 'kakao',
      providerId: id.toString(),
    };

    const jwtToken = await this.authService.validateOAuthUser(user);
    done(null, jwtToken);
  }
}

  • Auth Module에 Kakao Strategy 추가
@Module({
  
... 
providers: [AuthService, JwtStrategy, GoogleStrategy, KakaoStrategy],  // ← KakaoStrategy 추가
  • Auth Controller에 Kakao 엔드포인트 추가
  // Kakao OAuth 추가
  @Get('kakao')
  @UseGuards(AuthGuard('kakao'))
  @ApiOperation({ summary: 'Kakao 로그인' })
  async kakaoAuth() {
    // Kakao로 리디렉션
  }

  @Get('kakao/callback')
  @UseGuards(AuthGuard('kakao'))
  @ApiOperation({ summary: 'Kakao 로그인 콜백' })
  kakaoCallback(@Request() req: any) {
    return req.user;
  }

여기까지 OAuth 로그인이 잘 작동되어지는 것을 볼 수 있다.

profile
하루하루 기록하기!

0개의 댓글