기능 구현

[기능] 소셜로그인 BE (1. 전달받은 토큰 검증)

Fo_rdang 2025. 9. 2. 17:55
반응형

 

첫번째로, APP한테 전달받은 토큰 검증하는 코드를 작성해보자. 

 

핵심

- 구글은 id_token 서명 검증 

- 카카오는 access_token 유효성 검증 + 사용자 ID 확인 

 

자세히

- 구글:id_token=>OAuth2Client로 서명 + aud/iss 검증 

- 카카오:access_token=> Kakao API /v2/user/me 호출로 사용자 ID 확인 

 

1. 환경변수 (.env)

# Google
GOOGLE_CLIENT_ID=xxxxxxxxxxxx-abcdefg.apps.googleusercontent.com

# Kakao (항상 필요한 것)
KAKAO_APP_ID=1234567
KAKAO_API_BASE=https://kapi.kakao.com

# JWT
JWT_ACCESS_SECRET=super-secret-access
JWT_REFRESH_SECRET=super-secret-refresh

JWT_ACCESS_TTL=900s         # 15분
JWT_REFRESH_TTL=30d         # 30일

 

01. google

 

백엔드(NestJS)

  • 보통은 Web 클라이언트 ID만 있으면 충분
  • 왜냐하면 구글에서 발급한 id_token 안에 aud가 Web Client ID로 세팅돼 있기 때문.

프론트(iOS / Android / Web App)

  • 각 플랫폼에 맞는 Client ID를 써서 OAuth 요청을 시작해야 해.
  • React Native(Expo)에서 expo-auth-session 설정할 때 iOS용, Android용 client_id를 지정
  • 웹에서 로그인 버튼 붙일 때는 Web Client ID를 사용.
02. kakao

 

카카오 개발자 콘솔에 들어가면 여러 키가 있어

 

  • 앱 ID (숫자) → 1234567 같은 숫자 ID.
  • REST API 키 → 보통 xxxxxxxxxxxxxxxxxxxxxxxxxxxx 이런 문자열.
  • JavaScript 키 → 웹 SDK에서만 사용.
  • Native 키 → iOS/Android SDK에서 사용.
  • Client Secret (선택) → 보안 강화 옵션 켜면 발급.

🚩 어떤 걸 BE에서 써야 하나?

  • 백엔드에서 토큰 검증:
    • /v1/user/access_token_info 호출 시 응답에 appId가 들어오는데,
    • 이게 내 앱의 "앱 ID(숫자)"랑 같아야 “내 앱에서 발급된 토큰”임을 보장해.
    • 따라서 KAKAO_APP_ID (숫자) 가 반드시 필요해.
  • KAKAO_API_BASE: 
    • 카카오 API 기본 URL.
    • 보통 변하지 않지만, 테스트나 프록시 환경에서 base URL을 바꿀 수 있게 환경변수로 뺀 거야.
    • 실제 호출은 /v2/user/me, /v1/user/access_token_info 같은 엔드포인트로 이 주소를 prefix로 붙여 사용.
  • REST API Key:
    • 백엔드에서 코드 → access_token 교환 할 때 사용.
    • 예: /oauth/token 요청 시 client_id=REST_API_KEY.
    • 즉, FE에서 code를 보내고, BE가 REST_API_KEY로 토큰 교환할 경우에만 필요.
    • 만약 FE가 이미 access_token을 얻어서 BE로 넘겨주는 구조라면, 꼭 필요하지는 않아.
    • 👉 내 구조를 다시 보면:
      지금 FE가 Kakao access_token 받아서 BE에 전달 → BE는 /v2/user/me로 사용자 확인.
      이 경우 KAKAO_APP_ID만 있으면 충분하고, REST_API_KEY는 안 써도 돼.
  • Client Secret:
    • 콘솔에서 “보안 강화” 옵션을 켠 경우에만 필요.
    • 옵션을 켜지 않았다면 비워둬도 된다.
03. 우리 앱 자체 인증 토큰 (jwt)

 

 

JWT_ACCESS_SECRET / JWT_REFRESH_SECRET는 우리 앱의 자체 인증 토큰(JWT)을 서명할 때 쓰는 비밀키야.

 

openssl rand -base64 48 | tr '+/' '-_' | tr -d '='

 

  • SECRET은 길고 랜덤하게 생성해서 .env에만 보관.

 

04. token 

 

1. access token 

역할: API 요청 시 사용자 인증을 위한 토큰 

- 유효기간: 짧음 (15분~2시간)

- 용도: 실제 서비스 API 호출 시 헤더에 포함하여 권한 확인 

