기초적인 기능에서 한발 더 나아간다면,
현재 서비스를 요청하고 있는 클라이언트가 누구인지 식별해야할 필요성은
아마 말을 하지 않아도 누구나 인정한다.
이는 Auth(authentication) 이미 많은 갈래의 개념으로 나눠지고,
많은 종류의 라이브러리로도 구현되어있다.
그중 대표적인 개념에 대해 정리해보기로 했다.
HTTP 프로토콜은 몇가지 특징을 가지고있다.
connectionless: 서버가 클라이언트 요청에 응답을 완료하면 연결을 끊어버린다.
stateless: 연결을 끊은 뒤에는 클라이언트의 상태를 저장하지 않는다.
해당 특징들은 서버자원을 효율적으로 관리하기 위한 특징인데,
만일 클라이언트 상태를 메모리에 저장하는데 연결을 계속 유지하고 있으면 서버 자원을 계속 소모하게 되기 때문이다.
그런데 여기에서의 문제는 상태를 저장하지 않는데 어떻게 사용자가 누구인지, 로그인 인증이 끝나고 또 어떻게 일정시간동안 인증상태를 유지한단 말인가?
그래서 나온 개념이 바로 쿠키/세션 JWT이다.
쿠키는 간단하게 말해 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다.
브라우저가 서버에 요청을 보내면(Request to Server),
서버는 헤더에 쿠키를 담아 클라이언트로 응답을 보내준다(Response to Client/browser)
쿠키에는 여러가지 정보를 담을 수 있는데,
아래와 같은 특징이 존재한다.
따라서 쿠키가 존재한다면 그 쿠키 안에 들어있는 설정들로 빠르게 서버에서 데이터를 해당 쿠키에 맞게
변환하여 보내줄 수 있어 매우 유용하다.
그렇기에 쿠키를 사용한다면 구현이 편리한 Auth서비스가 많지만, 그전에 세션부분이 무엇인지 잠시 살펴보자
세션은 어떤형식으로 이루어지는지 간단하게 알아보자.
매콤 : 안녕하세요 서버님 저는 김매콤이에오 저는 이런 아이디랑 이런 비밀번호에요
서버: ㅇㅇ? 잠만 기다려보셈... ㅇㅋ 맞는거같다 세션 DB에 김매콤 등록할께
서버: 등록 끝났어, 근데 나 기억력이 좀 안좋아서 여기 니 전용 쿠키 줄테니깐 내 안에있는 다른곳 갈때마다 이거 좀 멕여줘서 니가 누군지만 좀 알려주셈
매콤: 감사해오
서버는 똑똑하지만 멍청하고 배고파한다.
그렇기에 위에서 설명한것처럼, 브라우저에 해당 서버의 쿠키 정보가 있을 시엔
HTTP요청에 쿠키를 실어서 보낸다
그렇기에 우리의 멋진 김매콤이 가지고있는 세션ID는 쿠키에 저장되어,
매일 배고파하는 서버에게 '세션ID있는 쿠키드세요~' 라고 말하면
서버는 '쿠키를 지닌 클라이언트의 요청'을 보고 냠냠냠냠 쿠키를 먹고
쿠키 안에 들어간 세션 ID로 조회하여 요청자가 누구인지 알게된다.
한마디로 쿠키/세션을 정의하자면
쿠키란 서버에게 자신이 누구인지 전달해주는 일종의 '전령'과 같은 존재라고 생각하면 된다.
즉 유저정보는 모두 서버에 존재하고, 전령은 자신이 누구인지 알려주는 역활이다
조금만 생각을 해보자.
그렇다면 모든 유저에 대한 세션아이디를 저장해야하는데...
만약 이용자가 많아질수록, 서버에 쿠키를 주고, 서버는 그 쿠키들을 보고 아 이친구는 이친구고 저친구는 저친구구나 하는 요청이 많아지게 된다.
세션아이디를 저장해야하는 디비도 필요할것이고...
갑자기 한번에 많은 요청이 들어올경우 서버 자체가 과부화되서 터져버리는 일도 발생할 수 있다.
이를 완화하고자, 유저 정보가 서버에 저장될 필요가 없는 Token기반 인증 방식이 등장하게 된다.
토큰은 필드가 추가될수록 토큰의 크기 역시 우람하게 커진다.
대신 쿠키에 비해 많은 양을 전달할 수있다.
여하튼 만일 토큰방식으로 로그인을 하게 된다면
Token = jwt.sign({ userId: user.userId }, 'Mcc-Key');
이런 형식으로 해당 정보를 조회하고, 'sign'을 통해 마치 계약서에 싸인한것처럼 토큰이 유효함을 알려준다.
const { userId } = jwt.verify(tokenValue, 'Mcc-Key');
그리고 만일 사용자를 조회할 필요가 있을경우, 해당 토큰값이 유효한지 'verify'작업을 하고, 유효할 경우 서비스를 요청을 허락하게된다.
이런식으로 데이터베이스에 따로 세션아이디를 주지 않고, 사용자임을 알리는 토큰이란 계약서를 보여주고, 해당 계약서가 유효함이 확인 될 경우, 해당 서비스를 제공한다.
그건 구현하고자 하는 서비스의 우선순위에 따라 틀려질것같다.
토큰은 상대적으로 사이즈가 크고, 웹브라우저 혹은 토큰에 정보가 저장되어 민감한 정보는 절대 담아서는 안되고,
세션의 경우엔 과부화를 방지하기 위해선 거대한 서비스일 수록 많은 분산서버가 필요하고, 확장성 면에서도 곤란한 부분이 존재하지만, 좀더 유연하게 사용자의 인증을 조정할수 있는 장점이 있다.
그렇다면 이번 프로젝트에선 어떻게 JWT를 통해 Auth를 구현할 수 있을까?
우선 간단하게 api부터 작성해보자.
router.post('/auth' , async(req, res) => {
const {nickname, password} = req.body;
let passCheck = false;
const user = await User.findOne({nickname}).exec();
사용자가 입력한 값을 서버로 보내주기위해 type은 post형식으로 값을 보내주고,
구조분해 할당을 통해 해당 값들을 가져온다.
그리고 입력받은 아이디로 특정 문서를 조회하여 가져온다.
bcrypt.compare(password, user['password'], function(err, msg){
if(msg === true){
const token = jwt.sign({ userId: user.userId }, 'Mcc-Key');
res.send({
token,
})
}
else{
res.status(400).send({
errorMessage:'이메일 또는 패스워드가 잘못되었습니다.'
})
return;
}
})
그리고 나는 비밀번호를 미리 해시처리 했기 때문에, 입력받은 비밀번호와 해시값을 비교하여 맞는 값일 경우에만
const token = jwt.sign({ userId: user.userId }, 'Mcc-Key');
jwt에 내 시크릿 키인 mcc-key로 싸인을 해준다.
블로그에 포스팅 했으니 나중에 키를 좀 변환해야겠다
자 그렇다면 비로써 token을 발급받고, 해당 값에 싸인도 해주었다.
그러면 만일 이 토큰값으로 값을 가져오기 위해선 어떻게 해야할까?
화면에 게시글을 보여주는 index.js
전역변수인 user에 유저 정보를 담아주기위해 선언한다.
물론 유저정보는 중간에 변해도 페이지가 로드됨에 따라 새로 불러오기 때문에
const로 선언하는것이 옳지만 일단 편의상 let으로 정의한다.
get_list(), getSelf(callback)
get_list함수는 화면에 게시글을 그려주는 함수,
getSelf함수는 토큰값을 사용하여 유저의 정보를 불러오는 역활을 하는 함수이다.
여기서 주의깊게 봐야할것은 바로 getSelf 함수이다.
getSelf
getself함수에서 벌어지는 일을 보자.
getself함수에서는 ajax로 서버에 /users/chkLogin이라는 api를 요청한다.
그리고 제공하는 데이터는 쿠키에 담긴 토큰값을
'authorizaion: Bearer : 토큰' 값으로 넘겨준다.
해당 api는 authMiddleware라는 미들웨어로 넘어가게 되는데,
구현한 미들웨어에는 사용자가 요청한 request에 무언가 좀더 가공을 시키거나, 인가받지 않은 요청을 걸러내는 요청을 한다.
미들웨어는 express의 큰 장점 중 하나다.
특정한 함수나, 함수들의 모임을 미들웨어로 export한 후, 그곳에서 요청을 검증하거나 다시 원하는 형태로 가공하여 보낼 수 있기 때문이다.
즉 이곳에서는, chkLogin으로 보낸 'authorizaion: Bearer : 토큰'값을 authMiddleware에서 검증한다.
AuthMiddleware
authMiddleware에서 하는 역활을 간단하게 보자
만약 사용자가 어찌어찌해서 로그인이 필요한 특정 사이트의 주소를 알아낼 경우
로그인을 하지 않고 해당 주소로 바로 넘어갈수 있다.
미들웨어에서 접근을 막아주는 모습
만일 토큰값이 존재하지않을경우 에러 메세지와 함께 다시 원래 위치로 돌려버린다.
그치만, 완벽하게 로그인되어 토큰값이 존재할경우?
const { userId } = jwt.verify(tokenValue, 'Mcc-Key');
User.findById(userId).exec().then((user) => {
res.locals.user = user;
next();
토큰이 존재하기에, jwt.verify로 토큰값을 시크릿키인 mcc-key로 변환한 후, 해당 정보에 존재하는 몽고디비의 ObjectId값으로 조회한다.
UserSchema.virtual("userId").get(function () {
return this._id.toHexString();
});
UserSchema.set("toJSON", {
virtuals: true,
});
ObjectId값을 userId라는 이름으로 선언하고, 해당값을 String 형태로 반환해 주게 한다.
따라서 해당 ObjectId값이 존재한다는것은, 해당 유저가 존재하게 되고, 그렇다면 그 값은
promise형태인 findById의 resolve에 'user'라는 이름으로 값을 전달한다 정의한다.
그리고 해당 데이터는 매우 민감할 수 있는 데이터이다.
또한 middleware인 'authMiddleWare'를 통해 넘어온 값이다.
따라서 클라이언트에서 넘어온 파라미터가 아니라 애플리케이션 레벨 미들웨어에서 설정한 값이기에 ejs 파일에서 사용하기위해
'res.locals.변수명 = 값'
을 통해 res.locals에 user라는 변수를 만들어 우리가 위에서 불러온 유저정보(user)을 담아주자.
즉 res.locals.user = user
라는 뜻이다.
자 그렇다면 정보도 다 담아 주었으니 'next()' 함수를 통해 다음 미들웨어. 즉 이 미들웨어를 불러온 미들웨어인
router.get('/users/chkLogin'
를 다시 실행할수 있게 된다.
/users/chkLogin
chkLogin의 미들웨어를 통해 나온
res.locals.user = user에서
구조분해 할당을 통해 user값을 user라 다시 정의 한후 해당 값에서 민감한 정보를 제외한. 유저ObjectId와 nickname만을 ejs파일로 전달해주면?
완벽하고 또 우아하게 인증이 끝난다.
jwt인증은 복잡하면서도 쉽다.
왜 Authorization : Bearer 형태로 토큰값을 날리는지,
어떻게 날리는지를 적는다면 이 글이 터져나갈것같기에 그것은 아마 내일 정리하기로하고
jwt를 간단하고 쉽게 구현해볼수 있어 너무 아름다웠다.
늘 해오는것처럼, 꾸준하게 하기만 하면 모든것은 다 굴러간다.
어렵다고, 힘들다고 느껴질때마다 내가 지금까지 해온 공부 시간을 보며 마음을 굳게 먹는다
아 맞다 오늘 지각했지