[Toy Project 기록하기 6] 회원가입 & 이메일 인증하기
회원가입 시 가짜 이메일로 가입을 남발하는 일을 막기 위해 이메일 인증 과정을 추가했습니다. 유저가 가입할 때 토큰을 발급하여 가입 시 입력한 이메일로 토큰을 전달해 인증하는 방식으로 구현했습니다. 이 과정에서 nodemailer를 통해 쉽게 메일을 보내고, 메일의 전송 여부를 테스트하기 위해 mailtrap을 이용했습니다.
Nodemailer
node.js 서버를 이용해 이메일 전송을 도와주는 모듈입니다.
npm install dotenv nodemailer
이메일을 보내려면 transporter 객체가 필요합니다.
let transporter = nodemailer.createTransport(transport[, defaults])
transporter 객체를 통해 메일을 보낼 수 있습니다.
transporter.sendMail(data[, callback])
Mailtrap
이메일 발송, 수신이 제대로 되었는지 테스트를 해볼 수 있는 SMTP를 제공하는 서비스입니다.
SMTP Settings에서 Integrations를 Nodemailer로 설정해주면 아래와 같은 코드가 나타납니다.
코드를 복사하고 user와 pass를 .env에 따로 저장해 불러왔습니다.
// utils/mail.ts
import nodemailer from 'nodemailer';
// 메일을 보내기 위한 transporter 객체 정의하기
export const transporter = nodemailer.createTransport({
host: 'smtp.mailtrap.io',
port: 2525,
auth: {
user: process.env.MAILTRAP_USER,
pass: process.env.MAILTRAP_PASS,
},
});
이메일을 보내는 과정은 회원가입을 하며 함께 진행하겠습니다.
회원가입 조건
✔️ name, username, email, password를 필수로 입력해야 한다.
✔️ username, email은 중복되지 않도록 한다.
다음 2가지 조건을 부합하는 경우에만 회원가입을 진행할 수 있습니다.
회원가입 로직
1. 클라이언트에서 회원가입에 필요한 form을 작성한 뒤 callbackUrl과 함께 제출하면 유효성 검사 후 서버로 axios를 통해 /user 경로로 post 요청을 보냅니다.
// Signup.tsx
import { signup } from '../services/user';
const callbackUrl = `${CLIENT_DOMAIN}/user/finish`;
export default function Signup() {
// ...
const {
mutate: signupMutation,
isLoading,
isError,
error,
} = useMutation(signup, {
onSuccess: () => {
navigate(APP.NOTICE);
},
});
const onValid: SubmitHandler<TSignupForm> = async ({
name,
email,
username,
password,
}: TSignupForm) => {
signupMutation({ name, username, email, password, callbackUrl });
};
return (
<form
method="POST"
onSubmit={handleSubmit(onValid)}
>
<h2>Signup</h2>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
// ...
/>
// ...
</div>
<div>
<label htmlFor="username">
Username
</label>
<input
id="username"
// ...
/>
// ...
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
// ...
/>
// ...
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
// ...
/>
// ...
</div>
// ...
<button type="submit" disabled={isLoading}>
{isLoading ? '가입 중' : '가입하기'}
</button>
</form>
);
}
// services/user.ts
export const signup = (data: TSignupMutation) => {
return axiosInstance.post<TSignupMutation>('/user', data);
};
2. 서버의 라우터에서 /user 경로로 들어온 데이터는 유효성 검사 후 통과 시 createUser 컨트롤러로 보냅니다.
// userRouter.ts
const userRouter = express.Router();
userRouter.post(
'/',
validate([
validationRule.Name.Create(),
validationRule.Username.Create(),
validationRule.Email.Create(),
validationRule.Password.Create(),
validationRule.CallbackUrl.Create(),
]),
createUser
);
export default userRouter;
3. createUser로 넘어온 데이터로 유저를 db에 등록 후 가입한 이메일로 callbackUrl과 token을 담아 인증 메일을 보냅니다.
import { transporter } from '../utils/mail';
const getVerifyEmailToken = (email: string) =>
jwt.sign(
{
email,
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '1h' }
);
const sendVerifyEmail = ({ email, callbackUrl }: TSendVerifyEmail) => {
const token = getVerifyEmailToken(email);
// 메일 내용
const mailContent = {
from: 'Doljabee',
to: email,
subject: 'Doljabee 회원가입 확인 메일입니다.',
html: signup({ callbackUrl, token }),
};
// transporter를 통해 메일 보내기
transporter.sendMail(mailContent);
};
export const createUser = async (
req: Request<TCreateUserVariables>,
res: Response
) => {
try {
const { name, email, username, password, callbackUrl } = req.body;
const hashedPassword = await getHashedPassword(password);
const user = new User({
name,
email,
username,
password: hashedPassword,
verifyEmail: false,
});
await user.save();
res.status(200).send({ message: SUCCESS.CreateUser });
try {
// 회원가입 완료 후 인증 메일 보내기
return sendVerifyEmail({ email, callbackUrl });
} catch (error) {
return res.status(500).send({ message: DEFAULT_ERROR_MESSAGE });
}
} catch (error) {
return res.status(500).send({ message: DEFAULT_ERROR_MESSAGE });
}
};
mailtrap으로 이메일이 온 것을 확인할 수 있습니다.
4. 유저가 이메일 확인 시 callbackUrl 뒤에 토큰이 붙어서 인증 완료 페이지로 이동합니다.
function App() {
return (
<div className="App">
<Routes>
<Route path='/user/finish' element={<Auth />}></Route>
// ...
</Routes>
</div>
);
}
5. 클라이언트에서 인증 완료 페이지에 도달하면 전달받은 토큰을 서버로 전송합니다.
import { verifyEmail } from '../services/user';
export default function Auth() {
// 주소창 뒤에 token이 붙어있어 쿼리스트링으로 token값만 가져오기
const query = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
});
const { isLoading, isError, error } = useQuery('verifyEmail', () =>
verifyEmail(query.token as string)
);
// ...
}
// services/user.ts
export const verifyEmail = (token: string) => {
return axiosInstance.post<TSignToken>('/user/verify-email', { token });
};
// ...
6. 서버에서는 클라이언트로부터 전달 받은 토큰이 유효한지 체크하고 인증을 완료합니다.
const userRouter = express.Router();
userRouter.post('/verify-email', verifyEmail);
// ...
export default userRouter;
export const verifyEmail = async (req: Request, res: Response) => {
try {
const { token } = req.body;
const payload = jwt.verify(
token,
process.env.ACCESS_TOKEN_SECRET
) as TTokenPayload;
const user = await User.findOne({ email: payload.email });
if (!user) {
return res.status(400).send({ message: FAILURE.CannotFindUser });
}
await user.updateOne({ $set: { verifyEmail: true } });
return res.status(200).send({ message: SUCCESS.VerifyEmail });
} catch (error) {
return res.status(405).send({ message: FAILURE.VerifyEmail });
}
};
🧗♀️ 제가 진행한 프로젝트가 궁금하다면
🔽 프론트엔드는 이곳에서 확인하실 수 있습니다.
https://github.com/Team-Madstone/doljabee-fe
GitHub - Team-Madstone/doljabee-fe: Climbing Community
Climbing Community. Contribute to Team-Madstone/doljabee-fe development by creating an account on GitHub.
github.com
🔽 백엔드는 이곳에서 확인하실 수 있습니다.
https://github.com/Team-Madstone/doljabee-be
GitHub - Team-Madstone/doljabee-be: Climbing Community
Climbing Community. Contribute to Team-Madstone/doljabee-be development by creating an account on GitHub.
github.com