들어가며
프렌딩에서 프로필 교환 관련 개발을 하고있었다.
여기서 프로필 교환이란, 나와 상대의 프로필 카드를 교환하는 것이다. 마치 명함을 교환하는 것처럼 말이다.
그런데 여기서 약간의 문제가 생겼다.
앱인 경우에는 프로필 공유를 하게되면, 해당 프로필을 가진 사람의 기기에 푸시알림을 보내서 친구추가를 하는 형태였지만, 앱을 설치하지 않은 사람이 설치한 사람의 프로필 링크를 받거나, QR을 찍는 경우 어떻게 할지가 고민했다.
결론은 앱을 설치하지 않은 사용자의 경우 웹을 띄워서 어떤 프로필인지 볼 수 는 있게 해주자 였는데, 이걸 어떻게 안전하게 구현할 수 있을지가 고민이었다.
그냥 프로필 id로 보여주면 되는거 아님?
웹링크는 랜딩 사이트를 이미 개발했고, 배포해두었기때문에, 랜딩 사이트의 url을 사용해서 웹링크 프로필을 보여줄 수 있었다.
그래서 초기에는 그냥 $baseURL/profile/1 의 형태로 그냥 보여주면 어떨까 고민했었다.
하지만, 이러면 profile ID가 노출되고, 해당 프로필을 통해 누구나, 언제나 다른 사람의 프로필에 접근할 수 있는 문제가 있다. 개인정보가 전혀 보호되지 못하는 것이다.
JWT도입
JWT를 사용한 이유
이런 문제를 해결하기 위해 JWT를 활용해보기로 했다.
도입하기로 한 이유는 누구나, 언제나 접근할 수 있다는 두 가지 문제를 JWT가 모두 해결할 수 있기때문이다.
- JWT는 비밀키로 서명을 하기때문에, profile ID가 노출된다 해도, 아무나 프로필을 요청할 수 없다.
- JWT에는 만료기한을 설정할 수 있어,토큰을 갖고있다 한들 언제나 프로필에 접근할 수 없다.
오로지 유효한 시간에, 링크를 받은 사람만 프로필을 볼 수 있게 되는 것이다.
처음 생각한 로직
처음에는 Flutter에서 JWT를 발행하고, 웹인 Nextjs환경에서 JWT에 대한 유효성을 검증하는 형태로 구현하려고 했다.
그림의 로직을 순서대로 작성하면
- 플러터에서 NextJS와 공유된 대칭키를 사용해서 공유할 profileID를 payload로 하여 JWT를 발행하고, QR/NFC로 baseURL/profile?token=발행한 JWT형태로 웹 링크를 전송
- 이를 받은 사용자는 그 링크에 접속하고, NextJS에서 JWT를 query string으로 받음
- NextJS에서 자체적으로 공유된 대칭키를 사용해 유효성을 검증한 후에, JWT 내부정보인 profileId를 통해 프로필을 요청함
- 서버가 프로필을 응답합
- 사용자는 프로필을 보게됨
이런식으로 흘러가게된다. 이러면 profileID가 노출되어도 아무나 프로필을 볼수는 없다.
또한 JWT만료 시간을 15분으로 하여 만료되는 경우, 더이상 그 링크로 프로필을 조회할 수 없게된다.(4번 과정에서 걸리게 되는것이다.)
하지만 이 방법에는 문제점이 있었다.
문제점
JWT에서 가장 핵심적인 부분은 대칭키이다.
대칭키가 있으면 JWT 유효성 검증, JWT 발행이 가능해진다.
이 부분에서 문제가 발생할 수 있을 것이라 판단했다.
웹 기술스택으로 Nextjs를 사용하긴 하나, 해당 링크는 CSR형태로 구현될 것이라 env로 JWT 대칭키를 등록한다한들, 브라우저에서 노출된다.
한번 노출되는 순간 곧, 정보 유출로 이어질 수 있기때문에 이 방법을 사용할 수 없었다.
부가적으로, 플러터와 Nextjs사이의 대칭키를 변경하는 경우, 싱크를 계속 맞춰줘야 하는 문제도 있었다.
서버를 이용하기
그래서 서버를 사용하기로 했다.
JWT발행과 JWT유효성 검증을 모두 서버에게 맡기는 것이다.
위 로직을 글로 순서대로 정리하면 아래와 같다.
- 프로필 공유를 눌러 웹에서 내프로필을 열 수 있는 링크를 express서버에 요청함
- 서버는 프로필 링크용 JWT를 발행하고, url응답으로 플러터에 넘겨줌
- 플러터는 QR/NFC를 통해 해당 URL을 사용자에게 넘겨줌
- 사용자는 웹링크를 엶
- 이때 NextJS에서 POST요청을 통해 프로필을 요청하는데, 이때 body에 token이 담김
- express 서버는 이를 받아 유효성 검증을 하고, 유효하다면 프로필정보를 응답으로 줌
- 이러면 사용자는 JWT가 유효하면 프로필를 보게되고, 아니라면 만료창을 보게됨
이러면 브라우저에서 대칭키가 노출될 일이 없고, 대칭키를 변경할때에도 express서버에서 한번만 변경해주면된다.
코드레벨로 살펴보기
나는 플러터를 개발하지 않고, express와 next만 개발하여 해당 코드만 있다.
주요 로직에서 사용한 코드만 소개하자면
Express
JWT Service
발행과 검증을 처리했다.
export class JWTService {
static async issueLinkToken(profileId: number) {
const token = jwt.sign({ id: profileId }, process.env.JWT_SECRET, {
expiresIn: '5m',
});
return token;
}
static async verifyLinkToken(token: string) {
let data;
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
throw new ErrorStatus('링크가 만료되었습니다', 401);
}
data = decoded;
});
return data as DecodedJWT;
}
}
WebLink Controller
export class WebLinkController {
static async get(req: Request, res: Response) {
const webLinkDTO = await webLinkValidation(req);
const linkToken = await WebLinkService.createLink(webLinkDTO);
res.json(`http://baseURL/profile?token=${linkToken}`);
}
static async post(req: Request, res: Response) {
const token = req.body.token;
const { id } = await JWTService.verifyLinkToken(token);
const result = await ProfileDAO.getProfile(id);
res.json(result);
}
}
여기서 get메서드는 웹링크 요청에 대해서 profile조회 링크를 만들어서 응답하고, post메서드는 발행한 JWT를 검증하고, 실제 프로필 정보를 반환한다.
참고로 오류처리는 상위레벨에서 errorHandler를 통해 통합적으로 처리하게 해두어서 해당 코드에는 그 부분이 없다.
NextJS
프로필 요청 로직
export default function Profile() {
const [isLoading, setLoading] = useState(true);
const [isError, setError] = useState(false);
const [data, setData] = useState<ProfileCard>();
...
useEffect(() => {
axios
.post(`/api/webLink`, { //서버에 weblink post요청
token: window.location.search.split(/\?token\=/)[1], //url을 파싱해서 token을 서버로 전송
})
.then((res) => {
if (res.data.name != 'AxiosError') {
setData(res.data);
setLoading(false);
} else {
setLoading(false);
setError(true);
}
});
}, []);
...
}
결론
JWT를 활용해서
- 시간제한이 있으며
- 아무나 볼 수 없고 (요청 정보 조작이 불가능함)
- 안전한(대칭키 유출 가능성이 없으며, 대칭키 변경이 용이함)
프로필 공유 기능을 구현해 보았다.