첫번째로, 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으로 관리(유출 대비).
- CORS는 credentials/origin 정확히.
- 프로덕션은 가능하면 RS256(비대칭키) 사용 및 키 롤링 고려.
'기능 구현' 카테고리의 다른 글
| [기능] 소셜로그인 구현 플로우 및 역할(RN + NestJS + MongoDB) (0) | 2025.09.02 |
|---|