[Nest.js 정리] 로그인과정까지 흐름
무작정 강의를 따라치기보단 어느 정도까지 진행되었을 때, 혼자 해보는 것이 좋다고 판단하여 진행
해당 과정까지 개발 순서
- 프로젝트의 시작
- 어떤 테이블을 작성할지 설계
- 게시물 관련 내용을 담을 post table 작성
- user 정보를 담을 users table 작성
- 이를 위해 어떤식으로 구현해야하는가
- service.ts, controller.ts를 각 모듈별로 설계
- 설계를 바탕으로 모듈 생성
- 각 모듈별 controller.ts, module.ts, service.ts 파일 수정
1. 프로젝트의 시작
1
nest new jw_sns
- 나의 경우엔 jw_sns라는 이름의 프로젝트를 시작한다.
- 또한 필요한 것들을 다운로드한다.
- typeORM, @nestjs/typeorm
- DB를 연결한다.
- postgres사용시 yarn add pg
- docker-compose.yaml 작성
1
2
3
4
5
6
7
8
9
10
11
12
services:
postgres:
image: postgres:15
restart: always
volumes:
- ./postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: jw_sns-postgres
2. 어떤 테이블을 만들어야하는가
우선 어떤 기능을 구현해야하는지 하나하나 따져보자.
- 게시글 작성 관련
- 게시글 - (작성자, 좋아요 수, 댓글 수, 제목, 내용)
- 게시글과 작성자간 OneToMany (게시글 입장에선 ManyToOne)
- 게시글 - (작성자, 좋아요 수, 댓글 수, 제목, 내용)
- 유저 관련
- 해당 유저의 이메일, 닉네임, 패스워드, 작성한 게시글, 좋아요 누른 것
- 작성한 게시글은 OneToMany관계
- 해당 유저의 이메일, 닉네임, 패스워드, 작성한 게시글, 좋아요 누른 것
자, 이제 module을 생성하고 entity를 만들어보자.
1
2
nest g resource
// 이후 적합한 module을 만들자 (users, posts)
a. posts.entity.ts
- 고려해야하는 것들
- author의 경우 usersModel과 ManyToOne 관계이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity()
export class PostsModel {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => UsersModel, (user) => user.posts)
authorId: number;
// 주의! ManyToOne으로 엮이는 경우 TypeORM은 자동으로 authorId 칼럼을 생성
// 따라서 author : string과 같은 경우 오류발생!
// 관계형 DB의 특징이기에 준수해야함.
@Column()
title: string;
@Column()
content: string;
@Column()
likeCount: number;
@Column()
commentCount: number;
}
이후 엔티티를 해당 모듈에서 사용하기 위해 module.ts를 수정하여 TypeORM이 해당 엔티티를 인식할 수 있도록 하자.
module에 imports 해야 service (핵심 로직 구현)에서 활용 가능하다.
1
2
3
4
5
6
@Module({
imports: [TypeOrmModule.forFeature([PostsModel])],
controllers: [PostsController],
providers: [PostsService],
})
export class PostsModule {}
b. users.entity.ts
- 고려해야하는 것
- nickname, email은 고유값이여야한다. (옵션)
- nickname의 길이를 제한한다 (20자로 하자 일단)
- 작성한 게시글 정보를 postsModel과 OneToMany 관계를 형성해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Entity()
export class UsersModel {
@PrimaryGeneratedColumn()
id: number;
@Column({
unique: true,
})
email: string;
@Column({
unique: true,
length: 20,
})
nickname: string;
@Column()
password: string;
@OneToMany(() => PostsModel, (post) => post.authorId)
posts: PostsModel[];
@Column({
type: 'enum',
enum: Object.values(Roles),
default: Roles.USER,
})
role: Roles;
}
users또한 정상작동하도록 module.ts 파일을 수정하자.
3. 어떤식으로 구현할까
이것만은 정상 작동해야한다!
- 게시글 작성
- 회원가입을 통해 로그인한 유저가 정상적으로 게시글을 작성할 수 있다.
- 게시글 확인
- 작성된 게시글이 무엇이 있는지 유저는 확인할 수 있어야한다.
posts
다음과 같은 기능이 구현되어야
- 게시글의 작성
- 작성자, 제목, 내용을 입력하면 자동으로 게시글 생성
- 이 때 좋아요 / 댓글 수는 default로 0 부여
- 작성자, 제목, 내용을 입력하면 자동으로 게시글 생성
- 게시글 수정
- id에 해당하는 게시글을 찾고 존재한다면 다음 과정 진행
- 제목, 내용을 입력해도 안해도 동작할 수 있도록 (선택적 업데이트)
- 게시글 삭제
- id에 해당하는 게시글이 존재한다면 이를 삭제
- 게시글 확인 (전체, 하나 상세)
- 하나의 포스트를 찾는 경우 id로 찾는다. 없으면 에러
posts.service.ts
작성한 테이블에 접근해야하므로 module.ts 수정을 통해 의존성 주입이 가능하도록 한다.
1
2
3
4
5
6
@Module({
imports: [TypeOrmModule.forFeature([UsersModel])],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
작성한 service.ts코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Injectable()
export class PostsService {
constructor(
@InjectRepository(PostsModel)
private postsRepository: Repository<PostsModel>,
) {}
// 1. 모든 게시글 확인하기
async getAllPosts() {
return this.postsRepository.find();
}
// 2. 특정 게시글 확인하기 (id로)
async getPost(id: number) {
const post = await this.postsRepository.findOne({
where: {
id,
},
});
if (!post) {
throw new NotFoundException('해당 게시글을 찾을 수 없습니다.');
}
return post;
}
// 3. 게시글 작성하기
// authorId: number, title: string, content: string
async createPost(
postData: Pick<PostsModel, 'authorId' | 'title' | 'content'>,
) {
const post = this.postsRepository.create({
...postData,
likeCount: 0,
commentCount: 0,
});
const newPost = await this.postsRepository.save(post);
return newPost;
}
// 4. 게시글 수정하기
async updatePost(
id: number,
updateData: Partial<Pick<PostsModel, 'title' | 'content'>>,
) {
const post = await this.postsRepository.findOne({
where: {
id,
},
});
if (!post) {
throw new NotFoundException('해당 게시글을 찾을 수 없습니다.');
}
if (updateData.title) {
post.title = updateData.title;
}
if (updateData.content) {
post.content = updateData.content;
}
const newPost = await this.postsRepository.save(post);
return newPost;
}
// 5. 게시글 삭제하기
async deletePost(id: number) {
const post = await this.postsRepository.findOne({
where: {
id,
},
});
if (!post) {
throw new NotFoundException('해당 게시글을 찾을 수 없습니다.');
}
await this.postsRepository.delete(id);
return `${id}번 게시글을 삭제했습니다.`;
}
}
posts.controller.ts
service.ts에서 핵심 로직을 작성했으니, 요청이 왔을 때 안내하는 역할인 controller를 수정해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
// 1. 모든 게시글을 보기위한 요청
@Get()
getPosts() {
return this.postsService.getAllPosts();
}
// 2. 특정 게시글 (id)을 확인하기 위한 요청
@Get(':id')
getPost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.getPost(id);
}
// 3. 게시글을 작성하기 위한 요청
// Body값으로 작성자, 제목, 내용을 받으면 이를 기반으로 게시글을 작성한다.
@Post()
postPosts(
@Body('authorId') authorId: number,
@Body('title') title: string,
@Body('content') content: string,
) {
return this.postsService.createPost({ authorId, title, content });
}
// 4. 게시글 수정
@Put(':id')
updatePosts(
@Param('id', ParseIntPipe) id: number,
@Body('title') title?: string,
@Body('content') content?: string,
) {
return this.postsService.updatePost(id, { title, content });
}
// 5. 게시글 삭제
@Delete(':id')
deletePost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.deletePost(id);
}
}
중간중간 parseIntPipe를 활용해서 url에서 오는 string을 number로 바꿔 요청을 진행했다. 또한 Pick, Partial을 활용하여 타입 안정성을 고려했다.
users
다음과 같은 기능이 구현되어야
- users 반환
- 굳이 필요없는 기능이지만, 개발의 편의 목적.
- 유저 등록
- 추후 auth - 회원가입 기능에서 이를 활용함.
- 유저를 등록하는 과정으로, 닉네임 중복 및 email 중복여부 확인 후 등록
- email을 활용하여 유저 정보를 가져오기
users.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Injectable()
export class UsersService {
constructor(
@InjectRepository(UsersModel)
private readonly usersRepository: Repository<UsersModel>,
) {}
async getAllUsers() {
return this.usersRepository.find();
}
async addUser(
userData: Pick<UsersModel, 'email' | 'nickname' | 'password'>,
role?: Roles,
) {
const existingEmail = await this.usersRepository.exists({
where: {
email: userData.email,
},
});
if (existingEmail) {
throw new BadRequestException('중복된 email입니다.');
}
const existingNickname = await this.usersRepository.findOne({
where: {
nickname: userData.nickname,
},
});
if (existingNickname) {
throw new BadRequestException('중복된 닉네임입니다.');
}
const userObject = this.usersRepository.create({
email: userData.email,
nickname: userData.nickname,
password: userData.password,
role: role,
});
const newUser = await this.usersRepository.save(userObject);
return newUser;
}
async getUserByEmail(email: string) {
return this.usersRepository.findOne({
where: {
email,
},
});
}
}
users.controller.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
getAllUsers() {
return this.usersService.getAllUsers();
}
@Post()
addUser(
@Body('email') email: string,
@Body('nickname') nickname: string,
@Body('password') password: string,
@Body('role') role?: Roles,
) {
return this.usersService.addUser({ email, nickname, password }, role);
}
}
auth
회원가입 및 로그인 로직은 크게 다음과 같다.
- email, nickname, password를 받고 회원가입을 진행한다.
- 단 email, nickname의 unique여부를 판별하고
- password는 암호화(hash)를 진행한다. (bcrypt 설치 필요)
- 만약 회원가입이 완료되었다면 Token을 반환한다. (@nestjs/jwt 설치 필요)
- 로그인의 경우 email과 password를 확인한다.
- password확인의 경우 hash값들을 비교한다.
- DB에 접근해서 존재하는 유저인지 확인해야한다. (검증)
- 검증을 통과하면 Token을 반환한다.
이를 구현하기 위해 auth module을 만들고 구현해보자. table은 따로 필요없으니 service, controller을 구현하자.
설치
1
yarn add @nestjs/jwt bcrypt
module 주입
설치한 jwt는 추가적인 주입이 필요하다. register을 활용하여 jwt을 주입하자. 또한 기존에 작성한 UsersModule을 활용할 예정이므로 이 또한 주입한다.
1
2
3
4
5
6
@Module({
imports: [JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
auth.service.ts
로그인 과정은 다음의 과정으로 진행한다.
- loginWithEmail 함수 실행 (로그인 시작 함수)
- authenticateWithEmailAndPassword 함수 실행 (검증)
- 사용자 존재 여부 확인
- 비밀번호 일치여부 확인
- 유효한 경우 사용자 정보 반환
- loginUser 함수 호출 (검증 통과한 경우 실행)
- signToken 호출하여 refresh와 access 토큰 생성
그리고 회원가입은 다음의 과정으로 진행한다.
- registerWithEmail 함수 실행 (회원가입 실행 함수)
- 비밀번호를 암호화하여 usersService의 함수 활용하여 DB 연결
- 회원가입 이후 로그인이 되도록 하려면 이후 로그인 과정 진행 (바로 loginUser 함수 호출)
정의해야하는 로직은 다음과 같다.
- Header에 담아서 보낸 토큰을 확인하는 로직
- Basic 인증과정
- 토큰 검증과정
- Refresh 토큰으로 새로운 Access 토큰을 발급받는 과정
- JWT 토큰을 생성하는 로직
- 로그인
- 회원가입
토큰 관련 로직
토큰 관련된 로직은 다음과 같은 로직이 필요하다.
- Header에서 토큰 빼기
- 토큰의 유효성 확인하기 (검증)
- 토큰 등록
- access 토큰의 재발급 과정
- Basic 요청이 온 경우 이를 바탕으로 해당 유저 검증하기
- email, password로 분리하고
- DB에서 유저 정보가 있는지 판단
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 1. 토큰 추출 - Header에 담긴 값을 바탕으로 토큰을 확인
extractTokenFromHeader(header: string, isBearer: boolean) {
const splitToken = header.split(' ');
const prefix = isBearer ? 'Bearer' : 'Basic';
if (splitToken.length !== 2 || splitToken[0] !== prefix) {
throw new UnauthorizedException('Header에 담긴 토큰의 유형이 잘못되었습니다.')
}
const token = splitToken[1];
return token;
}
// 2. 토큰의 유효성 확인
verifyToken(token: string) {
return this.jwtService.verify(token, {
secret: JWT_SECRET,
});
}
// 3. 토큰 등록 - refresh와 access 발급
signToken(user: Pick<UsersModel, 'email' | 'id'>, isRefreshToken: boolean) {
const payload = {
email : user.email,
id : user.id,
type : isRefreshToken ? 'refresh' : 'access',
};
return this.jwtService.sign(payload, {
secret : JWT_SECRET,
expiresIn : isRefreshToken ? 3600 : 300,
})
}
// 4. 토큰의 재발급 과정
rotateToken(token: string, isRefreshToken: boolean) {
const decoded = this.jwtService.verify(token, {
secret: JWT_SECRET,
});
if (decoded.type !== 'refresh') {
throw new UnauthorizedException(
'토큰 재발급은 refresh token으로만 해야합니다.',
);
}
return this.signToken(
{
...decoded,
},
isRefreshToken,
);
}
// 5. Basic 요청 => 로그인 유저 정보 확인 - 해당 값을 바탕으로 로그인 유효성을 판단한다.
decodeBasicToken(base64String: string) {
const decoded = Buffer.from(base64String, 'base64').toString('utf8');
const split = decoded.split(':');
if (split.length !== 2) {
throw new UnauthorizedException('Header에 담은 토큰 형식이 잘못되었습니다.')
}
const email = split[0];
const password = split[1];
return {
email,
password,
}
}
위의 토큰 관련 로직을 활용한 기능 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1. 로그인 과정
async loginWithEmail(user: Pick<UsersModel, 'email' | 'password'>) {
const existingUser = await this.authenticateWithEmailAndPassword(user);
return this.loginUser(existingUser);
}
// 1-1 존재하는 유저인지 판단.
async authenticateWithEmailAndPassword(
user: Pick<UsersModel, 'email' | 'password'>,
) {
// a. 사용자 정보 확인
const existingUser = await this.usersService.getUserByEmail(user.email);
if (!existingUser) {
throw new UnauthorizedException('존재하지 않는 사용자입니다.');
}
// b. 비밀번호의 비교
// 앞엔 일반 비밀번호 뒤엔 hash값
const checkPass = await bcrypt.compare(
user.password,
existingUser.password,
);
if (!checkPass) {
throw new UnauthorizedException('비밀번호가 틀렸습니다.');
}
return existingUser;
}
// 1-2 유효한 유저라면 토큰 발급 과정 진행
loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
return {
accessToken: this.signToken(user, false),
refreshToken: this.signToken(user, true),
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 2. 회원가입
async registerWithEmail(
user: Pick<UsersModel, 'email' | 'password' | 'nickname'>,
) {
// bcrypt의 경우 해시화 하고 싶은 패스워드 , round를 변수로 적는다.
// rounds는 hash에 소요되는 시간을 의미한다.
const hash = await bcrypt.hash(user.password, HASH_ROUNDS);
const newUser = await this.usersService.createUser({
...user,
password: hash,
});
return this.loginUser(newUser);
}
auth.controller.ts
controller의 역할은 service로의 연결을 잘 해주는 길 안내 역할이므로 service에서 정의한 함수가 요구하는 것을 잘 전달만 해주면 된다.
우리가 안내해줘야 하는 목적지는 다음과 같다.
- 이메일로 로그인하기 (service에서 loginWithEmail로 실행, 토큰, 이메일, 비밀번호 필요)
- Basic Token을 받고 이를 service로 전달. service는 이를 decode 하여 이메일과 비밀번호를 판별한다.
- 회원가입하기 (registerWithEmail로 실행, 이메일, 비밀번호, 닉네임 필요)
- 토큰 재발급 (access and refresh)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('token/access')
postTokenAccess(
@Headers('authorization') rawToken: string,) {
const token = this.authService.extractTokenFromHeader(rawToken, true);
/**
* 반환 => accessToken : {token}
*/
const newToken = this.authService.rotateToken(token, false)
return {
accessToken : newToken,
}
}
@Post('token/refresh')
postTokenRefresh(
@Headers('authorization') rawToken: string,) {
const token = this.authService.extractTokenFromHeader(rawToken, true);
/**
* 반환 => accessToken : {token}
*/
const newToken = this.authService.rotateToken(token, true)
return {
refreshToken : newToken,
}
}
@Post('login/email')
postLoginEmail(
@Headers('authorization') rawToken: string,
@Body('email') email: string,
@Body('password') password: string,
) {
const token = this.authService.extractTokenFromHeader(rawToken, false);
const credentials = this.authService.decodeBasicToken(token);
return this.authService.loginWithEmail(credentials);
}
@Post('register/email')
postRegisterEmail(
@Body('email') email: string,
@Body('password') password: string,
@Body('nickname') nickname: string,
) {
return this.authService.registerWithEmail({email, password, nickname})
}
}
결론
Nest에서 가장 중요한 것은 다음과 같다고 생각한다.
- 의존성을 잘 고려해야한다.
- 다른 모듈 (auth module에서 user module 사용하는 것)이나 jwt와 같은 것들을 사용하기 위해선 해당 모듈에 주입하는 것을 잊지 않아야한다.
- 해당 모듈을 imports하고 exports하는 것을 생각해야한다.
- entity 또한 모듈에서 사용하기 위해선 주입해야한다.
- 다른 모듈 (auth module에서 user module 사용하는 것)이나 jwt와 같은 것들을 사용하기 위해선 해당 모듈에 주입하는 것을 잊지 않아야한다.
- 함수의 시작지점을 짜고, 어떤 로직으로 실행되어야하는지 고려해야한다.
- login의 경우 검증 => login이 진행되어야함.
- 회원가입의 경우 usersService에 접근해서 실행하여야함.