Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Server] 0.4.5 배포 #179

Merged
merged 30 commits into from
Dec 4, 2023
Merged
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
86520a7
[Server] 개발환경 세팅 (#4)
pminsung12 Nov 9, 2023
8fe9ba6
Server/feature/#13 (#25)
pminsung12 Nov 14, 2023
2d84f75
테스트용 유저 API 구현 (#30)
yangdongsuk Nov 15, 2023
2b6fb53
[Server] 유닛 테스트 환경 세팅 (#32)
yangdongsuk Nov 15, 2023
112d9ba
[Server] Users resource 이름 변경 (#34)
yangdongsuk Nov 15, 2023
375e785
feature: usersService 테스트 코드 작성 (#39)
yangdongsuk Nov 16, 2023
5b230ec
[Server] Folder entity 생성 및 crud 구현 (#42)
pminsung12 Nov 16, 2023
da9f6d3
feature: docker파일 수정 (#57)
yangdongsuk Nov 20, 2023
e4936cb
feat: private checklist entity 생성 및 crud 구현 (#61)
pminsung12 Nov 21, 2023
5cfdb02
feat: checklist 폴더 분리 & dto 빈문자열 검증 추가 (#66)
yangdongsuk Nov 22, 2023
de6b5f3
[Server] Winston으로 로그 관리 (#70)
pminsung12 Nov 23, 2023
f795888
feat: jwt access, refresh token 기반 인가 구현
yangdongsuk Nov 23, 2023
304ea79
[Server] shared-checklist 소켓 구현 (#78)
yangdongsuk Nov 23, 2023
ff9598d
[Server] apple oauth api 구현 (#86)
pminsung12 Nov 26, 2023
4616d27
[Server] access 토큰 재발급시 유저 정보 없는 버그 수정 (#83)
yangdongsuk Nov 26, 2023
7091fad
[Server] privateChecklist의 내용 저장 api 구현 (#88)
yangdongsuk Nov 27, 2023
7f88671
[Server] apple oauth 로그인 로직 수정 (#118)
pminsung12 Nov 27, 2023
fd3703c
[Server] Clova Studio api 구현 (#126)
pminsung12 Nov 29, 2023
ac8ab73
feat: AccessTokenGuard 구현 및 적용 (#129)
yangdongsuk Nov 29, 2023
70043ff
Server/feature/#128 (#139)
pminsung12 Nov 29, 2023
2b1eaf8
feat: 공유 체크리스트 API 및 소켓 작업 구현 (#140)
yangdongsuk Nov 30, 2023
283e6fa
🔐feat: 개발용 임시로 액세스,리프레시 토큰들 만료기한 일주일로 설정 (#142)
pminsung12 Nov 30, 2023
5e5fd4e
feat: 공유 체크리스트 아이템 권한 문제 및 uuid 문제 해결 (#146)
yangdongsuk Nov 30, 2023
adc3269
feat: 소켓 다중 서버 지원 (#159)
yangdongsuk Dec 4, 2023
347cb90
feat: 소켓 editing 이벤트 추가 (#164)
yangdongsuk Dec 4, 2023
620c06d
fix: 레디스 연결 수정 (#168)
yangdongsuk Dec 4, 2023
9240dde
[Server] object 형태가 들어오면 redis에 저장 안되는 문제 수정 (#171)
pminsung12 Dec 4, 2023
7ae37bb
feat: 웹소켓 히스토리 버그 수정 및 콘솔 로그 추가 (#175)
yangdongsuk Dec 4, 2023
d047b41
[Server] 로거 기능 확대 (#176)
pminsung12 Dec 4, 2023
55ade94
🐛fix: redis module password 부분 주석처리 (#180)
pminsung12 Dec 4, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
[Server] apple oauth 로그인 로직 수정 (#118)
* feat: env 사용방식 변경 + idToken 검증로직 추가

* chore: jwk를 pem으로 변환하기 위한 jose 라이브러리 설치

* chore: jose 라이브러리 제거 @panva/jose 설치

* feat: request body로 들어오는 auth-user.dto.ts 수정

* feat: 애플 유저 등록 로직 수정

* docs: jsdoc return type 수정

* feat: apple login 로직 수정(appleToken, clientSecret 로직 삭제)

* feat: refreshAccessToken 함수에서 refreshToken도 함께 반환해주도록 로직 수정
pminsung12 authored Nov 27, 2023
commit 7f8867160e904ade4c699c4cb7508586e3bdb073
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@
"@nestjs/platform-ws": "^10.2.10",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.10",
"@panva/jose": "^1.9.3",
"axios": "^1.6.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
16 changes: 10 additions & 6 deletions server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -14,35 +14,39 @@ export class AuthController {
* @returns {accessToken, refreshToken}
*/
@Post('apple/login')
async postAppleLogin(@Body() dto: AuthUserDto) {
async postAppleLogin(
@Body() dto: AuthUserDto,
): Promise<{ accessToken: string; refreshToken: string }> {
return await this.authService.registerOrLoginWithApple(dto);
}

/**
* 이메일과 프로바이더를 통해 로그인한다. (개발자용)
* @param user {loginUserDto}
* @returns {accessToken,refreshAccessToken}
* @returns {accessToken,refreshToken}
*/
@Post('login')
async postLogin(@Body() user: loginUserDto) {
return await this.authService.loginWithEmailAndProvider(user);
}

/**
* refresh 토큰을 통해 access 토큰을 재발급한다.
* refresh 토큰을 통해 access 토큰과 refresh 토큰을 재발급한다
* @param rawToken
* @returns {accessToken,refreshAccessToken}
* @returns {accessToken, refreshToken}
*/
@Post('token/access')
postAccessToken(@Headers('authorization') rawToken: string) {
postAccessToken(
@Headers('authorization') rawToken: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const token = this.authService.extractTokenFromHeader(rawToken);
return this.authService.refreshAccessToken(token);
}

/**
* 이메일과 프로바이더를 통해 회원가입한다. (개발자용)
* @param user {registerUserDto}
* @returns {accessToken,refreshAccessToken}
* @returns {accessToken,refreshToken}
*/
@Post('register')
postRegister(@Body() user: registerUserDto) {
203 changes: 91 additions & 112 deletions server/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,91 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createPublicKey } from 'crypto';
import { JWK } from '@panva/jose';
import axios from 'axios';
import * as fs from 'fs';
import * as jwt from 'jsonwebtoken';
import * as querystring from 'querystring';
import { CreateUserDto } from 'src/users/dto/create-user.dto';
import { ProviderType, UserModel } from 'src/users/entities/user.entity';
import { UsersService } from 'src/users/users.service';
import { AuthUserDto } from './dto/auth-user.dto';
import { loginUserDto } from './dto/login-user.dto';

type TokenType = 'access' | 'refresh';

@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}

/**
* 애플 서버로부터 액세스 토큰과 리프레시 토큰을 받아옵니다.
* @param authorizeCode 클라이언트로부터 받은 애플 인증 코드
* @returns 애플로부터 받은 토큰들
* Apple ID 토큰을 검증합니다.
* @param idToken Apple ID 토큰
// * @param expectedNonce 클라이언트에서 전달된 nonce 값
* @returns {Promise<jwt.JwtPayload>}
*/
async getAppleTokens(authorizeCode: string) {
try {
// 클라이언트 시크릿 생성
const clientSecret = this.generateClientSecret();

// 애플 서버에 토큰 요청
const response = await axios.post(
'https://appleid.apple.com/auth/token',
querystring.stringify({
grant_type: 'authorization_code',
code: authorizeCode,
client_secret: clientSecret,
client_id: process.env.SUB,
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
async verifyAppleIdToken(idToken: string): Promise<jwt.JwtPayload> {
const decodedTokenHeader = jwt.decode(idToken, { complete: true }).header;

// 애플이 발급해준 access_token과 refresh_token 반환
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
idToken: response.data.id_token,
};
} catch (error) {
throw new UnauthorizedException(
'애플 인증 과정에서 오류가 발생했습니다.',
);
// Apple 공개키 가져오기
const applePublicKey = await this.getApplePublicKey(decodedTokenHeader.kid);

// Apple ID 토큰 검증
const decodedIdToken = jwt.verify(idToken, applePublicKey, {
algorithms: ['RS256'],
}) as jwt.JwtPayload;

if (!decodedIdToken) {
throw new UnauthorizedException('ID 토큰 디코드 오류');
}

// 'iss' 필드 검증
if (decodedIdToken.iss !== 'https://appleid.apple.com') {
throw new UnauthorizedException('잘못된 issuer');
}

// 'aud' 필드 검증
if (decodedIdToken.aud !== process.env['SUB']) {
throw new UnauthorizedException('잘못된 audience');
}

// // nonce 검증
// if (decodedIdToken.nonce !== expectedNonce) {
// throw new UnauthorizedException('Nonce 값이 일치하지 않습니다.');
// }

return decodedIdToken;
}

async getApplePublicKey(kid: string): Promise<string> {
/**
* Apple 공개 키를 가져옵니다.
* @param {string} kid
* @returns {Promise<string | Buffer>}
*/
async getApplePublicKey(kid: string) {
try {
// Apple 의 공개 키를 JWK 형식으로 가져오기
const response = await axios.get('https://appleid.apple.com/auth/keys');

// 일치하는 kid 를 가진 키를 찾기
const keys = response.data.keys;
const matchingKey = keys.find((key) => key.kid === kid);

if (!matchingKey) {
throw new Error('Matching key not found.');
}

return matchingKey;
//@panva/jose 라이브러리의 JWK.asKey 메소드를 사용하여 JWK 객체를 생성하기
const jwk = JWK.asKey(matchingKey);

// Node.js의 crypto 모듈의 createPublicKey 함수를 사용하여 JWK를 PEM 형식으로 변환하기
const pem = createPublicKey(jwk.toPEM()).export({
type: 'pkcs1',
format: 'pem',
});

return pem;
} catch (error) {
console.error('Apple public key 가져오기 실패:', error);
throw new UnauthorizedException(
@@ -74,77 +95,28 @@ export class AuthService {
}

/**
* 클라이언트 시크릿을 생성합니다.
* @returns 생성된 클라이언트 시크릿
* Apple 로그인/등록을 한번에 처리한다.
* @param {AuthUserDto} authUserDto
* @returns {Promise<{accessToken: string, refreshToken: string}>}
*/
private generateClientSecret(): string {
const algorithm = process.env.ALG as jwt.Algorithm; // 타입 캐스팅
const keyid = process.env.KID;
const issuer = process.env.ISS;
const expiresIn = 15777000; // 6개월 (초 단위)
const audience = 'https://appleid.apple.com';
const subject = process.env.SUB;
const authKey = fs.readFileSync(process.env.AUTHKEY, 'utf8');

const signOptions: jwt.SignOptions = {
algorithm: algorithm,
keyid: keyid,
issuer: issuer,
audience: audience,
subject: subject,
expiresIn: expiresIn,
};

return jwt.sign({}, authKey, signOptions);
}

async registerOrLoginWithApple(
authUserDto: AuthUserDto,
): Promise<{ accessToken: string; refreshToken: string }> {
const { authorizationCode, user: userDto } = authUserDto;

// 애플 서버로부터 액세스 토큰과 리프레시 토큰을 받아오기
const appleTokens = await this.getAppleTokens(authorizationCode);

if (!appleTokens.idToken) {
throw new UnauthorizedException('ID 토큰이 없습니다.');
}

const decodedTokenHeader = jwt.decode(appleTokens.idToken, {
complete: true,
}).header;
// 애플 공개키를 가져오기
const applePublicKey = await this.getApplePublicKey(decodedTokenHeader.kid);

// 애플 액세스 토큰을 디코드
let decodedIdToken;
try {
decodedIdToken = jwt.verify(appleTokens.accessToken, applePublicKey, {
algorithms: ['RS256'],
});
} catch (error) {
throw new UnauthorizedException('애플 토큰 디코드 오류');
}
const { identityToken, fullName } = authUserDto;

if (!decodedIdToken || typeof decodedIdToken === 'string') {
throw new UnauthorizedException('애플 토큰 디코드 오류');
}
// Apple ID 토큰 검증
const decodedIdToken = await this.verifyAppleIdToken(identityToken);

const fullName = `${userDto.name.firstName} ${userDto.name.lastName}`;
let user = await this.usersService.findUserByAppleId(decodedIdToken.sub);

if (!user) {
// 사용자가 존재하지 않으면 새로 생성
user = await this.usersService.createAppleUser({
providerId: decodedIdToken.sub,
provider: ProviderType.APPLE,
email: userDto.email,
fullName,
});
} else if (userDto) {
// entity가 존재하는데, user정보가 왔다면 업데이트
user = await this.usersService.updateAppleUser(user.userId, {
email: userDto.email,
fullName,
email: decodedIdToken.email, // 이메일은 decodedIdToken에서 추출
fullName: fullName || '',
nickname: fullName || '',
});
}

@@ -178,46 +150,49 @@ export class AuthService {
tokenType,
};
return this.jwtService.sign(payload, {
secret: process.env.JWT_SECRET,
secret: process.env['JWT_SECRET'],
expiresIn: tokenType === 'access' ? 300 : 3600,
});
}

/**
* 토근을 검증한다. 검증에 실패하면 UnauthorizedException을 발생시킨다.
* 토근을 검증한다. 검증에 실패하면 UnauthorizedException 을 발생시킨다.
* @param token
* @returns 토근에 담긴 정보
* @returns payload
*/
verifyToken(token: string) {
try {
return this.jwtService.verify(token, {
secret: process.env.JWT_SECRET,
secret: process.env['JWT_SECRET'],
});
} catch (error) {
throw new UnauthorizedException('토큰이 만료되었거나 잘못된 토큰입니다.');
}
}

/**
* refresh 토큰을 통해 access 토큰을 재발급한다.
* refresh 토큰을 통해 access 토큰과 refresh 토큰을 재발급한다.
* @param refreshToken
* @returns 새로 발급된 access 토큰
* @returns { accessToken: string, refreshToken: string}
*/
refreshAccessToken(refreshToken: string) {
const payload = this.verifyToken(refreshToken);
async refreshAccessToken(refreshToken: string) {
const payload = await this.verifyToken(refreshToken);

if (payload.tokenType !== 'refresh') {
throw new UnauthorizedException(
'access토큰 재발급은 refresh 토큰으로만 가능합니다.',
);
}
const accessToken = this.signToken({ ...payload }, 'access');
return { accessToken };
const newAccessToken = this.signToken({ ...payload }, 'access');
const newRefreshToken = this.signToken({ ...payload }, 'refresh');

return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

/**
* 이메일과 provider를 통해 유저를 인증한다.
* 없는 이메일이면 UnauthorizedException을 발생시킨다.
* provider가 다르면 UnauthorizedException을 발생시키고 어떤 provider로 가입되어 있는지 알려준다.
* 이메일과 provider 를 통해 유저를 인증한다.
* 없는 이메일이면 UnauthorizedException 을 발생시킨다.
* provider 가 다르면 UnauthorizedException 을 발생시키고 어떤 provider 로 가입되어 있는지 알려준다.
* @param user
* @returns existUser
*/
@@ -235,19 +210,21 @@ export class AuthService {
}

/**
* 이메일과 provider를 통해 유저를 인증하고 토큰을 발급한다.
* 이메일과 provider 를 통해 유저를 인증하고 토큰을 발급한다.
* @param user
* @returns { accessToken: string, refreshToken: string}
*/
async loginWithEmailAndProvider(user: loginUserDto) {
async loginWithEmailAndProvider(
user: loginUserDto,
): Promise<{ accessToken: string; refreshToken: string }> {
const existUser = await this.authenticateWithEmailAndProvider(user);
return this.loginUser(existUser);
}

/**
* 헤더에서 토큰을 추출한다.
* @param header
* @returns 토큰
* @returns token
*/
extractTokenFromHeader(header: string) {
// 정규식을 사용하여 'Bearer' 토큰 추출
@@ -261,11 +238,13 @@ export class AuthService {
}

/**
* 이메일과 provider를 통해 유저를 등록하고 토큰을 발급한다.
* 이메일과 provider 를 통해 유저를 등록하고 토큰을 발급한다.
* @param user
* @returns { accessToken: string, refreshToken: string}
*/
async registerUser(user: CreateUserDto) {
async registerUser(
user: CreateUserDto,
): Promise<{ accessToken: string; refreshToken: string }> {
const newUser = await this.usersService.createUser(user);
return this.loginUser(newUser);
}
Loading