Skip to content

Commit

Permalink
refactor: DB 스키마 변경 및 회원 email 필드 변경 (prgrms-fe-devcourse#236)
Browse files Browse the repository at this point in the history
* chore: DB 스키마 변경

* style: authService, userRepository에 DB 변동사항 반영

* feat: authController, authValidator에 DB 변동사항 반영

* chore: Model 디렉토리에 있는 모든 파일을 스키마로 사용하도록 수정

* chore: 테스트 파일도 빌드하도록 수정 (IDE에서 import 안되는 문제 해결)

* chore: 빌드 안된 test.ts 파일만 테스트 수행하도록 수정

* test: authService 테스트 코드 필드 이름 수정

* refactor: authorizationFilter에 throwsOnError 파라미터 추가 (prgrms-fe-devcourse#237)

* feat: authorizationFilter에 throwsOnError 필드 추가

* test: authorizationFilter 테스트 코드 수정

* style: 로그인 validation 응답하지 않도록 수정 및 비밀번호 변경에 500 응답
bbearcookie authored Jan 18, 2024
1 parent 15402a1 commit 837783a
Showing 13 changed files with 167 additions and 122 deletions.
3 changes: 2 additions & 1 deletion packages/slack/jest.config.js
Original file line number Diff line number Diff line change
@@ -5,5 +5,6 @@ module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
setupFiles: ['dotenv/config']
setupFiles: ['dotenv/config'],
testMatch: ['<rootDir>/src/**/*.test.ts']
};
6 changes: 1 addition & 5 deletions packages/slack/src/configs/database.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { DataSource } from 'typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { User } from '@/models/User';
import { Post } from '@/models/Post';
import { Comment } from '@/models/Comment';

const AppDataSource = new DataSource({
type: 'mysql',
@@ -12,8 +9,7 @@ const AppDataSource = new DataSource({
password: process.env.NODE_ENV === 'production' ? process.env.DEPLOY_DB_PASSWORD : process.env.LOCAL_DB_PASSWORD,
database: process.env.DB_DATABASE,
synchronize: true,
// logging: true,
entities: [User, Post, Comment],
entities: [__dirname + '/../models/*.{js,ts}'],
subscribers: [],
migrations: [],
namingStrategy: new SnakeNamingStrategy()
12 changes: 6 additions & 6 deletions packages/slack/src/domains/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -8,22 +8,22 @@ import authService from './auth.service';
const router = express.Router();

router.post('/signup', validationFilter(authValidator.signup), async (req, res) => {
const { name, password, username } = req.body as z.infer<typeof authValidator.signup.body>;
const { userId, accessToken } = await authService.signUp(username, password, name);
const { name, password, email } = req.body as z.infer<typeof authValidator.signup.body>;
const { userId, accessToken } = await authService.signUp(email, password, name);
res.json({ userId, accessToken });
});

router.post('/signin', validationFilter(authValidator.signin), async (req, res) => {
const { username, password } = req.body as z.infer<typeof authValidator.signin.body>;
const { userId, accessToken } = await authService.signIn(username, password);
const { email, password } = req.body as z.infer<typeof authValidator.signin.body>;
const { userId, accessToken } = await authService.signIn(email, password);
res.json({ userId, accessToken });
});

router.post('/signout', (req, res) => {
res.json({ message: 'TODO: 로그아웃 구현 필요..' });
});

router.get('/check', authorizationFilter, async (req, res) => {
router.get('/check', authorizationFilter(true), async (req, res) => {
const signedUser = req.user;
const accessToken = req.accessToken;

@@ -36,7 +36,7 @@ router.get('/check', authorizationFilter, async (req, res) => {
res.json({ userId, accessToken });
});

router.put('/password', authorizationFilter, validationFilter(authValidator.password), async (req, res) => {
router.put('/password', authorizationFilter(true), validationFilter(authValidator.password), async (req, res) => {
const { password } = req.body as z.infer<typeof authValidator.password.body>;
const signedUser = req.user;

26 changes: 13 additions & 13 deletions packages/slack/src/domains/auth/auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -4,38 +4,38 @@ import { encryptText } from '@/utils/crypto';
jest.mock('@/domains/users/users.repository');

describe('회원가입', () => {
const mockUser = { id: 1, username: '아이디', password: '12341234', name: '이름' };
const mockUser = { userId: 1, email: '[email protected]', password: '12341234', name: '이름' };

it('회원가입 성공', async () => {
UserRepository.findUserByUsername = jest.fn().mockResolvedValueOnce(mockUser);
UserRepository.findUserByEmail = jest.fn().mockResolvedValueOnce(mockUser);

const user = await authService.signUp(mockUser.username, mockUser.password, mockUser.name);
const user = await authService.signUp(mockUser.email, mockUser.password, mockUser.name);

expect(user.userId).toBe(mockUser.id);
expect(user.userId).toBe(mockUser.userId);
});

it('이미 존재하는 회원일 경우 예외가 발생한다', async () => {
UserRepository.findOneBy = jest.fn().mockResolvedValueOnce(mockUser);

expect(authService.signUp(mockUser.username, mockUser.password, mockUser.name)).rejects.toThrow();
expect(authService.signUp(mockUser.email, mockUser.password, mockUser.name)).rejects.toThrow();
});

it('생성된 유저 정보를 찾을 수 없을 경우 예외가 발생한다', async () => {
UserRepository.findUserByUsername = jest.fn().mockResolvedValueOnce(null);
UserRepository.findUserByEmail = jest.fn().mockResolvedValueOnce(null);

expect(authService.signUp(mockUser.username, mockUser.password, mockUser.name)).rejects.toThrow();
expect(authService.signUp(mockUser.email, mockUser.password, mockUser.name)).rejects.toThrow();
});
});

describe('로그인', () => {
const mockUser = { id: 1, password: encryptText('12341234', '시크릿'), salt: '시크릿' };
const mockUser = { userId: 1, password: encryptText('12341234', '시크릿'), salt: '시크릿' };

it('로그인 성공', async () => {
UserRepository.findOneBy = jest.fn().mockResolvedValueOnce(mockUser);

const user = await authService.signIn('아이디', '12341234');

expect(user.userId).toBe(mockUser.id);
expect(user.userId).toBe(mockUser.userId);
});

it('존재하지 않는 회원일 경우 예외가 발생한다', async () => {
@@ -52,20 +52,20 @@ describe('로그인', () => {
});

describe('로그인 확인', () => {
const mockUser = { id: 1 };
const mockUser = { userId: 1 };

it('로그인 확인 성공', async () => {
UserRepository.findOneBy = jest.fn().mockResolvedValueOnce(mockUser);

const user = await authService.signCheck(mockUser.id);
const user = await authService.signCheck(mockUser.userId);

expect(user.userId).toBe(mockUser.id);
expect(user.userId).toBe(mockUser.userId);
});

it('DB에 유저 정보가 없을 경우 예외가 발생한다', async () => {
UserRepository.findOneBy = jest.fn().mockResolvedValueOnce(null);

expect(authService.signCheck(mockUser.id)).rejects.toThrow();
expect(authService.signCheck(mockUser.userId)).rejects.toThrow();
});
});

44 changes: 20 additions & 24 deletions packages/slack/src/domains/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -4,8 +4,8 @@ import { ResponseError, ValidationError } from '@/utils/ResponseError';
import { makeRandomString, encryptText } from '@/utils/crypto';

const authService = {
async signUp(username: string, password: string, name: string) {
if (await UserRepository.findOneBy({ username })) {
async signUp(email: string, password: string, name: string) {
if (await UserRepository.findOneBy({ email })) {
throw new ResponseError(409, '이미 존재하는 회원입니다.');
}

@@ -14,80 +14,76 @@ const authService = {

await UserRepository.insert({
name,
username,
email,
password: encryptedPassword,
salt: salt
});

const insertedUser = await UserRepository.findUserByUsername({ username });
const insertedUser = await UserRepository.findUserByEmail({ email });

if (!insertedUser) {
throw new ResponseError(500, '회원가입에 실패했습니다.');
}

const accessToken = authService.generateAccessToken(insertedUser.id, insertedUser.username, insertedUser.role);
const accessToken = authService.generateAccessToken(insertedUser.userId, insertedUser.email, insertedUser.role);

return {
userId: insertedUser.id,
userId: insertedUser.userId,
accessToken
};
},

async signIn(username: string, password: string) {
const user = await UserRepository.findOneBy({ username });
async signIn(email: string, password: string) {
const user = await UserRepository.findOneBy({ email });

if (!user) {
throw new ValidationError({
username: '존재하지 않는 회원입니다.'
});
throw new ResponseError(400, '일치하는 회원이 없어요.');
}

const encryptedPassword = encryptText(password, user.salt);

if (user.password !== encryptedPassword) {
throw new ValidationError({
password: '비밀번호가 일치하지 않습니다.'
});
throw new ResponseError(400, '일치하는 회원이 없어요.');
}

const accessToken = authService.generateAccessToken(user.id, user.username, user.role);
const accessToken = authService.generateAccessToken(user.userId, user.email, user.role);

return {
userId: user.id,
userId: user.userId,
accessToken
};
},

async signCheck(userId: number) {
const user = await UserRepository.findOneBy({ id: userId });
const user = await UserRepository.findOneBy({ userId });

if (!user) {
throw new ResponseError(404, '사용자를 찾을 수 없어요.');
throw new ResponseError(500, '사용자를 찾을 수 없어요.');
}

return {
userId: user.id
userId: user.userId
};
},

async changePassword(userId: number, password: string) {
const user = await UserRepository.findOneBy({ id: userId });
const user = await UserRepository.findOneBy({ userId });

if (!user) {
throw new ResponseError(404, '사용자를 찾을 수 없어요.');
throw new ResponseError(500, '사용자를 찾을 수 없어요.');
}

const salt = makeRandomString(64);
const encryptedPassword = encryptText(password, salt);

await UserRepository.update(user.id, {
await UserRepository.update(user.userId, {
password: encryptedPassword,
salt: salt
});
},

generateAccessToken(id: number, username: string, role: string) {
const accessToken = jwt.sign({ id, username, role }, process.env.JWT_SECRET_KEY, {
generateAccessToken(id: number, email: string, role: string) {
const accessToken = jwt.sign({ id, email, role }, process.env.JWT_SECRET_KEY, {
expiresIn: process.env.JWT_EXPIRES_IN
});

19 changes: 12 additions & 7 deletions packages/slack/src/domains/auth/auth.validator.ts
Original file line number Diff line number Diff line change
@@ -3,12 +3,13 @@ import { z } from 'zod';
const authValidator = {
signup: {
body: z.object({
username: z
email: z
.string({
required_error: '아이디를 입력해주세요.'
required_error: '이메일을 입력해주세요.'
})
.min(1, '아이디를 입력해주세요.')
.max(20, '아이디는 최대 20글자까지만 입력할 수 있습니다.'),
.email({
message: '이메일 형식이 아닙니다.'
}),
password: z
.string({
required_error: '비밀번호를 입력해주세요.'
@@ -25,9 +26,13 @@ const authValidator = {
},
signin: {
body: z.object({
username: z.string({
required_error: '아이디를 입력해주세요.'
}),
email: z
.string({
required_error: '이메일을 입력해주세요.'
})
.email({
message: '이메일 형식이 아닙니다.'
}),
password: z.string({
required_error: '비밀번호를 입력해주세요.'
})
8 changes: 4 additions & 4 deletions packages/slack/src/domains/users/users.repository.ts
Original file line number Diff line number Diff line change
@@ -2,13 +2,13 @@ import AppDataSource from '@/configs/database';
import { User } from '@/models/User';

const UserRepository = AppDataSource.getRepository(User).extend({
async findUserByUsername({ username }: { username: string }) {
async findUserByEmail({ email }: { email: string }) {
const user: Pick<
User,
'id' | 'username' | 'role' | 'name' | 'introduce' | 'imageName' | 'slackId' | 'slackWorkspace'
'userId' | 'email' | 'role' | 'name' | 'introduce' | 'imageUrl' | 'slackId' | 'slackWorkspace'
> | null = await this.findOne({
select: ['id', 'username', 'role', 'name', 'introduce', 'imageName', 'slackId', 'slackWorkspace'],
where: { username }
select: ['userId', 'email', 'role', 'name', 'introduce', 'imageUrl', 'slackId', 'slackWorkspace'],
where: { email }
});

return user;
47 changes: 36 additions & 11 deletions packages/slack/src/middlewares/filters.test.ts
Original file line number Diff line number Diff line change
@@ -20,36 +20,61 @@ beforeEach(() => {
});

describe('authorizationFilter', () => {
it('accessToken이 정상적이면 req 객체에 user와 accessToken이 담기고 다음 미들웨어가 실행된다', async () => {
it('accessToken이 정상적일 때 req.user, req.accessToken이 담기고 다음 미들웨어가 실행된다.', async () => {
req = {
headers: {
authorization: 'Bearer 유효한_토큰'
}
};

jwt.verify = jest.fn().mockReturnValueOnce({ id: 1, username: '사용자', role: 'user' });
authorizationFilter(req as Request, res as Response, next);
const mockUser = { id: 1, username: '사용자', role: 'user' };
jwt.verify = jest.fn().mockReturnValueOnce(mockUser).mockReturnValueOnce(mockUser);

expect(req.user).toEqual({ id: 1, username: '사용자', role: 'user' });
// throwsOnError가 true일 때
authorizationFilter(true)(req as Request, res as Response, next);
expect(req.user).toEqual(mockUser);
expect(req.accessToken).toEqual('유효한_토큰');
expect(next).toHaveBeenCalledTimes(1);

expect(next).toHaveBeenCalled();
// throwsOnError가 false일 때
authorizationFilter(false)(req as Request, res as Response, next);
expect(req.user).toEqual(mockUser);
expect(req.accessToken).toEqual('유효한_토큰');
expect(next).toHaveBeenCalledTimes(2);
});

it('accessToken이 존재하지 않으면 AuthorizationError를 발생시킨다', async () => {
expect(authorizationFilter(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).not.toHaveBeenCalled();
it('accessToken이 존재하지 않을 때', async () => {
// throwsOnError가 true이면, AuthorizationError를 발생시킨다.
expect(authorizationFilter(true)(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).toHaveBeenCalledTimes(0);
expect(authorizationFilter()(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).toHaveBeenCalledTimes(0);

// throwsOnError가 false이면, 다음 미들웨어가 실행된다.
authorizationFilter(false)(req as Request, res as Response, next);
expect(req.user).toBeUndefined();
expect(req.accessToken).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});

it('accessToken이 유효하지 않으면 AuthorizationError를 발생시킨다', async () => {
it('accessToken이 유효하지 않을 때', async () => {
req = {
headers: {
authorization: 'Bearer 유효하지_않은_토큰'
}
};

expect(authorizationFilter(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).not.toHaveBeenCalled();
// throwsOnError가 true이면, AuthorizationError를 발생시킨다.
expect(authorizationFilter(true)(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).toHaveBeenCalledTimes(0);
expect(authorizationFilter()(req as Request, res as Response, next)).rejects.toThrow(AuthorizationError);
expect(next).toHaveBeenCalledTimes(0);

// throwsOnError가 false이면, 다음 미들웨어가 실행된다.
authorizationFilter(false)(req as Request, res as Response, next);
expect(req.user).toBeUndefined();
expect(req.accessToken).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
});

53 changes: 31 additions & 22 deletions packages/slack/src/middlewares/filters.ts
Original file line number Diff line number Diff line change
@@ -4,33 +4,42 @@ import { AnyZodObject } from 'zod';
import jwt from 'jsonwebtoken';

/**
* 사용자의 로그인 여부를 확인하는 미들웨어
* (로그인이 되어있지 않으면 AuthorizationError를 발생시킵니다.)
* 사용자의 로그인 정보를 가공하는 미들웨어
* @param throwsOnError 로그인이 되어있지 않을 때 에러를 발생시킬지 여부
*
* true일 때: AuthorizationError를 발생시키고 다음 미들웨어로 넘어가지 않습니다.
* false일 때: 다음 미들웨어로 넘어갑니다.
*/
export const authorizationFilter = async (req: Request, res: Response, next: NextFunction) => {
const { authorization } = req.headers;
const accessToken = authorization?.replace('Bearer ', '') ?? '';
export const authorizationFilter =
(throwsOnError = true) =>
async (req: Request, res: Response, next: NextFunction) => {
const { authorization } = req.headers;
const accessToken = authorization?.replace('Bearer ', '') ?? '';

try {
const payload = jwt.verify(accessToken, process.env.JWT_SECRET_KEY) as {
id: number;
username: string;
role: string;
};
try {
const payload = jwt.verify(accessToken, process.env.JWT_SECRET_KEY) as {
id: number;
username: string;
role: string;
};

req.user = {
id: payload.id,
username: payload.username,
role: payload.role
};
req.user = {
id: payload.id,
username: payload.username,
role: payload.role
};

req.accessToken = accessToken;
req.accessToken = accessToken;

return next();
} catch (error) {
throw new AuthorizationError();
}
};
return next();
} catch (error) {
if (throwsOnError) {
throw new AuthorizationError();
} else {
return next();
}
}
};

/**
* Zod Schema로 Body, Query, Params를 검증하는 미들웨어
26 changes: 17 additions & 9 deletions packages/slack/src/models/Comment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Post } from './Post';
import { User } from './User';

@Entity()
export class Comment {
@PrimaryGeneratedColumn()
id!: number;
commentId!: number;

@Column({ length: 50 })
author!: string;
@ManyToOne(() => Post, (post) => post.comments, {
cascade: true
})
@JoinColumn({ name: 'post_id' })
post!: Post;

@ManyToOne(() => User, (user) => user.comments, {
cascade: true
})
@JoinColumn({ name: 'author_id' })
author!: User;

@Column({ length: 50, default: '익명의 머쓱이' })
nickname!: string;

@Column({ length: 500 })
content!: string;
@@ -26,9 +39,4 @@ export class Comment {

@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;

@ManyToOne(() => Post, (post) => post.comments, {
cascade: true
})
post!: Post;
}
21 changes: 11 additions & 10 deletions packages/slack/src/models/Post.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, ManyToOne } from 'typeorm';
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './User';
import { Comment } from './Comment';

@Entity()
export class Post {
@PrimaryGeneratedColumn()
id!: number;
postId!: number;

@ManyToOne(() => User, (user) => user.posts, {
cascade: true
})
@JoinColumn({ name: 'author_id' })
author!: User;

@OneToMany(() => Comment, (comment) => comment.commentId)
comments!: Comment[];

@Column({ length: 50 })
title!: string;
@@ -21,12 +30,4 @@ export class Post {

@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;

@ManyToOne(() => User, (user) => user.posts, {
cascade: true
})
author!: User;

@OneToMany(() => Comment, (comment) => comment.id)
comments!: Comment[];
}
22 changes: 13 additions & 9 deletions packages/slack/src/models/User.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { Post } from './Post';
import { Comment } from './Comment';

@Entity()
export class User {
@PrimaryGeneratedColumn()
id!: number;
userId!: number;

@Column({ length: 50, unique: true })
username!: string;
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];

@OneToMany(() => Comment, (comment) => comment.author)
comments!: Post[];

@Column({ unique: true })
email!: string;

@Column()
password!: string;
@@ -24,21 +31,18 @@ export class User {
@Column({ nullable: true })
introduce!: string;

@Column({ length: 50, nullable: true })
imageName!: string;
@Column({ nullable: true })
imageUrl!: string;

@Column({ length: 50, nullable: true })
slackId!: string;

@Column({ length: 20, nullable: true })
@Column({ length: 50, nullable: true })
slackWorkspace!: string;

@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;

@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;

@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}
2 changes: 1 addition & 1 deletion packages/slack/tsconfig.json
Original file line number Diff line number Diff line change
@@ -33,5 +33,5 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src", "./src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
"exclude": ["node_modules", "dist"]
}

0 comments on commit 837783a

Please sign in to comment.