✏️ 작성자: 김소현
📛 작성자의 한마디: 토큰 어렵다 @!
안녕하세요, 솝트 30기 앱잼에서 헬푸미 팀에서 서버를 담당하고 있는 사람입니다. 🐣 헬푸미의 기능 중 하나인 소셜 로그인을 연동하기 위해서 access token과 refresh token을 활용한 인증 방식을 공부하고 적용할 수 있었습니다. 개인적으로 꽤 어려웠던 부분이어서 기록으로 남기고자 합니다.
서비스를 이용하다 보면 꽤 많은 곳에서 소셜 로그인을 볼 수 있습니다. 로그인 하면 서비스에 회원가입 되거나 로그인 되어 서비스 기능을 사용할 수 있습니다. 그렇다면 이 로직은 어떻게 이뤄지는 것일까요? 소셜 로그인과 서비스가 연동되는 과정을 설명해보겠습니다.
먼저 사용자가 소셜 서비스에 로그인을 하게 되면, 소셜 서비스 쪽에서는 사용자에게 access token
을 제공합니다. (여기서 사용자는 access token으로 소셜 서비스를 이용할 수 있습니다.)
소셜 서비스에서 받아 온 access token
을 서비스로 보내면 서비스에서는 회원 인증을 거쳐 회원가입 또는 로그인 시킨 후 access token
과 refresh token
을 제공합니다. 단, 여기서 서비스가 제공하는 access token
과 refresh token
은 소셜 서비스의 토큰과는 다른 것임을 명시해야 합니다. (서비스 이미지는 헬푸미 서비스 로고로 대체하겠습니다.)
카카오 서비스의 access token으로 회원 인증하는 코드를 살펴봅시다.
const kakaoAuth = async (kakaoAccessToken: string) => {
try {
const user = await axios({
method: "get",
url: "https://kapi.kakao.com/v2/user/me",
headers: {
Authorization: `Bearer ${kakaoAccessToken}`,
},
});
const userId = user.data.id;
if (!userId) return execptionMessage.INVALID_USER;
if (!user.data.kakao_account) {
return {
userId: userId,
email: null,
};
}
const kakaoUser: SocialUser = {
userId: userId,
email: user.data.kakao_account.email,
};
return kakaoUser;
} catch (error) {
logger.e("KakaoAuth error", error);
return null;
}
};
./src/config/auth.ts
파일에서 axios 모듈을 활용하여 해당 url로 카카오 서비스의 access token
을 보내서 회원 인증에 성공하면 회원 정보를 받을 수 있습니다. 위 코드에서는 소셜 서비스 회원의 _id
값과 email
값을 가져오고 있습니다. _id
값은 필수적으로 넘어오는 값이지만, email
값은 사용자가 정보 제공 동의 체크 유무에 따라 넘어올 수도, 그렇지 않을 수도 있기 때문에 nullable 처리 해주어야 합니다.
class KakaoAuthStrategy implements SocialAuthStrategy {
execute(accessToken: string): Promise<any> {
return auth.kakaoAuth(accessToken);
}
}
./src/config/services/SocialAuthStrategy.ts
파일에서 kakaoAuth 함수에서 값( {_userId: '...', email: '...'}
)을 반환 받아옵니다. 이 구조는 같은 헬푸미 팀의 다른 서버 파트 담당자가 설계하여 리팩토링 코드 하셨습니다. ( 관련 글 보러가기 )
export type SocialPlatform = "kakao" | "naver" | "apple";
const getUser = async (social: SocialPlatform, accessToken: string) => {
try {
const user = await authStrategy[social].execute(accessToken);
return user;
} catch (error) {
logger.e(error);
throw error;
}
};
./src/services/UserService.ts
파일에서 authStrategy 함수에서 소셜 로그인의 회원 정보를 받아와 다시 반환 합니다.
/**
* @route POST /auth
* @desc Authenticate user & Get token
* @access Private
*/
const getUser = async (req: Request, res: Response) => {
const social = req.body.social;
const token = req.body.token;
if (!social || !token) {
return res
.status(sc.UNAUTHORIZED)
.send(BaseResponse.failure(sc.UNAUTHORIZED, message.NULL_VALUE_TOKEN));
}
try {
const user = await UserService.getUser(social, token);
./src/controllers/UserController.ts
파일에서 request body에서 social
(소셜 서비스명) 값과 token
(소셜 서비스에서 발급받은 access token) 값을 받아옵니다. (받아 온 값이 없을 경우 401 에러를 반환합니다.) 받아 온 값을 Service로 넘겨서 소셜 서비스의 회원 정보를 받아옵니다.
const existUser = await UserService.findUserById(
(user as SocialUser).userId,
social,
);
if (!existUser) {
const data = createUser(social, user);
return res
.status(sc.CREATED)
.send(
BaseResponse.success(sc.CREATED, message.SIGN_UP_SUCCESS, await data),
);
}
소셜 서비스 회원 정보 중 _id 값을 가지고 서비스의 DB에서의 유저 존재를 체크합니다. 여기서 해당하는 유저가 존재하면 이미 서비스에 회원가입이 되어 있는 유저이고, 그렇지 않으면 신규 유저라는 것이 됩니다.
async function createUser(social: string, user: SocialUser) {
const refreshToken = jwt.createRefresh();
const newUser = await UserService.signUpUser(
social,
(user as SocialUser).userId,
(user as SocialUser).email,
refreshToken,
);
const accessToken = jwt.sign(newUser._id, newUser.email);
return {
user: newUser,
accessToken: accessToken,
refreshToken: refreshToken,
};
}
유저가 존재하지 않으면, createUser 함수로 social
(소셜 서비스명) 값과 user
(소셜 서비스 회원 정보) 값을 보내 회원가입을 진행합니다. refresh token
을 발급하여 소셜 서비스 및 해당 회원 정보와 함께 Service로 보내 회원가입을 진행한 후 결과 값을 받아옵니다. 그 후 회원의 _id
값과 email
값을 암호화하여 access token
을 발급 받아와 유저 정보 및 refresh token
값과 함께 반환하여 회원가입 처리 합니다.
const createRefresh = () => {
const refreshToken = jwt.sign({}, config.jwtSecret, { expiresIn: "14d" });
return refreshToken;
};
refresh token
은 ./src/modules/jwtHandler.ts
파일에서 위와 같이 암호화 하여 발급한 후 반환합니다. (유효기간은 14일로 지정함.)
const signUpUser = async (
social: string,
socialId: string,
email: string,
refreshToken: string,
) => {
try {
let user;
user = new User({
// 서비스에 필요한 유저 정보
refreshToken: refreshToken,
});
await user.save();
return user;
} catch (error) {
logger.e("", error);
throw error;
}
};
서비스에 필요한 유저 정보와 refresh token
값을 넣어 새로운 유저를 생성합니다. (회원가입 완료 !~!)
const sign = (userId: Types.ObjectId, email: string) => {
const payload = {
id: userId,
email: email,
};
const accessToken = jwt.sign(payload, config.jwtSecret, { expiresIn: "1h" });
return accessToken;
};
access token
은 ./src/module/jwtHandler.ts
파일에서 유저 정보로 암호화 하여 반환합니다. (유효기간은 1시간으로 지정함.)
다시 UserController.ts 파일로 돌아와서,
const refreshToken = jwt.createRefresh();
const accessToken = jwt.sign(existUser._id, existUser.email);
await UserService.updateRefreshToken(existUser._id, refreshToken);
const data = {
user: existUser,
accessToken: accessToken,
refreshToken: refreshToken,
};
return res
.status(sc.OK)
.send(BaseResponse.success(sc.OK, message.SIGN_IN_SUCCESS, data));
이미 존재하는 유저이면 refresh token
과 access token
값을 발급하여 유저 정보와 함께 결과로 반환합니다. 반환하기 전에 유저 정보에 refresh token
을 업데이트 해야 합니다. (로그인 완료 !~!)
소셜 로그인을 연동할 때, access token
과 refresh token
으로 굳이 왜 2개의 토큰이 필요한 지 이해가 되지 않았었습니다. 그래서 구글링으로 알아낸 사실은 보안상 문제였습니다.
서비스의 유저는 access token
으로 인증을 받아 서비스를 이용하는데, 중간에 이 토큰 값이 유출된다면 보안상의 위험성이 발생합니다.
이를 방지하기 위해서 유저의 토큰은 유출되기 전에 자주 바뀌어야 한다는 것입니다. 그래서 access token
의 유효시간을 1시간으로 잡았습니다.
유저가 수시로 로그인을 해서 access token
값을 업데이트 해도 되겠지만, 유저는 매우 불편함을 느낄텐데 계속 불편한 서비스를 이용하고 싶을까요?
그래서 refresh token
으로 access token
이 만료되었을 때 새로 발급하도록 합니다. refresh token
값은 유저의 DB에 저장되어 쉽게 노출되지 않아서 상대적으로 안전합니다.
하지만 100% 유출되지 않는다는 보장은 없으니 14일 정도의 만료 기간을 잡아 유저로부터 지속적으로 로그인 하여 토큰 값을 업데이트 시킵니다.
그렇다면 refresh token
으로 어떻게 access token
을 새로 발급받을 수 있을까요?
유저의 refresh token
을 서비스로 넘기면, 서비스는 토큰 값을 DB에서 존재함을 판단하고, 존재한다면 새로운 access token
값을 다시 넘겨줍니다.
코드로도 살펴볼까요?
const access = jwt.verify(accessToken as string);
if (access === exceptionMessage.TOKEN_INVALID) {
return res
.status(statusCode.UNAUTHORIZED)
.send(
BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
);
}
./src/controllers/TokenController.ts
파일에서 토큰을 재발급 합니다. 먼저 access token
을 복호화 합니다. 유효한 토큰이라면 유저의 정보(_id
와 email
값)를 얻을 것입니다. 하지만 그렇지 않다면 에러가 발생합니다. 복호화 했을 때 유효하지 않다고 판단되면 401 상태코드를 반환합니다.
const verify = (token: string) => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
return decoded;
} catch (error) {
if ((error as JsonWebTokenError).message === "jwt expired") {
logger.e("만료된 토큰입니다.", error);
return em.TOKEN_EXPIRED;
}
if ((error as JsonWebTokenError).message === "invalid signature") {
logger.e("유효하지 않은 토큰입니다.", error);
return em.TOKEN_INVALID;
}
logger.e("유효하지 않은 토큰입니다.", error);
return em.TOKEN_INVALID;
}
};
복호화 하는 코드는 ./src/module/jwtHandler.ts
파일에서 처리 합니다. 복호화 했을 때 회원 정보를 얻으면 그대로 반환하고, 에러가 발생하였으면 에러의 종류에 따라 적당한 값을 보내줍니다.
다시 Controller 파일로 돌아와서,
const refresh = jwt.verify(refreshToken as string);
if (refresh === exceptionMessage.TOKEN_INVALID) {
return res
.status(statusCode.UNAUTHORIZED)
.send(
BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
);
}
if (refresh === exceptionMessage.TOKEN_EXPIRED) {
return res
.status(statusCode.UNAUTHORIZED)
.send(
BaseResponse.failure(statusCode.UNAUTHORIZED, message.EXPIRED_TOKEN),
);
}
유효한 access token
임을 확인했으면, 다음으로 refresh token
을 복호화 하여 확인합니다. refresh token
이 유효하지 않거나 만료되었으면 401 에러를 반환합니다.
const user = await UserService.findUserByRfToken(refreshToken as string);
if (!user) {
return res
.status(statusCode.UNAUTHORIZED)
.send(
BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
);
}
const data = {
accessToken: jwt.sign(user._id, user.email),
refreshToken: refreshToken,
};
return res
.status(statusCode.OK)
.send(
BaseResponse.success(statusCode.OK, message.CREATE_TOKEN_SUCCESS, data),
);
유효한 refresh token
이면 DB에서 해당 값을 가진 유저를 찾습니다. 유저를 찾지 못하면 401 에러를 반환합니다. 해당 값을 가진 유저가 있으면 access token
을 새로 발급하여 반환함으로써 제공합니다.
🥚🐣🐥
소셜 로그인 연동과 refresh token 이라는 것을 처음 접해보기도 하고, 이해가 쉽게 되지도 않았었는데 글을 쓰다보니 글이 길어질수록 왜 어려워했는 지 기억도 나고, 일주일 동안 찾아 본 기억도 새록새록 나네요.
다른 소셜 로그인 연동을 앞으로 처음 접해볼 분들께 저보다 좀 더 편하게 공부하길 바라는 마음에서 소셜 로그인 연동과 토큰을 주제로 정해 글을 작성해보았습니다.
감사합니다 !!