- 저장: 메모리나 상대적으로 안전하지 않은 저장소 

- 특징: 자주 사용되므로 탈취 위험이 높아 짧은 수명으로 설정 

 

2. refresh token 

역할: 만료된 access token을 새로 발급받기 위한 토큰 

- 유효기간: 김(보통2주 ~1개월)

- 용도: access token 재발급 전용 

- 저장: 서버 db 또는 안전한 저장소 (HttpOnly 쿠키 등)

- 특징: 자주 사용되지 않아 탈취 위험이 상대적으로 낮음 

 

동작과정 

1. 로그인 성공 

=> 서버가 access token + refresh token 발급 

 

2. api 요청 

=> access token을 헤더에 담아서 요청 

 

3. access token 만료 (15분 후 ) 

=> api 요청 시 401 에러 발생 

 

4. 자동 토큰 갱신 

=> 앱이 refresh token으로 /auth/refresh 호출 

=> 서버가 새로운 access token + 새로운 refresh token 발급 

 

5. 사용자는 아무것도 모름(무중단 갱신)

  • Access 15분이어도 Refresh로 자동 갱신하면 사용자는 거의 로그인 다시 안 해요.
  • 재로그인은 Refresh가 만료/무효일 때만.

 

2. DTO 

// auth/dto/index.ts
export class GoogleDto { idToken!: string }
export class KakaoDto { accessToken!: string }
export class RefreshDto { refreshToken!: string }

 

! 기호는 definite assignment assertion, 확정 할당 단언이다. 

ex) 

- idToken은 반드시 string 타입이다. 

- 이 값은 런타임에 반드시 존재하니, 컴파일러한테 "나중에 무조건 값이 들어올 거니까 걱정하지마" 알려주는 것. 

- 즉, undefined 상태일 수 있다는 오류를 막아주는 역할 

class GoogleDto {
  idToken: string  // ❌ Error: Property 'idToken' has no initializer
}

- 쓰는 이유:  ts가 strict 모드일 때, 클래스 프로퍼티가 생성자에서 초기화되지 않으면 에러를 낸다. (굳이 생성자에서 초기화 안해도 되게됨) 

TypeScript는 기본적으로 클래스의 프로퍼티가 생성자에서 초기화되어야 한다고 요구합니다

class User {
  name: string;
  
  constructor(name: string) {
    this.name = name;  // ✅ 생성자에서 초기화
  }
}

3. UsersService 

사용자 관련 DB 조회/저장/갱신 로직 담당 

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export type Provider = 'google' | 'kakao';

export interface User {
  _id: string;
  provider: Provider;
  providerId: string;  // google sub or kakao id
  email?: string | null;
  name?: string | null;
  picture?: string | null;
  tokenVersion: number;
}

@Injectable()
export class UsersService {
  async findByProvider(provider: Provider, providerId: string): Promise<User | null> { /* ... */ return null; }
  async upsertByProvider(input: Partial<User> & { provider: Provider; providerId: string }): Promise<User> { /* ... */ return {} as any; }
  async bumpTokenVersion(userId: string): Promise<void> { /* ... */ }
}

 

1. Provider 타입 

- 로그인 제공자(provider)를 `문자열 리터럴` 타입으로 정의. 
- google 또는 kakao 중 하나만 올 수 있다는 뜻. 

 

 

2. User 인터페이스

export interface User {
  _id: string;              // MongoDB 같은 DB의 기본 id
  provider: Provider;       // google or kakao 
  providerId: string;       // 구글은 sub 값, 카카오는 id 값 
  email?: string | null;    // 사용자의 이메일 
  name?: string | null;     // 사용자 이름 
  picture?: string | null;  // 프로필 이미지 url 
  tokenVersion: number;     // JWT refresh 토큰 무효화 용도로 쓰임 
}​

 

User 타입을 schema 파일 코드로 수정했다. 

// users/user.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type UserDocument = HydratedDocument<User>;

export type Provider = 'google' | 'kakao';

@Schema({
  timestamps: true,
  collection: 'users',
  // API로 나갈 때 민감/내부 필드 제거
  toJSON: {
    versionKey: false,
    transform: (_doc, ret) => {
      // 내부 식별/보안 관련 값은 숨김
      delete ret.providerId;
      delete ret.tokenVersion;
      return ret;
    },
  },
})
export class User {
  // _id는 선언하지 않음: 기본 ObjectId 사용

  @Prop({ required: true, enum: ['google', 'kakao'] })
  provider!: Provider;

  // Google sub / Kakao id (숫자형이어도 문자열로 저장)
  @Prop({ required: true })
  providerId!: string;

  @Prop()
  name?: string;

