passport 로컬전략으로 인증하는 흐름에 대해 정리해보려고 한다.
post 요청으로 받아온 email이나 pwd 등의 유저 데이터를 express의 라우터에서 받는다.
이 라우터의 콜백에서 인증을 한다고 해보자.
Authenticate flow는 순서대로 이렇게 정리된다. request.body 안에 들어온 data들에 인증을 실행하면(인증의 실행) 로컬전략을 통과하여 가입된 사용자가 맞는지 검증된다.(로컬전략의 실행) serialize를 거쳐 최종적으로 세션id 쿠키가 만들어져 response 헤더에 담겨 보내진다.(serializeUser의 실행) 이후에는 브라우저가 이 세션id 쿠키를 저장했다가 요청시에 쿠키에 담아 보내기 때문에 서버에서 사용자임를 인식한 결과를 보내게 된다.(deserializeUser의 실행)
이 과정을 차근차근 살펴보자.
passport.authenticate('local', (err, user, info) => {콜백 return req.login()})
첫번 째 인자로 전략의 이름을 넣는다. local을 넣으면 로컬 전략 모듈을 실행한다. 로컬전략의 실행이 실패하면 콜백의 err 인자로 에러 객체가 들어오고 user는 false로 값이 들어오게 된다. info는 additional detail으로 실패이유가 들어온다. 이 콜백은 로컬전략을 통과한 user 객체를 얻게 해준다는 데 의의가 있다.
주의할 점은 콜백 안에 return으로 req.login()을 반드시 실행해야 한다는 사실이다. 공식문서 상에서는 이를 통해 세션을 만들어주어야 한다고 한다.
Note that when using a custom callback, it becomes the application's responsibility to establish a session (by calling req.login()) and send a response.
passport.use(new LocalStrategy({}, (email, password, done) => { verification을 해준다. })
로컬전략은 사용자가 입력한 id와 pwd가 맞는지 검증하는 게 주 목적이다. db에 저장된 user의 정보와 입력받은 데이터가 같은지 비교하게 되는데 이 전략은 개발자가 스스로 작성하게 된다. 패스포트 로컬전략은 검증 결과를 세션에 저장하고 쿠키를 주고받을 수 있도록 자동화된 틀을 제공 해줄 뿐이다.
passport.authenticate()는 local.js에 짜놓은 모듈로 req.body.username과 password를 보내준다. 이 두 값은 각각 username과 password라는 변수로 사용되게끔 디폴트로 설정되어 있다. 아래는 이 변수명을 email로 변경한 것이다. 굳이 username을 email로 변경할 필요가 없다면 해당 인자를 주지 않아도 된다.
사용자가 입력한 email과 password를 갖고 이제 verification을 하면 된다. email은 주로 db에서 직접 조회를 통해 db에 저장된 사용자인지 검증하고 password는 저마다의 비밀번호 암호화 모듈을 사용하여 검증한다.
만약 user가 없다면 done()으로 authenticate callback에 인자로 전달할 값을 적어 준다.
done의 인자는 순서대로 authenticate의 콜백에 매칭된다.
done => authenticate callback
null => error
false => user
{reason : "존재하지 않는 사용자입니다"} => info
db 조회 결과 user가 없다면, done의 인자로 error는 null을 보내준다. user가 없는 건 어플리케이션 오류가 아니기 때문이다. 대신 user에 false를 보냄으로써 없는 사용자라는 점을 알려준다. info는 user가 없는 상황에 대한 메세지를 준다. 이는 아래와 같이 표현할 수 있다. 이는 공식문서에 나와있는 패턴을 그대로 따라한 것 이다.(*대신 내 경우 sequelize를 사용하고 있다)
password의 경우 저마다가 사용하고 있는 암호화 모듈을 통해 검증하게 된다. 주로 bcrypt나 crypto 같은 암호화 모듈을 사용하는 듯 하다. 핵심은 내가 password 변수로 받은 비밀번호가 db에 저장된 사용자의 비밀번호와 맞는지 검증하는 것이다. 이를 위해 아까 db에서 꺼내온 user 객체를 사용하게 된다. db에 저장할 때 이미 암호화되어 들어가기 때문에 이를 풀기 위해 bcrypt.compare() 메소드를 사용한다. 이를 통해 입력된 그대로의 password와 암호화되어 저장된 db의 password가 같은지 검증할 수 있다.
검증이 완료되었다면 done(null, user)로 authenticate 콜백에 검증된 user 객체를 전달한다.(아까 db에서 꺼내온) 위에서 try 안에 검증이 실패할 경우 처리를 위한 2개의 if문을 만들었다. 따라서 if문에 걸리지 않으면 done(null, user)가 실행되고 만약 db에서 조회하는 데 오류가 있게되면 어플리케이션 오류이므로 catch에서 처리해준다.
serializeUser의 역할은 user 정보를 세션저장소(not db, but memory)에 저장하고 이에 대응하는 암호화된 세션 id를 response 헤더의 쿠키에 담는 것이다. 이를 세션을 만든다고 한다. 즉, user를 인식할 수 있는 값을 암호화해서 유저에게 전달하는 역할이다.
로컬전략 인증에 성공하고나면 done(null, user)가 라우터에 있는 authenticate('local', (err, user, info) => { 콜백함수의 내용 })로 user 객체를 전달한다. 이제는 user객체를 내가 원하는 형태로 다듬어서 res.send(user)하면 프론트로 전달된다(추가 로직을 작성할 수도 있고).
하지만 이 때, 패스포트가 할 일이 남아있다. res.send(user)로 응답할 reseponse 헤더에 user 객체를 인증할 세션과 쿠키를 만들어 넣는 일이다. 이는 authenticate callback 안에 아래의 명령어를 실행하므로써 진행된다.
req.login(user, (loginErr) => { ~~ return res.send(user) })
req.login()에 인자로 로컬전략으로 검증된 user객체를 넣어서 passport/index.js 모듈로 보내준다. 그러면 아래의 함수에 user 인자로 받는다.
passport.serializeUser( (user, done) => { done(null, user.id })
done을 하게되면, 이후 세션을 만드는 순서는 다음과 같을 것이다.(이해를 돕기위한 코드이지 실제로 코드가 이렇진 않다)
- 유저 id의 실제 값과 세션id의 값을 대응시켜 세션저장소에 저장한다.
{ user.id : sessionID } -> ex) { 1 : ahlfdk3sd903 }
- 세션id의 실제값과 해쉬값을 다시 대응시킨다.
{ sessionID : hashed_sessionID } -> ex { ahlfdk3sd903 : 328yaskjdfaiugasoguhp8we}
- response 헤더에 세션id의 해쉬값을 담은 쿠키를 넣어준다.
{ sid : 328yaskjdfaiugasoguhp8we }
즉, 처음의 user객체 전체를 저장하기에는 무겁기 때문에 user가 고유하게 갖는 id만 저장한다. 그리고 최종적으로 hashed_sessionID가 되어 유저에게 전달된다. 그러면 로그인 요청에 대해 응답하는 res객체에 위 쿠키가 전달되어 유저가 갖게된다. 이 모양의 쿠키는 브라우저의 network 탭에서 바로 확인 가능하다.
여기까지가 초기 로그인 요청에 대한 패스포트의 인증흐름이 된다.
그렇다면 한번 로그인 이후, 창을 새로고침하거나, 브라우저 창을 닫거나, 컴퓨터를 끄고난 뒤에도 로그인한 상태를 어떻게 유지하는 걸까?
여기서 시리얼라이즈와 디시리얼라이즈의 역할을 이해할 필요가 생긴다. 이들은 각각 user 객체에 대응하는 암호를 만들고, 다시 암호를 user 객체로 풀어주는 역할을 한다.
즉, user => hashed_sessionID, hashed_sessionID => user 인 셈이다.
결론적으로 초기 로그인 당시에 받았던 hashed_sessionID를 쿠키로서 갖고 있다가 서버에 요청할때마다 해당 hashed_sessionID 전달하고 이를 deserializeUser가 user 객체로 변경해줌으로써 서버는 요청을 보낸 유저를 구분하고 인식하며 로그인된 상태의 결과를 보내줄 수 있게 된다. 이를 위해 브라우저는 localstorage에 이 쿠키의 값을 만료전까지 계속 저장한다.
서버는 req의 헤더에 있는 세션 쿠키값을 매번 확인한다. 그리고 세션 쿠키가 있다면 세션을 만드는 과정을 거꾸로 돌려서 user 객체를 얻어낸다. 이 순서를 간략하게 보면 아래와 같다.
hashed_sessionID => sessionID => user.id => deserializeUser(id)
아래의 deserializeUser는 id를 db에서 조회하여 최종적으로 user 객체를 얻어낸다. 그리고 이를 req.user 안에 넣어줌으로써 서버는 요청한 user의 값에 접근할 수 있게된다.
시리얼라이즈와 디시리얼라이즈의 공식문서상 작성 패턴은 아래와 같다. 이들은 사실상 패턴화되어있어 대부분 그대로 사용한다. 로컬전략은 내 경우 user 객체에서 비밀번호를 제거한 뒤에 프론트로 보내주느라 다듬는 로직이 들어가있다. 그리고 최종적인 로그인 라우터의 모양은 아래와 같이 나왔다.
passport/index.js
passport/local.js
route/user.js 최종적인 로그인 라우터 코드
아래 글의 도움을 많이 받았습니다.
https://velog.io/@jakeseo_me/%EB%B2%88%EC%97%AD-passport-local%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%98%EB%8A%94-%EB%AA%A8%EB%93%A0-%EA%B2%83