사용자 인증과 JSON 웹 토큰
사용자 인증은 각각의 사용자를 구분하는 목적으로 이용되므로 서비스를 제공하는 서버에 사용자가 입력한 정보를 저장한다. 하지만 구글, 페이스북, 네이버 등 다른 사이트에서 가입되어 입력한 정보를 바탕으로 회원가입을 하용하기도 한다.
이런 방식으로 동작하는 서버들은 OAuth(open standard for access delegation)라고 하는 표준에 기반하며 OAuth는 모두 JSON 웹 토큰이란 기술에 기반을 두고 있다. 토큰(token)은 보통 문자열이나 숫자로 만든다.
JSON 웹 토큰(JWT)은 선택적 서명(optional signature)과 선택적 암호화(optional encryption)기술을 사용해 데이터를 만들게 하는 인터넷 표준이며 명칭은 REF 7519이며 HTTP 헤더의 Authorization항목에 값을 전송하는 방식으로 동작한다.
headers: {
Authorization: `Bearer ${jwt}`
}
이러한 HTTP 헤더를 수신받은 서버는 headers의 Authorization 항목에서 JWT값을 검증 후 정상작업을 수행하며 작업의 결과를 반환한다. JWT는 보통 회원가입 시 서버에서 생성하며 웹 브라우저 쪽 프런트엔드 프레임워크에 전달한다.
결과적으로 프런트엔드에서 JWT 값을 보관하고 있다 서버 API 호출 시 HTTP Headers의 Authorization 항목에 실어서 전송한다.
JWT 기능 구현하기 - 서버
Node.js 환경에서 JSON 웹 토큰과 관련된 기능은 jsonwebtoken 패키지를 사용한다.
> npm i jsonwebtoken
> npm i -D @types/jsonwebtoken
jsonwebtoken 패키지는 sign, verify 함수를 사용한다. sign 함수는 JSON 웹 토큰을 생성하고 verify 함수는 jwt를 검증하기 위해 사용된다.
import {sign, verify} from jsonwebtoken
const secret = 'payload를 암호화할 키'
// sign(payload, secret, options)
// options: 옵션을 설정(여기서는 유효기한을 설정)
const jwt = sign('Jack', secret, {expiresIn: '1d'})
const decoded = verify(jwt, secret, options) // jwt 검증
jsonwebtoken 패키지가 제공하는 기능을 프로그래밍으로 좀 더 쉽게 사용할 수 있게 jwtP.ts를 추가하자. 추가 후 ts-node명령을 통해 실행 로그를 확인하자.
import * as U from "../utils";
const jwtNomalTest = async () => { //웹 토큰 생성 & 검증 테스트
try {
const jwt = await U.jwtSignP({ name: "Jack", age: 32 });
console.log("jwt", jwt);
const decoded = await U.jwtVerifyP(jwt);
console.log("decoded", decoded);
} catch (e) {
if (e instanceof Error) console.log("error", e.message);
}
};
const jwtExceptionTest = async () => { // 토큰이 비정상 테스트("1234")
try {
const decoded = await U.jwtVerifyP("1234");
console.log("decoded", decoded);
} catch (e) {
if (e instanceof Error) console.log("error", e.message);
}
};
const jwtExpireTest = async () => { // 토큰 유효시간 생성 & 검증 테스트
const jwt = await U.jwtSignP({ name: "Jack", age: 32 }, { expiresIn: "1s" });
const id = setTimeout(async () => {
try {
const decoded = await U.jwtVerifyP(jwt);
console.log("decoded", decoded);
} catch (e) {
if (e instanceof Error) console.log("error", e.message);
}
}, 2000);
};
jwtNomalTest().then(jwtExceptionTest).then(jwtExpireTest);
비밀번호 해시값 구하기 - 서버
사용자 인증 라우터에서 사용될 비밀번호를 해시값(hash value)으로 변경하기 위해 bcrypt 패키지를 사용해 구현하자. 이 패키지의 bcrypt 객체를 통해 hash와 compare 함수를 bcrypt.hash, bcrypt.compare 형태로 사용할 수 있다.
> npm i bcrypt
> npm i -D @types/bcrypt
Promise 버전인 hash와 compare 함수를 이용해 비밀번호를 해시하는 함수를 hashPasswordP.ts 파일로 추가하자.
import bcrypt from "bcrypt";
const saltRounds = 10;
export const hashPasswordP = (password: string) =>
new Promise<string>(async (resolve, reject) => {
try {
const salt = await bcrypt.genSalt(saltRounds); // 임의의 문자열 생성
const hash = await bcrypt.hash(password, salt); // 임의의 값으로 해시화
resolve(hash);
} catch (e) {
reject(e);
}
});
// 비밀번호 검증
export const comparePasswordP = (password: string, hashedPassword: string) =>
new Promise<boolean>(async (resolve, reject) => {
try {
const result = await bcrypt.compare(password, hashedPassword);
resolve(result);
} catch (e) {
reject(e);
}
});
추가한 hashPasswordP.ts의 함수를 테스트하기 위해 hashTest.ts를 추가한다.
import * as U from "../utils";
const hashTest = async () => {
const password = "1234";
try {
const hashed = await U.hashPasswordP(password);
console.log("hashed", hashed);
const same = await U.comparePasswordP(password, hashed);
console.log("same", same);
const same2 = await U.comparePasswordP("abcd", hashed);
console.log("same2", same2);
} catch (e) {
if (e instanceof Error) console.log("error", e.message);
}
};
hashTest();
라우터 구현하기 - 서버
routers 디렉터리에 authRouter.ts 파일을 생성해 코드를 추가하자. 회원가입 정보 검증 처리와 더불어 비밀번호 해시화를 통한 jwt 토큰을 발행한다.
import type { MongoDB } from "../mongodb";
import { Router } from "express";
import * as U from "../utils";
export const authRouter = (...args: any[]) => {
const db: MongoDB = args[0];
const user = db.collection("user");
const router = Router();
return router.post("/signUp", async (req, res) => {
const { body } = req;
try {
console.log("/signup", body);
const exists = await user.findOne({ email: body.email });
if (exists) {
res.json({ ok: false, errorMessage: "이미 가입한 회원입니다." });
} else {
const { email, password } = body;
const hashed = await U.hashPasswordP(password);
const newBody = { email, password: hashed };
const { insertedId } = await user.insertOne(newBody);
const jwt = await U.jwtSignP({ userId: insertedId });
res.json({ ok: true, body: jwt });
}
} catch (e) {
if (e instanceof Error) console.log("error", e.message);
}
});
};
기존에 testRouter를 설정한 setupRouters에 authRouter를 설정한다. 이로서 회원가입 경로는 '/auth/signup'이 되며 물론 요청은 post로 요청해야 한다.
import { Express } from "express";
import * as R from "../routers";
export const setupRouters = (app: Express, ...args: any[]): Express => {
// 3) 전달받은 router를 testRouter로 전달
return app
.use("/test", R.testRouter(...args))
.use("/auth", R.authRouter(...args));
};
회원 인증 기능 구현하기 - 클라이언트
클라이언트와 백앤드의 가장 이상적인 API 호출방식은 API서버 요청행위에 대한 오류가 없을 때 클라이언트에서도 서버에서 얻어온 결과를 반영해 주는 것이 일반적이다.
기존 백앤드를 타지 않고 구현한 signup을 확인해보면 이상적인 형태로 구현되어 있진 않다. 왜냐하면 API 서버와의 통신이 빠져있기 때문이다.
const signup = useCallback((email: string, password: string, callback?: Callback) => {
const user = {email, password}
setLoggedUser(notUsed => ({email, password}))
U.writeObjectP('user', user).finally(() => callback && callback())
// callback && callback()
}, [])
백엔드 API 서버가 있을때의 구현예시를 들어보자. 서버에서 ok 값이 true일 때 setLoggedUser, U.writeObjectP 함수를 호출하는 방식으로 변경 하였으며 서버에서 보내온 JSON 토큰이나 통신장애의 오류를 처리하기 위한 상태를 추가한다.
const [jwt, setJwt] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const signup = useCallback((email: string, password: string, callback?: Callback) => {
const user = { email, password}
post('/auth/signup', user).then(res => res.json())
.then((result: {ok: boolean, body?: string; errorMessage?: string}) => {
const {ok, body, errorMessage} = result
if (ok) {
U.writeStringP('jwt', body ?? '').finally() => {
setJwt(body ?? '')
setLoggedUser(notUsed => user)
U.writeObjectP('user', user).finally(() => callback && callback())
}) // U.writeStringP, U.writeObjectP : 로컬스토리지 사용자 정보 추가
}
})
}, [])
로그인이 정상적으로 되었을 때 localStorage에 저장된 jwt값을 읽어 컨텍스트의 jwt 상태값을 복원해 주는 것도 필요하다.
useEffect(() => {
U.readStringP('jwt').then(jwt => setJwt(jwt ?? '').catch(() => {/* 오류무시 */})
, [])
앞서 API서버를 적용시 변경부분을 종합해 AuthContext.tsx를 변경하자.
import type {FC, PropsWithChildren} from 'react'
import {createContext, useContext, useState, useCallback, useEffect} from 'react'
import * as U from '../utils'
import {post} from '../server'
export type LoggedUser = {email: string; password: string}
type Callback = () => void
type ContextType = {
loggedUser?: LoggedUser
signup: (email: string, password: string, callback?: Callback) => void
login: (email: string, password: string, callback?: Callback) => void
logout: (callback?: Callback) => void
}
export const AuthContext = createContext<ContextType>({
signup(email: string, password: string, callback?: Callback) {},
login(email: string, password: string, callback?: Callback) {},
logout(callback?: Callback) {}
})
type AuthProviderProps = {}
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({children}) => {
const [loggedUser, setLoggedUser] = useState<LoggedUser | undefined>(undefined)
const [jwt, setJwt] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const signup = useCallback((email: string, password: string, callback?: Callback) => {
const user = {email, password}
debugger
post('/auth/signup', user)
.then(res => res.json())
.then((result: {ok: boolean; body: string; errorMessage: string}) => {
const {ok, body, errorMessage} = result
if (ok) {
U.writeStringP('jwt', body ?? '').finally(() => {
setJwt(body ?? '')
setLoggedUser(notUsed => ({email, password}))
U.writeObjectP('user', user).finally(() => callback && callback())
})
} else setErrorMessage(errorMessage ?? '')
})
.catch((e: Error) => setErrorMessage(e.message))
// callback && callback()
}, [])
const login = useCallback((email: string, password: string, callback?: Callback) => {
setLoggedUser(notUsed => ({email, password}))
callback && callback()
}, [])
const logout = useCallback((callback?: Callback) => {
setLoggedUser(undefined)
callback && callback()
}, [])
useEffect(() => {
U.readStringP('jwt')
.then(jwt => setJwt(jwt ?? ''))
.catch(() => {
/* 오류무시 */
})
}, [])
const value = {
jwt,
errorMessage,
loggedUser,
signup,
login,
logout
}
return <AuthContext.Provider value={value} children={children} />
}
export const useAuth = () => {
return useContext(AuthContext)
}
로그인 기능 구현하기 - 서버
몽고DB는 ObjectId 타입의 _id속성이 있기 떄문에 문자열을 ObjectId 형태로 변환해주는 stringToObjectId 함수를 구현하자.
import { ObjectId } from "mongodb";
export const stringToObjectId = (id: string) => new ObjectId(id);
로그인 시 JSON토큰은 HTTP 요청 헤더의 Authorization 속성의 설정값으로 서버에 전송이 되며 전송 받은 값은 다양한 방어하는 코드 및 jwt를 반환하는 형태로 구현되어야 한다.
router.post(경로, (req, res) => {
const {authorization} = req.header || {}
const tmp = authorization.split(' ')
if(tmp.length !== 2) {
res.json({ok: false, errorMessage: '헤더에서 JSON 토큰을 얻을 수 없습니다'})
} else {
// 얻은 토큰을 통해 user컬렉션의 문서 _id값을 얻어 findOne으로 userId값을 얻는다.
const jwt = tmp[1]
const decoded = (await U.jwtVerifyP(jwt)) as {userId: string}
const result = await user.findOne({_id: stringToObjectId(decoded.userId)})
}
로그인 후 사용자 정보를 얻는 방법까지 확인했으며 이를 토대로 authRouter.ts에 post('/login')라우트 부분을 구현하자.
import { stringToObjectId, type MongoDB } from "../mongodb";
import { Router } from "express";
import * as U from "../utils";
export const authRouter = (...args: any[]) => {
const db: MongoDB = args[0];
const user = db.collection("user");
const router = Router();
return router
...(생략)...
.post("/login", async (req, res) => {
const { authorization } = req.headers || {};
if (!authorization) {
res.json({ ok: false, errorMessage: "JSON 토큰이 없습니다." });
return;
}
try {
const tmp = authorization.split(" ");
if (tmp.length !== 2) {
res.json({
ok: false,
errorMessage: "헤더에서 JSON 토큰을 얻을 수 없습니다.",
});
} else {
const jwt = tmp[1];
const decoded = (await U.jwtVerifyP(jwt)) as { userId: string };
const result = await user.findOne({
_id: stringToObjectId(decoded.userId),
});
if (!result) {
res.json({
ok: false,
errorMessage: "등록되지 않은 사용자 입니다.",
});
return;
}
const { email, password } = req.body;
if (email !== result.email) {
res.json({ ok: false, errorMessage: "이메일 주소가 틀립니다." });
return;
}
const same = await U.comparePasswordP(password, result.password);
if (false === same) {
res.json({ ok: false, errorMessage: "비밀번호가 틀립니다." });
return;
}
res.json({ ok: true });
}
} catch (e) {
if (e instanceof Error)
res.json({ ok: false, errorMessage: e.message });
}
});
};
로그인 기능 구현하기 - 클라이언트
클라이언트 쪽에서 HTTP요청에 JSON 토큰을 실어 보내는 방법을 알아보자. 프론트앤드에서 서버로 로그인하려면 먼저 JSON 토큰을 운반하는 post메서드를 구현해야 한다.
참고로 fetch함수의 두 번째 매개변수 부분을 분리하는 형태는 다음과 같다.
let init: RequestInit = {
method: 'POST' 혹은 'PUT',
body: JSON.stringify(data),
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin'
}
fetch(getServerUrl(path), init)
이를 토대로 JSON 웹 토큰 유무에 따라 headers부분을 변경할 수 있도록 postAndPut.ts에 코드를 추가하자.
import {getServerUrl} from './getServerUrl'
const postAndPut =
(methodName: string) =>
(path: string, data: object, jwt?: string | null | undefined) => {
let init: RequestInit = {
method: methodName,
body: JSON.stringify(data),
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin'
}
if (jwt) {
// jwt 토큰유무에 따라 헤더 값을 추가여부 설정
init = {
...init,
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`}
}
} else init = {...init, headers: {'Content-type': 'application/json'}}
return fetch(getServerUrl(path), init)
}
export const post = postAndPut('POST')
export const put = postAndPut('PUT')
AuthContext.tsx 파일의 login 함수 다시 구현하기
authRouter의 '/auth/login' 호출 시 JSON 토큰 값이 필요한데 현재는 localStorage에 담겨 있어 AuthContext.tsx의 login함수는 localStorage에서 JSON토큰을 읽는 행위로 시작되어야 하며 얻은 토큰 정보와 함께 사용자 정보를 전송한다.
이후 얻은 결과값 중 ok 속성값이 false일경우 오류 메시지를 화면에 출력하고, true일 경우 setLoggedUser 와 Callback함수를 호출해 다음 화면으로 진행 되도록 구현한다.
const login = userCallback((email: string, password: string, callback?: Callback) => {
const user = {email, password}
U.readString('jwt').then(jwt => {
return post('/auth/login', user, jwt)
}).then(res => res.json).then((result: {ok:boolean, errorMessage?:string}) => {
if(result.ok) {
setLoggedUser(notUsed => user)
callback && callback()
} else {
setErrorMessage(result.errorMessage ?? '')
}
}).catch((e: Error) => setErrorMessage(e.message ?? '')
},[])
다음은 logout 함수의 구현 내용으로 AuthProvider는 useState 훅으로 얻은 jwt값만을 사용하며 localStorage에 담긴 jwt값을 사용하지 않는다. 즉 useState훅으로 유지되는 jwt 토큰만을 초기화 해야 한다.
const logout = useCallback((callback?: Callback) => {
setJwt(notUsed => '')
setLoggedUser(undefined)
callback && callback()
},[])
앞서 본 login과 logout함수를 AuthContext.tsx에 코드를 추가한다.
import type {FC, PropsWithChildren} from 'react'
import {createContext, useContext, useState, useCallback, useEffect} from 'react'
import * as U from '../utils'
import {post} from '../server'
export type LoggedUser = {email: string; password: string}
type Callback = () => void
type ContextType = {
jwt?: string
errorMessage?: string
loggedUser?: LoggedUser
signup: (email: string, password: string, callback?: Callback) => void
login: (email: string, password: string, callback?: Callback) => void
logout: (callback?: Callback) => void
}
export const AuthContext = createContext<ContextType>({
signup(email: string, password: string, callback?: Callback) {},
login(email: string, password: string, callback?: Callback) {},
logout(callback?: Callback) {}
})
type AuthProviderProps = {}
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({children}) => {
const [loggedUser, setLoggedUser] = useState<LoggedUser | undefined>(undefined)
const [jwt, setJwt] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
...(생략)...
const login = useCallback((email: string, password: string, callback?: Callback) => {
const user = {email, password}
U.readStringP('jwt').then(jwt => {
setJwt(jwt ?? '')
return post('/auth/login', user, jwt)
.then(res => res.json())
.then((result: {ok: boolean; errorMessage?: string}) => {
if (result.ok) {
setLoggedUser(notUsed => user)
callback && callback()
} else {
setErrorMessage(result.errorMessage ?? '')
}
})
.catch((e: Error) => setErrorMessage(e.message ?? ''))
})
}, [])
const logout = useCallback((callback?: Callback) => {
setJwt(notUsed => '')
setLoggedUser(undefined)
callback && callback()
}, [])
useEffect(() => {
const deleteToken = false // localStorage의 jwt값을 초기화할 때 사용
if (deleteToken) {
U.writeStringP('jwt', '')
.then(() => {})
.catch(() => {})
} else {
U.readStringP('jwt')
.then(jwt => setJwt(jwt ?? ''))
.catch(() => {
/* 오류무시 */
})
}
}, [])
useEffect(() => {
if (errorMessage) {
alert(errorMessage)
setErrorMessage(notUsed => '')
}
}, [errorMessage])
const value = {
jwt,
errorMessage,
loggedUser,
signup,
login,
logout
}
return <AuthContext.Provider value={value} children={children} />
}
export const useAuth = () => {
return useContext(AuthContext)
}
RequireAuth 컴포넌트에 JSON 토큰 반영하기 - 클라이언트
사용자가 회원가입이나 로그인 했는지를 useAuth 훅이 반환하는 loggedUser 객체(상태) 유무로 판단했지만 현재는 JSON 웹 토큰(JWT)의 유무로 구분하는게 좀 더 정확하다. RequireAuth.tsx의 loggedUser를 jwt로 변경하자.
import type {FC, PropsWithChildren} from 'react'
import {useEffect} from 'react'
import {useNavigate} from 'react-router-dom'
import {useAuth} from '../../contexts'
type RequireAuthProps = {}
const RequireAuth: FC<PropsWithChildren<RequireAuthProps>> = ({children}) => {
const {jwt} = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!jwt) navigate('/login') // jwt 토큰이 없을 경우 login화면으로 이동
}, [jwt, navigate])
return <>{children}</> // 토큰이 있을경우 children이 element가 되게 함
}
export default RequireAuth
서버 쪽에서 JSON 토큰 기능 구현하기 - 서버
authRouter.ts에서 JSON토큰으로부터 user 컬렉션에 저장된 특정 문서의 _id 속성값을 userId란 이름으로 찾는 로직이 있다. 하지만 아래 코드는 양이 적지 않고 다른 REST API 구현에도 중복적으로 적용해야 하므로 유틸로 구성함이 좋다.
post("/login", async (req, res) => {
const { authorization } = req.headers || {};
const jwt = tmp[1];
const decoded = (await U.jwtVerifyP(jwt)) as { userId: string };
const result = await user.findOne({_id: stringToObjectId(decoded.userId),});
});
이 중복을 제거하고자 getUserIdFromJwtP 유틸리티 함수를 구현하겠다.
import type { Request } from "express";
import * as U from "../utils";
export const getUserIdFromJwtP = (req: Request) =>
new Promise<string>(async (resolve, reject) => {
const { authorization } = req.headers || {};
if (!authorization) {
reject(new Error("JSON 토큰이 없습니다."));
return;
}
try {
const tmp = authorization.split(" ");
if (tmp.length !== 2)
reject(new Error("헤더에서 JSON 토큰을 얻을 수 없습니다."));
else {
const jwt = tmp[1];
const decoded = (await U.jwtVerifyP(jwt)) as { userId: string };
resolve(decoded.userId);
}
} catch (e) {
if (e instanceof Error) reject(new Error(e.message));
}
});
다음으로 testRouter.ts에 getUserIdFromJwtP 함수를 호출하는 코드를 추가한다. 코드는 라우트 콜백 함수들의 try 문 첫 줄에 await getUserIdfromJwtP(res) 호출문을 추가한다.
즉 클라이언트에서 보내오는 JSON 웹 토큰을 얻으려면 이런 기계적인 코드를 작성하면 된며 하단은 각각의 라우터에 적용할 getUserIdFromJwtP의 예시이다.
router.get("/", async (req, res) => {
try {
const userId = await getUserIdFromJwtP(req); // 모든 라우터에 적용
} catch (e) {
if (e instanceof Error)
res.json({ ok: false, errorMessage: e.message });
}
})
클라이언트 쪽 JSON 토큰 기능 구현하기 - 클라이언트
JSON 웹 토큰(JWT)은 다음 형태로 서버로 전송된다. 이 형태로 서버에 전송하기 위해 getAndDel, postAndPut을 수정할 필요가 있어보인다.
headers: {
Authorization: `Bearer ${jwt}`
}
우선 getAndDel.ts의 코드를 수정한다. 2번째 매개변수로 jwt 토큰을 선택적으로 수신한다. 토큰이 있을 경우 RequestInit타입 init변수의 headers속성에 Authorization을 추가한다. 이 내용은 postAndPut도 동일하다.
import {getServerUrl} from './getServerUrl'
const getAndDel =
(methodName: string, jwt?: string | null | undefined) =>
(path: string, jwt?: string | null | undefined) => {
let headers = {'Content-type': 'application/json'}
let init: RequestInit = {
method: methodName
}
if (jwt) {
init = {
...init,
headers: {...headers, Authorization: `Bearer ${jwt}`}
}
} else init = {...init, headers}
return fetch(getServerUrl(path), init)
}
export const get = getAndDel('GET')
export const del = getAndDel('DELETE')
JWT기능을 각각의 get, put, post, del에 적용하면 된다 예시로 get에 대해서 확인하자.
const getAllTest = useCallback(() => {
setErrorMessage(null)
get('/test', jwt)
.then(res => res.json())
.then(data => setData(data))
.catch(error => setErrorMessage(error.message))
}, [jwt])
'FrontEnd > React' 카테고리의 다른 글
[React] 익스프레스 프레임워크로 API 서버 만들기 (0) | 2025.01.06 |
---|---|
[React] 프로그래밍으로 몽고DB 사용하기 (1) | 2025.01.03 |
[React] 공개 라우트와 비공개 라우트 구현하기 (2) | 2024.12.30 |
[React] Outlet 컴포넌트와 중첩 라우팅 (0) | 2024.12.27 |
[React] 처음 만나는 리액트 라우터 (1) | 2024.12.27 |