  // 이메일은 nullable + 유니크(부분 인덱스). 소셜이 안 줄 수 있으므로 optional 유지
  @Prop({ lowercase: true, trim: true })
  email?: string;

  @Prop()
  avatar?: string;

  @Prop({ unique: true, trim: true })
  nickname?: string;

  // 이메일 검증시각(선택)
  @Prop()
  emailVerifiedAt?: Date;

  // 리프레시 토큰 로테이션용 버전
  @Prop({ default: 0, select: true })
  tokenVersion!: number;

  // 상태/운영용 필드(선택)
  @Prop()
  lastLoginAt?: Date;

  @Prop({ default: true })
  isActive?: boolean;
}

export const UserSchema = SchemaFactory.createForClass(User);

// 고유 식별: provider + providerId
UserSchema.index({ provider: 1, providerId: 1 }, { unique: true });

// nickname 유니크(문자열일 때만)
UserSchema.index(
  { nickname: 1 },
  { unique: true, partialFilterExpression: { nickname: { $type: 'string' } } },
);

// email 유니크(문자열일 때만)
UserSchema.index(
  { email: 1 },
  { unique: true, partialFilterExpression: { email: { $type: 'string' } } },
);

// 닉네임 중복 이슈 줄이려면 대소문자 무시 collation도 고려
// UserSchema.index({ nickname: 1 }, { unique: true, collation: { locale: 'en', strength: 2 } });

 

4. TokenService 

// auth/token.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class TokenService {
  constructor(private jwt: JwtService) {}

  signAccessToken(userId: string) {
    return this.jwt.sign(
      { sub: userId, typ: 'access' },
      { secret: process.env.JWT_ACCESS_SECRET!, expiresIn: process.env.JWT_ACCESS_TTL || '900s' },
    );
  }

  signRefreshToken(userId: string, tokenVersion: number) {
    return this.jwt.sign(
      { sub: userId, tv: tokenVersion, typ: 'refresh' },
      { secret: process.env.JWT_REFRESH_SECRET!, expiresIn: process.env.JWT_REFRESH_TTL || '30d' },
    );
  }

  verifyRefresh(token: string) {
    return this.jwt.verify(token, { secret: process.env.JWT_REFRESH_SECRET! });
  }
}

5. 구글 id_token 검증 + 카카오 access_token 검증 

// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { OAuth2Client } from 'google-auth-library';
import axios from 'axios';
import { UsersService } from '../users/users.service';
import { TokenService } from './token.service';

@Injectable()
export class AuthService {
  private googleClient = new OAuth2Client();

  constructor(
    private users: UsersService,
    private tokens: TokenService,
  ) {}

  // === Google: id_token 서명+aud/iss 검증 ===
  async loginWithGoogle(idToken: string) {
    try {
      const ticket = await this.googleClient.verifyIdToken({
        idToken,
        audience: process.env.GOOGLE_CLIENT_ID!,
      });
      const payload = ticket.getPayload();
      if (!payload) throw new UnauthorizedException('Invalid Google token');

      // iss 안전 확인 (권장)
      const issOk = payload.iss === 'accounts.google.com' || payload.iss === 'https://accounts.google.com';
      if (!issOk) throw new UnauthorizedException('Invalid Google issuer');

      const sub = payload.sub;                 // 고유 사용자 ID
      const email = payload.email ?? null;
      const name = payload.name ?? null;
      const picture = payload.picture ?? null;

      const user = await this.users.upsertByProvider({
        provider: 'google',
        providerId: sub,
        email, name, picture,
      });

      const accessToken = this.tokens.signAccessToken(user._id);
      const refreshToken = this.tokens.signRefreshToken(user._id, user.tokenVersion);

      return { userId: user._id, accessToken, refreshToken };
    } catch (e) {
      throw new UnauthorizedException('Google token verification failed');
    }
  }

