- NextJS
- Firebase
실시간 채팅 애플리케이션 구현(2)
Firebase를 이용한 이메일 기반 인증하기
Intro
지난 글에 이어서 채팅앱에 필요한 사용자 인증을 적용해봅니다
Firebase의 Authentication 콘솔에서 계정에 관한 모든 설정이 관리됩니다
구현전략
🤨 데스트탑 앱을 만들어야 하니 어떤 프로그램을 쓰면 좋을까 NextJS와 Electron을 결합한 Nextron이란 라이브러리로 환경을 구성해보자 😎 백엔드 서버를 따로 두지 않고 Firebase에서 지원하는 인증을 사용해보자 Firebase 콘솔에서 새로운 프로젝트를 생성한 후 config를 프로젝트에 세팅한다
😲 Firebase 인증을 적용하고 서버사이드에서 접근을 제한 해보자
- 1. 계정 생성 페이지를 생성하고 createUserWithEmailAndPassword 함수로 계정을 만든다
- 2. 로그인 페이지를 생성하고 signInWithEmailAndPassword 함수로 로그인을 한다
- 3. 토큰 관리를 위해 context를 생성한 후 onAuthStateChanged 함수로 유저의 로그인 여부를 판별한 뒤 쿠키에 저장한다
- 4. ServerSideProps를 사용하여 인증 상태를 판별한 뒤 성공 시 채팅 리스트로 실패 시 로그인 페이지로 보낸다 😏 채팅리스트와 각 채팅방에 입장하기 위한 라우터 처리는 이렇게 해보자 채팅방 입장은 pages에 chat/[id].tsx로 즉, 채팅방의 라우터 id로 접근하도록 한다(/chat/8796) 🙄 각 채팅방에 입장 시 입력된 메시지를 어떻게 보여주면 될까
- 1. useFirestoreQuery로 데이터베이스에 추가된 컬렉션의 메시지를 orderBy로 내림순으로 가져온다
- 2. 채팅 내용을 입력하고 firestore의 collection과 add 함수를 이용하여 메시지를 조회하고 추가한다
- 3. onSnapshot을 사용하여 채팅을 입력하기 전 쿼리와 입력 후 쿼리를 비교하여 채팅 내용을 업데이트 한다
계정 생성
이메일과 암호를 기반으로 한 계정을 생성하기 위해서는 createUserWithEmailAndPassword 함수를 사용하여
계정을 만들 수 있는데 암호는 6자 이상 입력하여야 한다.
// renderer/pages/join.tsx
import React, { useState } from "react";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { firebaseClientAuth } from "../firebase.config";
import { useRouter } from "next/router";
function Join() {
const router = useRouter();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const signUp = () =>
createUserWithEmailAndPassword(firebaseClientAuth, email, password)
.then((userCredential) => {
// Signed in
alert("Account has been created." + "Email:" + userCredential.user.email)
})
.catch((error) => {
const errorCode = error.code;
const errorMessage = error.message;
});
return (
<>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
<button onClick={signUp}>회원가입</button>
</>
);
}
export default Join;
앞단에서 유효성 검사 이후 추가적인 처리를 적용 하려면 다음과 같이 코드를 추가할 수 있다.
const signUp = () =>
createUserWithEmailAndPassword(firebaseClientAuth, email, password)
.then((userCredential) => {
// Signed in
const user = userCredential.user;
if(!user.emailVerified) {
alert("이메일을 정확히 입력해 주세요!")
}
})
.catch((error) => {
const errorCode = error.code;
const errorMessage = error.message;
});
이 외에 Firebase 공식문서의 계정 관리를 참고하면
가입된 계정에 대해 수정, 삭제 기능을 지원하고 있다
로그인
로그인은 입력한 이메일과 패스워드가 signInWithEmailAndPassword 함수를 통해 Firebase 인증 백엔드에 전송되고 자격이 증명되면
ID 토큰(JWT) 및 리프레쉬 토큰을 반환 받는 형식으로 프로세스가 진행이 된다.
// renderer/pages/login.tsx
import React, { useState } from "react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { firebaseClientAuth } from "../firebase.config";
import { useRouter } from "next/router";
import Head from "next/head";
function Login() {
const router = useRouter();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const logIn = async () => {
await signInWithEmailAndPassword(firebaseClientAuth, email, password);
await router.push('/');
};
return (
<React.Fragment>
<Head>
<title>Login</title>
</Head>
<div className="flex">
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
<button onClick={logIn}>로그인</button>
</div>
</React.Fragment>
);
}
export default Login;
Firebase ID 토큰의 지속시간은 1시간으로 비교적 짧은 편인데 토큰 탈취와 도난 복구를 위함이라고 한다.
인증 상태 판별
자 그렇다면,
로그인이 안된 상태일 때 혹은 로그인 이후 인증 관리는 어떻게 처리하면 될까
아마 이 부분이 가장 난해한 부분이라 생각된다
새 페이지로 이동 시에도 인증 상태를 유지하면서 로그인 여부에 따라 유저의 정보를 관리할 수 있도록 해야 한다
// renderer/context/AuthProvider.tsx
import React, { useState, useMemo, useEffect, useContext, createContext } from 'react'
import { User, getAuth } from 'firebase/auth'
import { firebaseClientAuth } from '../firebase/firebaseClient'
import nookies from 'nookies'
const AuthContext = createContext<{ user: Pick<User, 'displayName' | 'photoURL'> | null }>({
user: null,
})
const AuthProvider = ({ children }) => {
const [userState, setUserState] = useState<Pick<User, 'displayName' | 'photoURL'> | null>(null)
const user = useMemo(
() => ({
user: userState,
}),
[userState],
)
useEffect(() => {
return firebaseClientAuth.onAuthStateChanged(async (user) => {
if (!user) { // 인증 상태의 변경을 감지한 뒤 로그인 중이 아니라면 userState를 null로 변경한다
setUserState(null)
nookies.set(null, 'token', '', {
httpOnly: process.env.NODE_ENV !== 'development',
secure: process.env.NODE_ENV !== 'development',
maxAge: 60 * 60,
sameSite: 'strict',
path: '/',
})
return
}
setUserState({
displayName: user.displayName,
photoURL: user.photoURL,
})
const token = await user.getIdToken()
nookies.destroy(null, 'token')
nookies.set(null, 'token', token, { // token 정보를 지닌 쿠키를 생성한다
httpOnly: process.env.NODE_ENV !== 'development',
secure: process.env.NODE_ENV !== 'development',
maxAge: 60 * 60,
sameSite: 'strict',
path: '/',
})
})
}, [])
useEffect(() => {
const refreshToken = setInterval(async () => {
const { currentUser } = getAuth()
if (currentUser) await currentUser.getIdToken(true)
}, 10 * 60 * 1000)
return () => clearInterval(refreshToken)
}, [])
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>
}
export const useAuthContext = () => {
return useContext(AuthContext)
}
export default AuthProvider
전역에서 사용할 수 있도록 내보낸 AuthProvider로 App을 감싸준다
// renderer/pages/_app.tsx
import type { AppProps } from 'next/app'
import AuthProvider from '../context/AuthProvider'
function MyApp({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
)
}
export default MyApp
Authenticated server-side rendering with Next.js and Firebase 소스를 참고 하였다
로그인 페이지에서 사용자가 로그인에 성공하면 Firebase는 유저의 인증 상태를 클라이언트로 반환해주는데
이때, onAuthStateChanged를 사용하여 판별할 수 있다. 즉, 로그인 성공 시에는 token 정보를 쿠키로 저장하고
로그아웃 시에는 다시 token 정보가 담긴 쿠키에서 값만 날린다
이후 전역에서 로그인한 유저의 정보를 활용할 수 있도록 context를 사용했다!
(다른 상태관리 라이브러리를 사용할 수 도 있지만 사용 빈도가 비교적 적다고 판단하여 context만으로 충분하다고 생각하였다)
import { useAuthContext } from '../context/AuthProvider'
function Profile() {
const { user } = useAuthContext()
console.log(user?.displayName)
}
컴포넌트 전역에서 생성된 useAuthContext를 이용하여 유저의 정보를 활용할 수 있다
접근 제한
마지막으로 인증되지 않은 사용자의 접근을 제한하기 위해 verifyIdToken 함수로 token 값을 전달하여
확인을 하게 된다. 만약 이때 에러가 발생되면 로그인 페이지로 이동하도록 처리해주면 된다
nookies 라이브러리를 사용한 이유가 여기에 있다.
nookies는 ServerSide의 context에 접근하여 모든 쿠키를 가져올 수 있는데 token 정보가 담긴 쿠키를 받아
verifyIdToken 함수에 인자로 토큰 값을 전달할 수 있다
// renderer/pages/index.tsx
import React, { useEffect } from 'react'
import { GetServerSidePropsContext } from 'next'
import { admin } from '../firebase/firebaseAdmin'
import nookies from 'nookies'
import Layout from '../components/Layout'
function HomePage() {
return (
<Layout>
// TODO: 채팅 리스트 노출
</Layout>
)
}
export const getServerSideProps = async (
ctx: GetServerSidePropsContext,
): Promise<{ props: {} }> => {
try {
const cookies = nookies.get(ctx)
const decodedToken = await admin.auth().verifyIdToken(cookies.token) // 쿠키에 담킨 token 정보를 확인한다
// TODO: 채팅리스트를 가져옴
} catch {
ctx.res.writeHead(302, { Location: '/login' }) // 에러가 발생되면 로그인 페이지로 라우팅한다
ctx.res.end()
return {
props: {},
}
}
}
export default HomePage
Github 주소: nextron-desktop-chat에서 전체 소스를 확인할 수 있다.