현재 진행 중인 프로젝트에 직접 계정 생성/로그인을 구현하는 것 외에 OAuth 2.0을 통한 '카카오 계정으로 로그인' 기능을 구현하려고 한다.
프론트엔드는 React, 백엔드는 Koa, DB는 MongoDB를 사용하고 있다.
사용자가 카카오 계정으로 로그인하면 인가 코드를 받을 수 있고, 이 인가 코드로 다시 토큰을 발급받을 수 있다. 토큰의 고유 정보(id)로 DB에 사용자 계정을 생성하고 이미 같은 id로 계정이 존재하는 경우 해당 계정을 반환한다.
먼저 Kakao Developers에 가입한 후 간단한 애플리케이션 설정을 마친다.
인가 코드를 받기 위해서는 ① 사용자를 카카오 서버의 로그인 페이지로 이동시키고,
② 로그인을 마쳤을 때 카카오가 리다이렉트 시켜서 코드를 넘겨줄 페이지의 주소를 지정해야 한다.
<a href={`https://kauth.kakao.com/oauth/authorize?client_id=${REACT_APP_KAKAO_API}&redirect_uri=${REACT_APP_KAKAO_REDIRECT}&response_type=code`}>
<KakaoLoginButton>
<img
src={`${process.env.PUBLIC_URL}/kakao.png`}
alt="kakao_logo"
></img>
<span>카카오 로그인</span>
</KakaoLoginButton>
</a>
이때 REST API 키와 우리가 지정한 리다이렉트 페이지 주소가 필요하다.
.env를 이용해 따로 적어놓자.
이동하면 이런 페이지가 나오고, '계속하기'를 누르면 리다이렉트 페이지로 이동하며 쿼리 형태로 인가 코드를 얻을 수 있다.
// Kakao.tsx
// ...
const code = new URL(window.location.href).searchParams.get('code');
useEffect(() => {
if (code) {
localStorage.setItem('code', code);
dispatch(kakaoLogin({ code }));
}
}, []);
useEffect(() => {
if (user.username) {
navigate('/');
}
}, [user]);
리다이렉트 페이지를 라우팅하는 컴포넌트 내에서 인가 코드를 따오고 kakaoLogin 액션을 dispatch 한다. (백엔드 API 호출)
카카오 API에 직접 요청을 보낸다.
kakaoLogin
은 getKakaoToken
으로 토큰을 가져온 후, getKakaoInfo
로 토큰에 담긴 사용자 ID를 읽어온다. 그리고 해당 ID로 DB에 접근하여 새로운 계정을 생성하거나 기존 계정을 가져와 프론트엔드로 전달한다.
export const kakaoLogin = async (ctx: DefaultContext) => {
const { code } = ctx.request.body;
try {
const {
access_token,
expires_in,
refresh_token,
refresh_token_expires_in,
} = await getKakaoToken(code);
const id = await getKakaoInfo(access_token);
let user = await User.findByUsername(id);
if (!user) {
user = new User({
username: id,
snsProvider: 'kakao',
nickname: `사용자-${id}`.slice(0, 10),
});
await user.save();
}
ctx.body = user.serialize();
// 쿠키에 토큰 및 토큰 만료시간을 저장
ctx.cookies.set('kakao_access_token', access_token);
ctx.cookies.set('kakao_expires_in', Date.now() + expires_in * 1000);
ctx.cookies.set('kakao_refresh_token', refresh_token);
ctx.cookies.set(
'kakao_refresh_token_expires_in',
Date.now() + refresh_token_expires_in * 1000,
);
// 로컬 jwt 토큰 생성
const token = user.generateToken();
ctx.cookies.set('access_token', token, {
maxAge: 1000 * 60 * 60 * 24 * 7,
httpOnly: true,
});
} catch (e) {
ctx.throw(500, e as Error);
}
};
const getKakaoToken = async (code: string) => {
const { KAKAO_API } = process.env;
const url = 'https://kauth.kakao.com/oauth/token';
const params = {
grant_type: 'authorization_code',
client_id: KAKAO_API,
redirect_uri: 'http://localhost:3000/fitness-app/auth/kakao/redirect',
code,
};
try {
const response = await axios.post(url, null, {
params,
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
},
});
return response.data;
} catch (e) {
console.error(e as Error);
}
};
const getKakaoInfo = async (token: string) => {
const url = 'https://kapi.kakao.com/v1/user/access_token_info';
const config = {
headers: {
Authorization: `Bearer ${token}`,
},
};
try {
const response = await axios.get(url, config);
const { id } = response.data;
return id;
} catch (e) {
console.error(e as Error);
}
};
API의 request/response 스펙이 공식 문서에 잘 나와있기 때문에 잘 보고 따라하면 된다. 인자를 body가 아닌 params에 담는다는 것만 주의하면 된다.
백엔드가 보내준 사용자를 state에 잘 저장하면 된다.
export const kakaoLogin = createAsyncThunk(
'KAKAO_LOGIN',
async ({ code }: { code: string }) => {
const response = await api.kakaoLogin(code);
return response.data;
},
);
// ...
export const userSlice = createSlice({
// ...
extraReducers: (builder) => {
builder.addCase(kakaoLogin.fulfilled, (state, action) => {
state.user = action.payload;
})
.addCase(kakaoLogin.rejected, (state) => {
state.user = initialUser;
})
// ...
프론트엔드가 백엔드로 API를 요청할 때마다, 미들웨어를 통해 현재 요청을 보낸 사용자가 유효한지 검증한다.
카카오 로그인 기능을 추가했기 때문에, 로컬 토큰에 더하여 카카오 토큰의 유효성까지 확인해야 한다.
액세스 토큰이 유효하면 로컬 토큰을 갱신하여 사용할 수 있도록 하고, 만료된 경우엔 리프레쉬 토큰을 이용해 토큰을 재발급 받도록 한다.
만약 리프레쉬 토큰까지 만료되었다면 모든 토큰을 삭제하고 ctx.state에 user 값을 할당하지 않고 바로 next()로 넘겨 인증 에러를 발생시킨다.
현재 쿠키에 카카오 토큰이 존재한다면 해당 사용자는 카카오로 로그인한 사용자이므로 먼저 카카오 토큰을 검증한다. 그렇지 않다면 바로 로컬 토큰을 검증한다. 토큰 갱신은 refreshKakaoToken
이 수행한다.
const jwtMiddleware = async (ctx: Context, next: Next) => {
const token = ctx.cookies.get('access_token');
if (!token) return next();
const kakao_expires_in = ctx.cookies.get('kakao_expires_in');
const kakao_refresh_token = ctx.cookies.get('kakao_refresh_token');
const kakao_refresh_token_expires_in = ctx.cookies.get(
'kakao_refresh_token_expires_in',
);
// 카카오로 로그인했고 액세스 토큰이 만료되었을 때
if (kakao_expires_in && Date.now() > +kakao_expires_in) {
// 만약 리프레쉬 토큰이 만료되지 않았다면 토큰 갱신 요청
if (
kakao_refresh_token &&
kakao_refresh_token_expires_in &&
Date.now() < +kakao_refresh_token_expires_in
) {
await refreshKakaoToken(ctx, kakao_refresh_token);
// 리프레쉬 토큰도 만료된 경우 모든 토큰 삭제
} else {
removeAllToken(ctx);
return next();
}
}
// 이후 로컬 토큰 검증...
}
액세스 토큰이 만료되었고 리프레쉬 토큰이 유효하다면 다음 로직을 수행한다.
export const refreshKakaoToken = async (ctx: DefaultContext, token: string) => {
const { KAKAO_API } = process.env;
const url = 'https://kauth.kakao.com/oauth/token';
const params = {
grant_type: 'refresh_token',
client_id: KAKAO_API,
refresh_token: token,
};
try {
const response = await axios.post(url, null, {
params,
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
},
});
const {
access_token,
expires_in,
refresh_token,
refresh_token_expires_in,
} = response.data;
// 액세스 토큰 갱신
ctx.cookies.set('kakao_access_token', access_token);
ctx.cookies.set('kakao_expires_in', Date.now() + expires_in * 1000);
// 만약 새로운 리프레쉬 토큰이 발급된 경우 리프레쉬 토큰도 갱신
if (refresh_token) {
ctx.cookies.set('kakao_refresh_token', refresh_token);
ctx.cookies.set(
'kakao_refresh_token_expires_in',
Date.now() + refresh_token_expires_in * 1000,
);
}
} catch (e) {
console.error(e as Error);
}
};
카카오의 토큰 갱신 API에서 새로운 리프레쉬 토큰은 유효기간이 1개월 이내인 경우에만 응답되므로 따로 if문으로 분리했다.
토큰 유효기간을 쿠키에 저장하지 않고 토큰 자체만 저장한 후 나중에 해당 토큰으로 다시 요청을 보내 유효기간을 알아오는 방법도 있지만, 편의상 두 가지 모두 저장하도록 했다.
이후 로컬 토큰을 검증한다.
로그아웃과 계정 삭제는 거의 동일한 방식으로 구현할 수 있다.
현재 사용자가 카카오로 로그인했다면 로컬 계정 액션을 수행하기 전 카카오 계정 액션을 추가로 호출한다.
const onLogout = () => {
if (user.snsProvider === 'kakao') {
dispatch(kakaoLogout());
}
dispatch(logout());
persistor.purge();
};
const onDeregister = () => {
if (
!window.confirm(
'삭제한 계정은 복구할 수 없습니다.\n정말 삭제하시겠습니까?',
)
)
return;
if (user.snsProvider === 'kakao') {
dispatch(kakaoDeregister());
}
dispatch(deregister({ username: user.username }));
dispatch(logout());
persistor.purge();
};
카카오 로그아웃은 토큰만 만료시킨다. 연결 끊기(unlink)는 최초 로그인 시 동의 항목 또한 철회한다.
// kakao.ctrl.tsx
const kakaoQuit = async (ctx: DefaultContext, type: 'logout' | 'unlink') => {
const access_token = ctx.cookies.get('kakao_access_token');
if (!access_token) {
ctx.status = 401;
return;
}
const url = `https://kapi.kakao.com/v1/user/${type}`;
const config = {
headers: {
Authorization: `Bearer ${access_token}`,
},
};
try {
await axios.post(url, null, config);
removeAllToken(ctx);
ctx.status = 204;
} catch (e) {
ctx.throw(500, e as Error);
}
};
export const kakaoLogout = async (ctx: DefaultContext) =>
kakaoQuit(ctx, 'logout');
export const kakaoDeregister = async (ctx: DefaultContext) =>
kakaoQuit(ctx, 'unlink');
로컬 API 함수도 작성해준다. 로그아웃은 간단히 액세스 토큰만 삭제해주고, 회원 탈퇴(deregister)는 DB에서 실제 사용자 정보를 삭제하도록 한다.
// local.ctrl.tsx
export const logout = async (ctx: DefaultContext) => {
ctx.cookies.set('access_token');
ctx.status = 204;
};
export const deregister = async (ctx: DefaultContext) => {
const inputSchema = Joi.object().keys({
username: USERNAME_SCHEMA,
});
const result = inputSchema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
const { username } = ctx.request.body;
try {
const user = await User.findByUsername(username);
if (!user) {
ctx.status = 404;
return;
}
User.findByIdAndDelete(user._id).exec(); // DB에서 삭제
ctx.status = 204;
} catch (e) {
ctx.throw(500, e as Error);
}
};
역시 전체 흐름을 파악하는 게 중요한 것 같다. 처음엔 많이 복잡해보였지만 계속 해보니 적당히 이해가 되었다.
참조
Node.js 교과서(2판)
리액트를 다루는 기술(개정판)
REST API | Kakao Developers
REST-API 활용한 카카오 소셜 로그인 구현(feat. React)