-
Notifications
You must be signed in to change notification settings - Fork 2
[재하] 1116(목) 개발기록
- 리뷰 피드백 반영한 것 충돌 없이 PR merge
- board, auth CRUD 완성
- docker로 FE, BE 모두 배포해보기, nginx 추가
- nginx 역할 : FE 빌드해서 적정 파일 서빙, BE 리버스 프록시로 /api 경로에 매핑
- 가능하다면 docker-compose와 github actions로 자동 배포도 시도하기
- 갈라진 branch, 충돌 해결해서 rebase하기
- GET /board (#84 [06-13])
- GET /board/by-author (#65 [09-03])
- PATCH /board/:id/like (#45 [06-08])
- PATCH /board/:id/unlike (#45 [06-08])
- PATCH /board/:id (#88 [06-14])
- DELETE /board/:id (#89 [06-15])
- auth 모듈과 관련 e2e 테스트 구상
- POST /auth/signup
- POST /auth/signin
- GET /auth/signout
- GET /auth/is-available-username, GET /auth/is-available-nickname
어제 PR을 두 개 날렸는데, 첫 PR의 리뷰 피드백에서 수정소요가 발생해서 브랜치가 갈라졌다.
그런데 두 번째 PR이 같은 파일에서 수정이 일어나 자동 rebase가 안돼서 충돌을 해결해서 보내야 하는 상황!
그렇다고 위 그림과 같이 merge를 해서 보내면 커밋 로그들이 안남고 master에 merge 커밋만 남는다. 이러면 안되니 rebase를 하긴 해야함
요런식으로!
어떻게 하냐면 위 그림도 일단 git rebase를 걸어놓고 충돌 해결 후 git add, 그리고 그 후에 git rebase --continue를 진행하라고 한다.
첫 PR에서 수정한 코드들은 두번째 PR 뒤에도 반영되어야 하므로 이전에 했던 --skip 방식을 쓰면 안된다.
rebase --skip을 통한 충돌 해결 사례는 이전에 작성한 다음 포스팅 참고.
git switch [B]
git rebase [A]
# IDE에서 직접 충돌 해결 후
git add .
git rebase --continue
아무튼 대략적으로 A에 추가된 커밋를 B에 rebase시키는 흐름은 위와 같다. 해보자.
??
?????
??????? 뭐야 충돌 없이 이전 커밋들도 반영 잘 되네? 뭐임 ㅋ
설레발치고 온갖소리 다했지만 git이 결국 파일이 아닌 line-by-line으로 충돌을 검사해서 rebase 명령만으로 알아서 처리가 되나보다 ㅋ.. 쉽게 문제 해결.
그래도 앞으로 충돌이 나면 그때 위에서 조사한 방식으로 해결하면 될듯. 끝!
참고로 push는 force로 해주셔야 함
git push -f origin feat/get-board-by-id
# 이후 PR에 commit들 바뀌어있나 확인
// board.e2e-spec.ts
// (추가 필요) 서버는 사용자의 글 목록을 전송한다.
it('GET /board', async () => {
const response = await request(app.getHttpServer()).get('/board').expect(200);
expect(response).toHaveProperty('body');
expect(response.body).toBeInstanceOf(Array);
const boards = response.body as Board[];
if (boards.length > 0) {
expect(boards[0]).toHaveProperty('id');
expect(boards[0]).toHaveProperty('title');
}
});
위와 같이 테스트 코드를 작성해줬다!
실패하는 테스트 코드 완성
repository에서 가져와서 board 리스트를 가져와서 넘겨주는 service코드를 작성한다.
// board.service.ts
async findAll() {
const boards = await this.boardRepository.find();
return boards;
}
통과!
// board.controller.ts
@Get()
findAllBoards(): Promise<Board[]> {
return this.boardService.findAllBoards();
}
// board.service.ts
async findAllBoards(): Promise<Board[]> {
const boards = await this.boardRepository.find();
return boards;
}
타입 명시 및 메소드명 변경
마찬가지로 잘 통과되고
postman에서 리스트도 한 번 확인해봄
author를 하드코딩 할 게 아니라 직접 넣어줘야 하는게 아닐까 하는 논의가 나와서,
만들어둔 POST /board
메소드를 이용해서 앞단에 직접 특정 author의 게시물을 넣어줬다.
근데 그러면 이게 찾아진 게 해당 author인지 확인하는 것도 넣어야 하는 게 아닌가?
그런식으로 또 GET /board/:id
메소드를 이용해서 author 확인도 해주고 그래보았다.
// #65 [09-03] 서버는 검색된 사용자의 글 데이터를 전송한다.
it('GET /board/by-author', async () => {
const author = 'testuser';
const board = {
title: 'test',
content: 'test',
author,
};
await request(app.getHttpServer()).post('/board').send(board).expect(201);
const response = await request(app.getHttpServer())
.get(`/board/by-author?author=${author}`)
.expect(200);
expect(response).toHaveProperty('body');
expect(response.body).toBeInstanceOf(Array);
const boards = response.body as Board[];
if (boards.length > 0) {
expect(boards[0]).toHaveProperty('id');
expect(boards[0]).toHaveProperty('title');
}
const id = boards[0].id;
const response2 = await request(app.getHttpServer())
.get(`/board/${id}`)
.expect(200);
expect(response2.body.author).toBe(author);
});
근데 이런 식이면 TDD라기 보단 그냥 한도끝도 없을 것 같아서, 구현을 위한 테스트라는 본질이 흐려지지 않게 적절히 상한을 둘 필요가 있다고 판단됨.
결국 다음으로 합의
// board.e2e-spec.ts
// #65 [09-03] 서버는 검색된 사용자의 글 데이터를 전송한다.
it('GET /board/by-author', async () => {
const author = 'testuser';
const board = {
title: 'test',
content: 'test',
author,
};
await request(app.getHttpServer()).post('/board').send(board);
const response = await request(app.getHttpServer())
.get(`/board/by-author?author=${author}`)
.expect(200);
expect(response).toHaveProperty('body');
expect(response.body).toBeInstanceOf(Array);
const boards = response.body as Board[];
expect(boards.length).toBeGreaterThan(0);
expect(boards[0]).toHaveProperty('id');
expect(boards[0]).toHaveProperty('title');
});
// board.controller.ts
@Get('by-author')
findAllBoardsByAuthor(@Query('author') author: string): Promise<Board[]> {
return this.boardService.findAllBoardsByAuthor(author);
}
쿼리 파라미터는 위와 같이 가져올 수 있다. 학습메모 5 참고~
// board.service.ts
async findAllBoardsByAuthor(author: string): Promise<Board[]> {
const boards = await this.boardRepository.findBy({ author });
return boards;
}
구현 과정에서 타입을 명시해서 리팩터 과정은 딱히 필요없는걸로!
// board.e2e-spec.ts
it('PATCH /board/:id/like', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const createdBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
expect(createdBoard).toHaveProperty('like_cnt');
const cntBeforeLike = createdBoard.like_cnt;
const resLike = await request(app.getHttpServer())
.patch(`/board/${createdBoard.id}/like`)
.expect(200);
expect(resLike).toHaveProperty('body');
expect(resLike.body).toHaveProperty('like_cnt');
const cntAfterLike = resLike.body.like_cnt;
expect(cntAfterLike).toBe(cntBeforeLike + 1);
});
새로운 보드 생성 후, like_cnt가 반환되는지 확인하고, PATCH /board/:id/like 요청 후 like_cnt가 1 증가하는지를 본다.
// board.entity.ts
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Board extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 255, nullable: false })
title: string;
@Column({ type: 'text', nullable: true })
content: string;
@Column({ type: 'varchar', length: 50, nullable: false })
author: string;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
created_at: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updated_at: Date;
@Column({ type: 'int', default: 0 })
like_cnt: number;
}
entity에 int, default 0인 like_cnt 컬럼을 추가해주고
// board.controller.ts
@Patch(':id/like')
patchLike(@Param('id') id: string): Promise<Partial<Board>> {
return this.boardService.patchLike(+id);
}
// board.service.ts
async patchLike(id: number): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
board.like_cnt += 1;
await this.boardRepository.save(board);
return { like_cnt: board.like_cnt };
}
컨트롤러와 서비스 작성. 모든 데이터가 아닌 Patch된 like_cnt만 넘겨주는 것으로 하자.
잘 통과됨
postman으로도 해봄
// #45 [06-08] 서버는 좋아요 / 좋아요 취소 요청을 받아 데이터베이스의 데이터를 수정한다.
it('PATCH /board/:id/unlike', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const createdBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
expect(createdBoard).toHaveProperty('like_cnt');
const cntBeforeUnlike = createdBoard.like_cnt;
const resUnlike = await request(app.getHttpServer())
.patch(`/board/${createdBoard.id}/unlike`)
.expect(200);
expect(resUnlike).toHaveProperty('body');
expect(resUnlike.body).toHaveProperty('like_cnt');
const cntAfterUnlike = resUnlike.body.like_cnt;
expect(cntAfterUnlike).toBe(cntBeforeUnlike - 1);
});
like와 유사하게 하나 줄어드는지만 봄
// board.controller.ts
@Patch(':id/unlike')
patchUnlike(@Param('id') id: string): Promise<Partial<Board>> {
return this.boardService.patchUnlike(+id);
}
// board.service.ts
async patchUnlike(id: number): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
board.like_cnt -= 1;
await this.boardRepository.save(board);
return { like_cnt: board.like_cnt };
}
// (추가 필요) 서버는 사용자의 요청에 따라 글을 수정한다.
it('PATCH /board/:id', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const createdBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
expect(createdBoard).toHaveProperty('id');
const id = createdBoard.id;
const toUpdate: UpdateBoardDto = {
title: 'updated',
content: 'updated',
};
const updated = await request(app.getHttpServer())
.patch(`/board/${id}`)
.send({ title: 'updated', content: 'updated' })
.expect(200);
expect(updated).toHaveProperty('body');
const updatedBoard = updated.body;
expect(updatedBoard).toHaveProperty('id');
expect(updatedBoard.id).toBe(id);
expect(updatedBoard).toHaveProperty('title');
expect(updatedBoard.title).toBe(toUpdate.title);
expect(updatedBoard).toHaveProperty('content');
expect(updatedBoard.content).toBe(toUpdate.content);
});
게시글의 title, content 수정 후, 수정한 것이 반영되었는지를 검사한다.
// board.controller.ts
@Patch(':id')
updateBoard(@Param('id') id: string, @Body() updateBoardDto: UpdateBoardDto) {
return this.boardService.updateBoard(+id, updateBoardDto);
}
// board.service.ts
async updateBoard(id: number, updateBoardDto: UpdateBoardDto) {
const board: Board = await this.findBoardById(id);
const updatedBoard: Board = await this.boardRepository.save({
...board,
...updateBoardDto,
});
return updatedBoard;
}
추가로 Update 될 때 자동으로 시간이 갱신되도록 entity에서 create_at, update_at을 각각
CreateDateColumn()
, UpdateDateColumn()
으로 어노테이션을 수정해줬다.
// board.entity.ts
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
export class Board extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 255, nullable: false })
title: string;
@Column({ type: 'text', nullable: true })
content: string;
@Column({ type: 'varchar', length: 50, nullable: false })
author: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@Column({ type: 'int', default: 0 })
like_cnt: number;
}
테스트 잘 통과됨
// board.e2e-spec.ts
// (추가 필요) 서버는 사용자의 요청에 따라 글을 삭제한다.
it('DELETE /board/:id', async () => {
const board: CreateBoardDto = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
await request(app.getHttpServer())
.delete(`/board/${newBoard.id}`)
.expect(200);
await request(app.getHttpServer()).get(`/board/${newBoard.id}`).expect(404);
});
// board.controller.ts
@Delete(':id')
deleteBoard(@Param('id') id: string): Promise<void> {
return this.boardService.deleteBoard(+id);
}
// board.service.ts
async deleteBoard(id: number): Promise<void> {
const result = await this.boardRepository.delete({ id });
}
없으면 404뜨는 remove 대신 delete 메소드를 활용했다.
// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
// #20 [03-02] 사용자가 정보제공을 허용하여 콜백 API 요청을 받으면, 백엔드 서버는 요청에 포함된 코드를 통해 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
// #21 [03-03] 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
// #22 [03-04] 사용자 정보를 전달받으면, 필요한 속성만 추출하여 회원 정보를 데이터베이스에 저장한다.
// #27 [04-04] 데이터베이스에서 로그인 데이터로 조회를 하여 비교한다.
// #28 [04-05] 없는 회원 정보라면 NotFoundError를 응답한다.
// #29 [04-06] 있는 회원 정보라면 JWT(Access Token 및 Refresh Token)를 발급하고 쿠키에 저장한다.
// #30 [04-07] JWT에 대한 Refresh Token을 Redis에 저장한다.
// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.
이슈부터 긁어왔다. 각 이슈를 분석해 만들어야 할 API를 명세함
// auth.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('AuthController (/auth, e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
it.todo('GET /auth/check-duplicate-username');
// #91 [02-12] 서버는 닉네임 중복을 검사하고 결과를 클라이언트에 전송한다.
it.todo('GET /auth/check-duplicate-nickname');
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
it.todo('POST /auth/signup');
// #20 [03-02] 사용자가 정보제공을 허용하여 콜백 API 요청을 받으면, 백엔드 서버는 요청에 포함된 코드를 통해 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
// #21 [03-03] 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
// #22 [03-04] 사용자 정보를 전달받으면, 필요한 속성만 추출하여 회원 정보를 데이터베이스에 저장한다.
it.todo('GET /auth/oauth/:service'); // OAuth2.0 서비스 로그인 페이지로 리다이렉트, 쿼리 스트링으로 client_id, scope 담아서 보냄
it.todo('GET /auth/oauth/:service/callback');
// OAuth2.0 서비스 로그인 후 리다이렉트, 쿼리 스트링으로 code 담아서 보냄.
// 백엔드 서버는 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
// 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
// 사용자 정보를 받으면, 유저 테이블을 조회하여 이미 가입한 회원인지 확인한다.
// 이미 가입한 회원이라면, JWT를 발급하고 쿠키에 저장한다. -> JWT 리턴하는지 확인
// 새로운 회원이라면, 클라이언트에 닉네임 정보를 받아오도록 요청함. 받아오면 회원 정보를 데이터베이스에 저장하고 JWT를 발급하고 쿠키에 저장한다. -> redirect 확인
// 위 로직은 e2e 테스트가 힘들기 때문에 구현을 먼저 하는 것으로 결정.
// #27 [04-04] 데이터베이스에서 로그인 데이터로 조회를 하여 비교한다.
// #28 [04-05] 없는 회원 정보라면 NotFoundError를 응답한다.
// #29 [04-06] 있는 회원 정보라면 JWT(Access Token 및 Refresh Token)를 발급하고 쿠키에 저장한다.
// #30 [04-07] JWT에 대한 Refresh Token을 Redis에 저장한다.
it.todo('POST /auth/signin');
// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.
it.todo('POST /auth/signout');
});
코드에 나와있는 대로 OAuth2.0 로그인 및 회원가입에 대해서는 e2e 테스트가 힘들기 때문에, TDD에서 제외하기로 결정함.
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
it('POST /auth/signup', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
const response = await request(app.getHttpServer())
.post('/auth/signup')
.send(newUser)
.expect(201);
expect(response).toHaveProperty('body');
const createdUser = response.body;
expect(createdUser).toHaveProperty('id');
expect(typeof createdUser.id).toBe('number');
expect(createdUser).toMatchObject({
username: newUser.username,
nickname: newUser.nickname,
});
});
// user.entity.ts
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
username: string;
@Column({ type: 'varchar', length: 100, nullable: false })
password: string;
@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
nickname: string;
@CreateDateColumn()
created_at: Date;
}
// create-user.dto.ts
export class CreateUserDto {
username: string;
password: string;
nickname: string;
}
엔티티와 createDto부터 만들어주고
// auth.controller.ts
@Post('signup')
signUp(@Body() createUserDto: CreateUserDto): Promise<Partial<User>> {
return this.authService.signUp(createUserDto);
}
yarn workspace server add bcrypt
bcrypt 모듈 설치 후
// auth.service.ts
import * as bcrypt from 'bcrypt';
...
async signUp(createUserDto: CreateUserDto): Promise<Partial<User>> {
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(createUserDto.password, salt);
const newUser = this.authRepository.create({
...createUserDto,
password: hashedPassword,
});
const createdUser: User = await this.authRepository.save(newUser);
createdUser.password = undefined;
return createdUser;
}
controller와 service를 만들어줬다.
생성 시엔 password hash시켜주고! 생성 후 반환할 때는 password는 빼주고!
통과!
hash도 잘 만들어진다!
// auth.e2e-spec.ts
it('POST /auth/signin', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
newUser.nickname = undefined;
const response = await request(app.getHttpServer())
.post('/auth/signin')
.send(newUser)
.expect(200);
expect(response).toHaveProperty('headers');
expect(response.headers).toHaveProperty('set-cookie');
const cookies = response.headers['set-cookie'];
expect(cookies).toBeInstanceOf(Array);
expect(cookies.length).toBeGreaterThan(0);
expect(cookies[0]).toContain('accessToken');
newUser.password = 'wrong password';
await request(app.getHttpServer())
.post('/auth/signin')
.send(newUser)
.expect(401);
});
실패하는 테스트 코드 작성. cookie를 어떻게 확인하는지를 잘 몰라 좀 헤맸다.
yarn workspace server add @nestjs/jwt @nestjs/passport passport passport-jwt
먼저 로그인, JWT 관련 모듈을 설치한다.
// jwt.config.ts
import { JwtModuleOptions } from '@nestjs/jwt';
import { configDotenv } from 'dotenv';
configDotenv();
const jwtConfig: JwtModuleOptions = {
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: 3600,
},
};
export { jwtConfig };
// auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../config/jwt.config';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register(jwtConfig),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
config 파일을 만들고 auth.module.ts에 JWT 모듈을 등록해준다.
// board.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
Res,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignUpUserDto } from './dto/signup-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { SignInUserDto } from './dto/signin-user.dto';
import { Response } from 'express';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
...
@Post('signin')
@HttpCode(200)
async signIn(
@Body() signInUserDto: SignInUserDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.signIn(signInUserDto);
res.cookie('accessToken', result.accessToken, { httpOnly: true });
return result;
}
...
}
이게 좀 어려운데, 일단 NestJS에서 기본적으로 Post 데코레이터를 등록하면 성공 시 201을 리턴해준다.
그래서 @HttpCode(200)
를 붙여줘야 함.
그리고 보통 jwt 토큰을 관리하는 데 두 가지 방식이 있다.
- 그냥 리턴해줘서 client에서 이걸 로컬 스토리지 같은 데에 저장해뒀다가, 매번
Authorization 헤더
로Bearer ${accessToken}
요렇게 직접 쏴주게 하는 방법이 있고 - 서버 단에서
set-cookie
로 쿠키에 저장해 뒀다가,Cookie 헤더
로 매번 자동으로 실려오는 쿠키에서 accessToken을 확인하는 방법이 있다.
우리는 FE 담당 분들께 따로 Task를 주지 않아도 되고, 보안적으로도 더 안전하다고 알려져있는 cookie 방식을 사용하기로 결정. 그래서 res.cookie() 메소드를 이용해 httpOnly로 쿠키를 등록해줬다.
async signIn(signInUserDto: SignInUserDto): Promise<{ accessToken: string }> {
const { username, password } = signInUserDto;
const user = await this.authRepository.findOneBy({ username });
if (user && (await bcrypt.compare(password, user.password))) {
const payload = { username };
const accessToken = await this.jwtService.sign(payload);
return { accessToken };
} else {
throw new UnauthorizedException('login failed');
}
}
마지막으로 서비스 단에서는 bcrypt 모듈을 활용해 검증하고, jwt 토큰을 활용해 토큰을 발급해준다.
드디어 완성!
// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.
it('GET /auth/signout', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
newUser.nickname = undefined;
await request(app.getHttpServer()).post('/auth/signin').send(newUser);
const response = await request(app.getHttpServer())
.get('/auth/signout')
.expect(200);
expect(response).toHaveProperty('headers');
expect(response.headers).toHaveProperty('set-cookie');
const cookies = response.headers['set-cookie'];
expect(cookies.length).toBeGreaterThan(0);
expect(cookies[0]).toBe(
'accessToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly',
);
});
// auth.controller.ts
@Get('signout')
async signOut(@Res({ passthrough: true }) res: Response) {
res.clearCookie('accessToken', { path: '/', httpOnly: true });
return { message: 'success' };
}
이건 controller만 넣어주면 된다. 간단!
// auth.e2e-spec.ts
// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
it('GET /auth/is-available-username', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
await request(app.getHttpServer())
.get(`/auth/is-available-username?username=${randomeBytes}`)
.expect(409);
await request(app.getHttpServer())
.get(`/auth/is-available-username?username=${randomeBytes + '1'}`)
.expect(200);
await request(app.getHttpServer())
.get(`/auth/is-available-username`)
.expect(400);
});
// #91 [02-12] 서버는 닉네임 중복을 검사하고 결과를 클라이언트에 전송한다.
it('GET /auth/is-available-nickname', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname?nickname=${randomeBytes}`)
.expect(409);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname?nickname=${randomeBytes + '1'}`)
.expect(200);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname`)
.expect(400);
});
signup 후 요청하면 중복될테니 409(Conflict) 에러 리턴, 중복 없으면 200(OK) 리턴. 값 안넣으면 400(Bad Request) 에러 리턴.
자동 생성된 GET /auth/:id
에 잡혀서 200이 리턴되어버림. 지워주고 다시 돌렸다.
// auth.controller.ts
@Get('is-available-username')
isAvailableUsername(@Query('username') username: string) {
return this.authService.isAvailableUsername(username);
}
@Get('is-available-nickname')
isAvailableNickname(@Query('nickname') nickname: string) {
return this.authService.isAvailableNickname(nickname);
}
// auth.service.ts
async isAvailableUsername(username: string): Promise<boolean> {
if (!username) {
throw new BadRequestException('username is required');
}
const user = await this.authRepository.findOneBy({ username });
if (user) {
throw new ConflictException('username already exists');
} else {
return true;
}
}
async isAvailableNickname(nickname: string): Promise<boolean> {
if (!nickname) {
throw new BadRequestException('nickname is required');
}
const user = await this.authRepository.findOneBy({ nickname });
if (user) {
throw new ConflictException('nickname already exists');
} else {
return true;
}
}
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)