파이어베이스는 인증을 제공한다. 구글, 페이스북등은 손쉽게 연결할 수 있지만, 아쉽게도 네이버 로그인은 지원하지 않는다.
네이버 로그인은 파이어베이스의 커스텀 인증을 해야한다.
커스텀 인증을 하려면 서버가 필요하다. 커스텀 토큰을 발급하려면 Firebase Admin SDK 라는 라이브러리를 사용해야 하는데, 클라이언트에서, Firebase와 Firebase Admin SDK를 동시에 실행할 수 없기 때문이다.
그래서 파이어베이스의 서버리스 시스템인 Firebase Functions를 사용한다.
인증순서를 설명하기전에, 용어를 소개하면,
1. 클라이언트 = 리엑트 앱
2. 서버 = 여기서는 firebase functions
3. 네이버 API
이렇게 3가지가 필요하다.
1. 클라이언트에서 네이버 로그인 API를 호출한다.
2. 콜백으로 네이버 인증 코드를 받는다.
3. 인증 코드를 서버에 보낸다.
4. 서버에서 인증 코드를 네이버 엑세스 토큰으로 바꾼다.
5. 네이버 엑세스 토큰으로 네이버 유저정보를 얻어온다.
6. 네이버 유저정보로 파이어베이스 유저를 생성한다.
7. (Optional) 파이어베이스 유저정보에 권한을 추가한다.
8. 파이어베이스 유저 uid로 커스텀 토큰을 발급한다.
9. 커스텀 토큰을 리턴한다
10. 클라이언트에서 응답받은 파이어베이스 토큰으로 로그인한다.
11. (Optional) 파이어베이스 토큰을 로컬스토리지에 저장한다.
네이버 로그인 요청 페이지
위 API 명세를 참고하여 코드를 작성한다.
요청과 응답이 같은 사이트라는 확인을 위해, state를 생성하여 로컬스토리지에 저장하고 요청에 함께 보내고, 응답받은 콜백 페이지에서 로컬스토리지에서 꺼내서 대조한다.
API명세에 나온대로, response_type, client_id, redirect_uri, state 를 요청 파라미터로 url에 첨부해서 보낸다.
// /pages/login/index.tsx
const Login = () => {
async function handleSignup() {
const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
window.localStorage.setItem('naverState', state);
const { protocol, hostname, port } = location;
const params = [
'response_type=code',
`client_id=${process.env.NEXT_PUBLIC_NAVER_CLIENT_ID}`,
`redirect_uri=${protocol}//${hostname}${port ? `:${port}` : ''}/callback/naver`,
`state=${state}`
].join('&');
location.href = `https://nid.naver.com/oauth2.0/authorize?${params}`;
}
return (
<button onClick={handleSignup}>로그인</button>
)
}
콜백페이지
// /pages/callback/naver.tsx
import { httpsCallablem, getFunctions } from 'firebase/functions';
import { signInWithCustomToken, getAuth } from 'firebase/auth';
function naverLogin = async () => {
try {
// 파이어베이스 functions에서 사전에 만든 getFirebaseTokenByNaverCode 함수를 호출할 수 있도록 초기화 한다.
const getFirebaseTokenByNaverCode = httpsCallable(getFunctions(), 'getFirebaseTokenByNaverCode');
// 네이버코드를 파라미터로 넘겨줘서 서버에서 네이버 로그인을 하고 firebaseToken을 받는다.
const { data } = await getFirebaseTokenByNaverCode({ code });
const { firebaseToken } = data as { forebaseToken: string };
// 파이어베이스 토큰으로 로그인한다.
await signWithCustomToken(getAuth(), firebaseToken);
...
}
4. 서버에서 인증 코드를 네이버 엑세스 토큰으로 바꾼다.
5. 네이버 엑세스 토큰으로 네이버 유저정보를 얻어온다.
6. 네이버 유저정보로 파이어베이스 유저를 생성한다.
7. (Optional) 파이어베이스 유저정보에 권한을 추가한다.
8. 파이어베이스 유저 uid로 커스텀 토큰을 발급한다.
9. 커스텀 토큰을 리턴한다
10. 클라이언트에서 응답받은 파이어베이스 토큰으로 로그인한다.
11. (Optional) 파이어베이스 토큰을 로컬스토리지에 저장한다.
Firebase Functions
// 파이어베이스 앱을 초기화한다.
initializeApp();
const findClaim = async (email: string) => {
const strAdmins = process.env.ADMIN_CLAIM_ADMIN_EMAILS ?? "";
logger.info(strAdmins);
const admins = strAdmins.split(",");
if (loginType==="ta" && admins.includes(email)) {
return "admin";
}
return null;
};
const getNaverUserRecord = async (accessToken: string): Promise<{
id: string,
profile_image: string,
email: string,
name: string,
}> => {
const {data} = await axios.get("https://openapi.naver.com/v1/nid/me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (data.resultcode !== "00") {
throw new Error("Request Me failed.");
}
return data.response;
};
const getNaverAccessToken = async (code: string): Promise<string> => {
const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const {data} = await axios.get("https://nid.naver.com/oauth2.0/token", {
params: {
"grant_type": "authorization_code",
"client_id": `${process.env.NAVER_APP_CLIENT_ID}`,
"client_secret": `${process.env.NAVER_APP_CLIENT_SECRET}`,
"code": code,
"state": state,
},
});
return data.access_token;
};
export const getFirebaseTokenByNaverCode({cors: true}, async (requst, response) => {
try {
const { code } = request.body.data;
const naverToken = await getNaverAccessToken(code);
const naverUser = await getNaverUserRecord(naverToken);
let firebaseUser: UserRecord;
try {
firebaseUser = await getAuth().getUser(naverUser.id);
} catch (err) {
logger.info("user정보를 찾을 수 없어, 새 유저를 생성합니다.", err);
firebaseUser = await getAuth().createUser({
uid: naverUser.id,
email: naverUser.email,
photoURL: naverUser.profile_image,
displayName: naverUser.name,
});
}
const claim = await findClaim(`${firebaseUser.email}`);
if (claim === null) {
throw new Error("권한을 찾을 수 없음");
}
await getAuth().setCustomUserClaims(firebaseUser.uid, {[claim]: true});
const firebaseToken = await getAuth().createCustomToken(firebaseUser.uid);
response.status(200).json({data: {firebaseToken}});
} catch (error: any) {
response.status(500).json({data: "login fail", error: error.message});
}
})
댓글