오늘의 명언
이 포스트의 목적
이번에 약 1달의 기간동안 나만의 명언집 프로젝트에 리액트의 메타 프레임워크인 NextJS 14.1 버전을 사용하였습니다. 이 프로젝트에 로그인 인증을 구현하기 위해서 간편한 인증 라이브러리인 next-auth( → 현재는 auth.js 로 새단장중)와 같은 도구를 사용 하지 않고, 전통적인 방식의 JWT 라이브러리를 활용해서 인증을 구현하였습니다.
이 포스트의 목적은 기존 프로젝트에 적용된 jwt 인증 방식(accessToken 기반)의 보안상 문제점을 인식하고, 이를 개선하기 위해 refreshToken 을 적용하는 리팩토링 과정에서 보이는 여러 가지 문제점들을 기록하고 개선하는 모든 과정을 정리하는 것 입니다.
따라서 내용의 흐름이 부자연스러울 수 있다는 점을 참고하면 좋을 것 같습니다.
(참고1) 해당 포스트를 살펴보는 2024년 2월 말 기준으로 아래 예시로 나온 코드들은 지속적인 리팩터링과 기능확장을 통해 변동사항이 존재할 수 있습니다. 정리 목적은 이러한 과정을 거쳐왔다는 것을 정리하기 위한 목적임으로 참고 하시면 좋을 것 같습니다.
(참고2) 포스트의 제목과는 다르게 refreshToken 생성 기능을 추가하기 앞서 일부 로그인 관련 로직에 기능 확장 및 리팩터링 부분도 같이 언급하고 있습니다. 이를 뛰어넘어 본론으로 가시고자 한다면, 데스크톱 기준 오른쪽 목차 모달을 확인하셔서 이용해주시면 좋을 것 같습니다.
(참고3) 원래 해당 포스트는 ~다, ~였음. 형식으로 작성되었고, 해당 포스트와 연계되어 있는 기능 구현 포스트와의 연계성을 위해 존중어로 변경하였습니다. 따라서 미처 수정하지 못한 부분에서 어색한 부분이 있을 수 있습니다.
코드 개선하기 ① | 사용자 로그인 정보에 대한 유효성 검사 추가
기존에는 로그인 시에는 별도의 유효성을 처리하지 않았습니다. 따라서 예기치 못한 보안상 문제를 방지하기 위해 아이디와 패스워드에 대한 유효성 검사를 추가하였습니다. 어떤 개선이 이루어졌는지는 스니펫 하단에 정리하였습니다.
// 0. 이메일로 유저 정보 찾기
const { email, password } = await req.json()
// 유효성 검사
const schema = Joi.object({
email: Joi
.string()
.email({ maxDomainSegments: 2, tlds: { allow: [`com`, `net`] } }),
password: Joi
.string()
.pattern(
new RegExp(
/^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&+=])[a-zA-Z0-9!@#$%^&+=]{8,}$/,
),
)
})
// 검증 후 처리
const validResults = schema.validate({ email, password })
if(validResults.error?.name === 'ValidationError') {
console.log(validResults.error.stack)
return NextResponse.json({success:false, meg:validResults.error.message, status:400})
}
개선점
Joi 를 이용한 유효성 검사를 추가하였습니다.
해당 검사 로직은 회원가입 시에도 동일하게 사용되고 있는데, 사용자가 잘못된 형식으로 요청을 보내면 유효성 검사는 실패하고, 이에 대한 후처리 메시지를 NextResponse 객체에 담아서 응답하도록 로직을 수정하였습니다.
이로서 악의적인 사용자가 잘못된 코드나 스크립트를 서버 측에 전달하는 보안상 문제를 방지하였습니다.
코드 개선하기 ② - 1 | Refresh 토큰 생성 로직 추가 및 추상화
기존의 accessToken 생성 로직 외에도 refreshToken 을 생성하는 로직을 추가하였습니다. 이와 관련한 개선점도 스니펫 아래에서 언급하고 있습니다.
// (이전 코드) 액세스 토큰만 발급
const createAccessToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) + 60 * 60,
data: { dbEmail, userId },
},
scrept,
)
// (개선 코드) 액세스 토큰과 리프레쉬 토큰 발급
const scrept = process.env.JWT_SCREPT || ''
export const createToken = (user: Pick<User, 'userEmail' | 'userId'>, isAccessToken: boolean) => {
const payload = {
email: user.userEmail,
sub: user.userId,
type: isAccessToken ? 'access' : 'refresh'
}
const token = jwt.sign({
exp: isAccessToken ? Math.floor(Date.now() / 1000) + (60 * 5) : Math.floor(Date.now() / 1000) + (60 * 60 * 24),
data: payload
}, scrept)
return token
}
개선점
① token 을 생성하는 로직 자체를 추상화하여 createToken 이라는 함수로 관리
기존에 accessToken 만 사용했을 때는 1시간 마다 로그인 하라는 요청만 하면 되었기에 별다른 토큰 관리가 불필요했지만(사실 필요한데 무지했기에 방치된.. ),
refreshToken 을 사용함에 따라 두 토큰을 생성하는 로직을 재사용할 가능성이 높아짐에 따라 utils/auth.ts 파일에 모듈화 하여 담아두었습니다.
② 불필요한 인자-매개변수의 타입 유형 제한 및 관련 코드 리팩터링
함수의 매개변수에 전달되는 인자에 전달되는 값을 줄이기 위해서 Pick 타입과 boolean 타입으로 지정하였습니다.
또한, 기존에 data에 직접 객체 형식으로 담아서 관리했던 페이로드를 별도의 변수로 분리하여 보다 가독성을 높였습니다. 또한 두 토큰의 발급은 동일한 로직에 의해 수행되므로 isAccessToken 에 따른 삼항 연산식을 추가하여 , 각 토큰 별로 서로 다른 만료기간이 생성되도록 로직을 구성하였습니다.
마지막으로 생성된 토큰은 return 키워드를 사용하여 함수를 호출하는 REST API 로직에서 재사용할 수 있도록 하였습니다. 이로써 중복된 코드를 추상화하여 전체적인 코드라인수를 줄임에 따라 개발 편의성을 향상 시킬 수 있었습니다.
코드 개선하기 ② - 2 | refreshToken 을 쿠키에 저장하자
일단 수정된 코드 부터 정리하고, 그 아래에 왜 이렇게 하였는지에 대한 이유를 작성해 봅니다. 참고로 쿠키에 저장하는 함수는 NextJs 의 서버 컴포넌트에서 제공하는 (next/headers) cookies 메서드를 활용하였습니다.
//(수정 전 코드)
const createAccessToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) + 60 * 60,
data: { dbEmail, userId },
},
scrept,
)
const decode = jwt.verify(createAccessToken, scrept) as jwt.JwtPayload
const { dbEmail: validEmail } = decode.data
return NextResponse.json({
success: true,
meg: '정상적으로 처리 되었습니다..',
status: 201,
email: validEmail,
profile: { image: profile_image, nickname },
accessToken: createAccessToken,
})
// -----------------------
//(수정 후 코드)
const accessToken = createToken({userEmail, userId },true)
const refreshToken = createToken({userEmail, userId },false)
cookies().set ({
name:'refreshToken', // 쿠키 이름
value:'Bearer '+ refreshToken, // 쿠키에 저장할 값
httpOnly: true, // 자바스크립트로 접근 불가능(Only HTTP 로만 전송 가능, XSS 공격 방지)
secure:true, // 오직 안전한 연결인 HTTPS 에서만 사용가능(로컬 환경에서는 http 허용 해줌)
path:'/', // 쿠키에 접근할 수 있는 사이트 경로
})
return NextResponse.json({
success: true,
meg: '정상적으로 처리 되었습니다..',
status: 201,
email: userEmail,
profile: { image: profile_image, nickname:nickname || '익명의 명인' },
accessToken,
})
개선점
앞서 ② - 1 에서 생성한 토큰을 생성하는 함수인 createToken() 을 호출하여 accessToken 과 refreshToken 을 반환(return) 받아 accessToken 은 json 객체에 담아서 클라이언트로 보내고, refreshToken 은 몇 몇 보안 옵션을 적용하여 쿠키에 저장 하였습니다.
httponly 옵션을 적용하여 클라이언트단에서 쿠키에 접근하기 위해 자바스크립트를 사용하는 방식을 차단하였고,
HTTP 통신을 통해서 자동으로 서버 측에 refresh 토큰이 전달되기 때문에 별도의 추가 로직 없이 어디서든 재사용할 수 있도록 하였습니다.
이러한 장점은 XSS 를 사전에 차단할 뿐만 아니라 refresh 를 재검증하고 accessToken 를 발급하는 일련의 과정을 단순화 해주기 때문에, 여러 이점이 있는 개선이라 생각 됩니다.
Q. 왜 토큰을 쿠키에 저장해야 했나?
쿠키 말고도 원래 두 가지 정도의 방법이 있었지만, 아래 이유로 그 둘을 배제하였습니다.
(배제이유) 로컬 스토로지 저장의 문제점
로컬 저장소에 저장하는 경우에는 refreshToken 도 XSS에서 안전하지 못하다고 합니다. 애초에 ccessToken 을 프록시 처럼 앞에 두고, refreshToken 을 프록시 뒷 단에 두는 느낌으로 쓰고 있는데, 같이 로컬 스토로지에 저장하는 것은 사이좋게 탈취 당하자는 의미인가 싶어서 굳이? 라는 생각이 들었습니다.
(배제이유) 데이터베이스 저장의 문제점
반면, 데이터베이스에 저장하는 경우에는 애초에 토큰을 활용하는 것 자체가 데이터베이스 접근으로 인한 오버헤드가 심해질 수 있기 때문에, 아주아주 돈이 썩어 나지 않는 이상은 그런 리소스 낭비는 피하는게 좋지 않을까 싶어서 배제하였습니다.
쿠키 선택 이유
마지막으로, 쿠키를 이용하는 경우 httpOnly 라든지, sameSite 와 같은 보안이 적용된 처리를 적용할 수 있을 뿐만 아니라, 무엇보다 서버와 클라이언트 통신에 자동으로 전달된다는 점이 이점이라 생각했습니다. 또한 지정한 도메인 내에서만 쿠키가 설정되도록 하는 등의 여러 보안적인 처리가 계속해서 보완되고 있습니다.
특히, httpOnly 옵션을 활성화하는 경우 자바스크립트 코드로는 클라이언트 상의 쿠키에 접근할 수 없게 되고, 오직 HTTP 통신으로만 쿠키를 주고 받을 수 있기 때문에 XSS 취약성을 어느 정도 보완 할 수 있습니다.
그리고 향후 HTTS 와 같이 사용된다면 네트워크 통신을 도청하는 문제 까지 같이 해결할 수 있으니 쿠키가 여러의미로 효율성과 보안성, 안정성 등의 면에서 좋은 방법이라 생각되었습니다.
고로 여러 이점들을 고려했을 때, refreshToken 을 쿠키로 저장하는 것을 마다할 이유가 없었습니다.
물론 쿠키도 완전히 안전하지는 못하다. 특히 클라이언트에 노출된 쿠키는 서버로 전달될 때 악의적인 해커가 쉽게 탈취할 수 있다. 그래서 사용자의 민감한 정보의 경우에는 절대 쿠키에 포함시켜서는 안 된다. 그리고 탈취를 최대한 막기 위해서는 https 를 사용하여 네트워크 통신이 외부에 노출되지 않도록 해야 한다는 점을 고려해야 한다. |
코드 개선하기 ③ | accessToken 이 만료 되면 refreshToken으로 재발급 받도록 로직 수정하기
기존 방식은 클라이언트 측에서 서버로 accessToken 을 보내기만 하였습니다. refreshToken 을 적용하고 나서는 accessToken 을 재발급 하는 로직이 추가된 것이 주요 개선점이라 볼 수 있습니다.
/**
* GET | 데이터베이스에서 유저 정보 불어오기
* @param url 경로
* @param token accessToken
* @returns 유저정보를 반환. 이 반환 정보를 바탕으로 getUserQuotesFromDb 를 호출
*/
export const getUserInfoFromDb = async (url: string, token: string) => {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
},
})
if(!response.ok) throw new Error('데이터 조회에 실패하였습니다.')
const result = await response.json()
const { status, meg } = result
// accessToken 재발급 요청 후 재귀호출 시도
if (status === 401) {
const newToken = await requestNewAccessToken()
if (newToken) {
localStorage.setItem('token', newToken)
await getUserInfoFromDb(url, newToken)
}
}
// 정상처리 후 데이터 반환
if (status === 200) {
const { items: userInfo } = result
return userInfo
}
// 그 외 사용자 안내 메시지 알림
alert(meg)
} catch (error) {
console.error('에러 발생:', error)
}
}
전체 코드를 제외하고 추가된 주요 코드만 살펴보면 아래와 같습니다.
클라이언트 측 개선점
기존에는 accessToken 을 헤더에 담아서 서버에 보내는 로직이 전부였으나, refreshToken 이 추가되고 나서, accessToken 재발급을 요청하는 로직을 분리하여 다음과 같이 requestNewAccessToken() 함수로 모듈화하였습니다.
만일 사용자가 서비스를 이용중에 accessToken 이 만료된다면, 서버에서는 해당 토큰이 만료되어 접근 권한이 없다는 의미로 401 상태를 반환하고, 해당 상태를 응답받은 클라이언트에서는 refreshToken 을 기반으로 accessToken 을 재발급하도록 로직이 추가 되었습니다.
/**
* POST | 새로운 accessToken 발급
*/
export const requestNewAccessToken = async () => {
const config = { method: 'POST' }
try {
const respone = await fetch('/api/auth/access', config)
if (!respone.ok) throw new Error('토큰 발급 요청이 실패하였습니다.')
const { status, accessToken } = await respone.json();
if (status === 201) return accessToken
} catch (error) {
console.error('에러 발생: ', error)
}
}
서버 측 개선점
그리고 서버 측 apiRoute 에서는 api/auth/refresh 와 api/auth/access 경로를 나눠 전자는 refreshToken 재발급 요청을 처리하고, 후자는 accessToken 요청을 처리하도록 경로를 분리하였다.
이에 대한 코드는 다음과 같이 정리해보았습니다.
// api/auth/access/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createToken, tokenVerify } from "@/utils/auth";
export async function POST(req: NextRequest) {
try {
// refresh 토큰 검증(쿠키에 저장되어 있으므로 verify 함수 내부에서 처리한다.)
const { status, meg, success, user } = tokenVerify(req, false)
const {email:userEmail, sub:userId} = user
if (status === 400) {
return NextResponse.json({ status, success, meg })
}
if (status === 401) {
return NextResponse.json({ status, success, meg })
}
// 새 토큰 생성(refreshToken 검증 후 디코딩된 jwt 에서 반환받은 유저 정보를 바탕으로 accessToken을 생성한다.)
const newAccessToken = createToken({userEmail,userId}, true)
return NextResponse.json({ meg: '새로운 토큰이 발급되었습니다.', accessToken: newAccessToken, status: 201, success: true })
} catch (error) {
console.error('/api/auth/access', error)
return NextResponse.json({ meg: '토큰 발급에 실패하였습니다.', status: 500, success: false })
}
}
③ 번 부분에서 개선된 로직 요약
ⓐ 만일 사용자가 사용하는 토큰이 만료된다면 클라이언트에서는 401 상태를 응답 받고 새로운 토큰 요청을 api/auth/access 경로로 요청합니다.
ⓑ 이 때 서버 측에서는 쿠키에 저장된 refreshToken 을 기반으로 jwt.vertify 검증을 시도하고, refreshToken 이 만료되지 않았다면, 디코드 후 페이로드에 담긴 유저 정보를 반환합니다.
ⓒ 반환 받은 페이로드는 accessToken 을 재발급 하는데 사용되며, 앞서 login 요청 시 사용했던 createToken 함수에 유저정보를 전달하여 accessToken 을 생성 후 반환합니다.
ⓓ 반환된 accessToken 은 json 객체에 담아서 클라이언트로 보내주고, 클라이언트 측에서는 해당 토큰을 다시 로컬 저장소에 저장합니다.
그 다음 개선 사항에 대한 고민과 최근 까지 개선 점
(개선점) 기존 리팩터링에 더해 accessToken 과 refreshToken 의 경우 로직을 더욱 단순화하여 다음과 같이 로직이 개선되었습니다.
'프로젝트 > 나만의명언집' 카테고리의 다른 글
[나만의 명언집 프로젝트] 테스트 코드 적용 정리본(일부) (4) | 2024.03.05 |
---|---|
[나만의 명언집 프로젝트] 기능 구현 정리본② (0) | 2024.03.04 |
[나만의 명언집 만들기 프로젝트] 기능 구현 모음집 ① (0) | 2024.03.04 |
[나만의 명언집 프로젝트] 트러블 슈팅 모음집 ② | 7 ~ 13 (0) | 2024.03.02 |
[나만의명언집 프로젝트] 트리블 슈팅 모음집 ① | 1 ~ 6 (1) | 2024.02.24 |