  // === Kakao: access_token 유효성 검증 ===
  // 1) /v1/user/access_token_info 로 토큰 유효·소유 App 확인 (appId 매칭)
  // 2) 필요 시 /v2/user/me 로 프로필 추가 조회
  async loginWithKakao(accessToken: string) {
    try {
      // 1) 토큰 유효성 & appId 확인
      const infoRes = await axios.get(
        `${process.env.KAKAO_API_BASE || 'https://kapi.kakao.com'}/v1/user/access_token_info`,
        { headers: { Authorization: `Bearer ${accessToken}` } },
      );
      const { id, appId, expiresIn } = infoRes.data as { id: number; appId: number; expiresIn: number };

      if (!id || !appId) throw new UnauthorizedException('Invalid Kakao token');
      const expectedAppId = Number(process.env.KAKAO_APP_ID);
      if (expectedAppId && appId !== expectedAppId) {
        throw new UnauthorizedException('Kakao token issued for different app');
      }
      if (!expiresIn || expiresIn <= 0) {
        throw new UnauthorizedException('Kakao token expired');
      }

      // 2) (선택) 사용자 프로필 조회
      const meRes = await axios.get(
        `${process.env.KAKAO_API_BASE || 'https://kapi.kakao.com'}/v2/user/me`,
        { headers: { Authorization: `Bearer ${accessToken}` } },
      );
      const kakao = meRes.data;
      const kakaoId = String(kakao.id);
      const profile = kakao.kakao_account || {};
      const email = profile.email ?? null;
      const name = profile.profile?.nickname ?? null;
      const picture = profile.profile?.profile_image_url ?? null;

      const user = await this.users.upsertByProvider({
        provider: 'kakao',
        providerId: kakaoId,
        email, name, picture,
      });

      const access = this.tokens.signAccessToken(user._id);
      const refresh = this.tokens.signRefreshToken(user._id, user.tokenVersion);
      return { userId: user._id, accessToken: access, refreshToken: refresh };
    } catch (e) {
      throw new UnauthorizedException('Kakao token verification failed');
    }
  }

  // === Refresh ===
  async refresh(refreshToken: string) {
    try {
      const payload = this.tokens.verifyRefresh(refreshToken) as { sub: string; tv: number; typ: string };
      if (payload.typ !== 'refresh') throw new UnauthorizedException('Not a refresh token');

      const user = await this.users.findByProvider(/* 무관 */ 'google', ''); // 구현에 맞게 바꿔
      // ↑ 실제로는 userId로 직접 조회:
      // const user = await this.users.findById(payload.sub)

      if (!user) throw new UnauthorizedException('User not found');
      if (user.tokenVersion !== payload.tv) {
        throw new UnauthorizedException('Refresh token revoked');
      }

      const newAccess = this.tokens.signAccessToken(user._id);
      const newRefresh = this.tokens.signRefreshToken(user._id, user.tokenVersion);
      return { accessToken: newAccess, refreshToken: newRefresh };
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

  // (선택) 강제 로그아웃: tokenVersion 증가시켜 기존 refresh 전부 무효화
  async revokeAll(userId: string) {
    await this.users.bumpTokenVersion(userId);
  }
}

 

포인트
  • Google: verifyIdToken + audience=GOOGLE_CLIENT_ID + iss 체크.
  • Kakao: GET /v1/user/access_token_info로 appId 매칭유효/만료 먼저 확인 → GET /v2/user/me로 프로필.
  • 우리 DB에는 (provider, providerId)로 upsert.

최종적으로 우리 서비스용 JWT(access/refresh) 발급.

6. Controller 

// auth/auth.controller.ts
import { Body, Controller, Get, Post, UseGuards, Request } from '@nestjs/common';
import { AuthService } from './auth.service';
import { GoogleDto, KakaoDto, RefreshDto } from './dto';
import { JwtAuthGuard } from './jwt.guard';

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

  @Post('google')
  google(@Body() dto: GoogleDto) {
    return this.auth.loginWithGoogle(dto.idToken);
  }

  @Post('kakao')
  kakao(@Body() dto: KakaoDto) {
    return this.auth.loginWithKakao(dto.accessToken);
  }

  @Post('refresh')
  refresh(@Body() dto: RefreshDto) {
    return this.auth.refresh(dto.refreshToken);
  }

  @UseGuards(JwtAuthGuard)
  @Get('me')
  me(@Request() req: any) {
    return { userId: req.user.sub };
  }
}

7. JWT Guard & Strategy (보호 api 용) 

// auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_ACCESS_SECRET!,
      ignoreExpiration: false,
    });
  }
  async validate(payload: any) {
    // req.user 로 들어감
    return { sub: payload.sub };
  }
}

// auth/jwt.guard.ts
import { AuthGuard } from '@nestjs/passport';
export class JwtAuthGuard extends AuthGuard('jwt') {}

8. 보안 체크리스트 (짧게)

  • HTTPS 강제(앱↔서버 전 구간).
  • 구글은 반드시 aud(=GOOGLE_CLIENT_ID)와 iss 체크.
  • 카카오/access_token_info에서 appId내 앱과 일치하는지 확인.
  • Refresh 토큰 무효화tokenVersion으로 관리(유출 대비).
  • CORScredentials/origin 정확히.
  • 프로덕션은 가능하면 RS256(비대칭키) 사용 및 키 롤링 고려.
반응형