(막간 보충 공부중)
오늘 한 것:
- TDD 방식으로 FizzBuzz 문제 풀기
- TDD 방식으로 ‘올바른 괄호’ 문제 풀기
- 로그인 인증의 2가지 방식을 코드를 대조하며 공부함.
내일 이어서 할 것:
- 피자나누기 3 / 문자열 뒤집기 / 프로그래머스 0단계 ⇒ 저녁 8시에 튜터님께 찾아가기
(기술 면접 대비 질문 & 정리 모음 좋은 사이트)
Jest로 예외 발생 여부를 테스트하기
Jest로 예외 발생 여부를 테스트하기
toThrow()
: 문자열을 넘기면 예외 메시지를 비교하고 정규식을 넘기면 정규식 체크를 해준다.
- 다만 이렇게 하면 에러가 발생하고
test("throw when id is non negative", () => {
expect(getUser(-1)).toThrow();
expect(getUser(-1)).toThrow("Invalid ID");
});
- 이렇게 해줘야 ‘에러가 던져지는지’를 테스트할 수 있다:
test("throw when id is non negative", () => {
expect(() => getUser(-1)).toThrow();
expect(() => getUser(-1)).toThrow("Invalid ID");
});
1. 문제 발견
1. 문제 발견
기존의 auth-middleware에서
module.exports = (req, res, next) => {
const { authorization } = req.headers;
const [authType, authToken] = (authorization || '').split(' ');
if (!authToken || authType !== 'Bearer') {
res.status(401).send({
errorMessage: '로그인이 필요한 기능입니다.',
});
return;
}
try {
const { user_id } = jwt.verify(...)
}...
이런 방식은 반드시 authorization 헤더를 포함한 HTTP 요청이 발생했을 때만 사용자 인증이 가능하다는 문제를 발견했다. 프론트에서 Ajax 요청을 보내지 않는 단순한 페이지 이동일 시, req.headers.authorization에 유저 확인 정보를 담는 이 방식으로는 계속해서 ‘로그인이 필요한 기능입니다’라고만 페이지를 띄울 뿐이다. 프론트 쪽에서 페이지 로딩이 될 때마다 /users/me를 호출하게 만드는 방식으로 해결할 수 있지만, 이는 서버쪽에서의 해결이 아니다.
location.href = ‘/admin_index’를 활성화하는 시점에 /users/me를 호출하게 하거나 Authorization header를 넣어주거나 하는 방법은 없을까?
페이지가 로딩되는 시점에 일단 me()를 호출하고, 바로 이어서 페이지 자체를 렌더링해주는 api를 또 호출하게 하는 식은 안될까? ⇒ 페이지가 로딩된 시점이라는 것 자체가 이미 렌더링 된 페이지를 받아왔다는 것이므로 안 되겠다.
2. auth-middleware 비교 실험
2. auth-middleware 비교 실험
Authorization 헤더를 이용한 버전
: Request Header의 Authorization으로부터 Bearer 토큰을 불러옴 :
// middlewares/auth-middelware.js
const jwt = require('jsonwebtoken');
const { User } = require('../models');
require('dotenv').config();
module.exports = (req, res, next) => {
const { authorization } = req.headers;
const [authType, authToken] = (authorization || '').split(' ');
if (!authToken || authType !== 'Bearer') {
res.status(401).send({
errorMessage: '로그인이 필요한 기능입니다.',
});
return;
}
try {
const { user_id } = jwt.verify(
authToken,
process.env.ACCESSTOKEN_SECRET_KEY
);
User.findByPk(user_id).then((user) => {
res.locals.user = user;
next();
});
} catch (err) {
console.log(err);
res.status(401).send({
errorMessage: '로그인이 필요한 기능입니다.',
});
}
};
cookie를 이용한 버전 :
// middlewares/auth-middelware.js
const jwt = require('jsonwebtoken');
const { User } = require('../models');
require('dotenv').config();
module.exports = async (req, res, next) => {
try {
const { accessToken } = req.cookies;
...
const { id, nickname } = jwt.verify(accessToken, process.env.COOKIE_SECRET);
const user = await User.findOne({ where: {id, nickname,} });
...
res.locals.user = user;
next();
} catch (error) {
res.clearCookie('accessToken');
next(error);
}
};
⇒ 회원이 존재하지 않으면 에러를 발생시키고, response 객체의 accessToken 쿠키를 지운 후 에러를 errorhandler 미들웨어로 넘긴다.
- app.py에서 cookie-parser를 Express app에 등록시킨다
// app.py const cookieParser = require('cookie-parser') ... app.use(cookieParser()); app.use(express.json()); ...
- cookie가 쓰인 곳(용례) :
// login res.cookie('accessToken', accessToken); // => 쿠키 등록 // logout과 deleteUser res.clearCookie('accessToken'); // => 쿠키 삭제 // auth const { accessToken } = req.cookies; // => 쿠키 조회
authorization 헤더를 이용한 방식과 cookie를 이용한 방식이 다른 점은, 후자의 경우 로그인 API가 단순히 accessToken을 반환하지 않고 Response 객체에 쿠키값으로 저장도 해준다는 것이다.
// controllers/user.controller.js
login = async (req, res, next) => {
...
res.cookie(’accessToken’, accessToken)
return res.json({ accessToken });
}
그렇다면 Request 객체의 Authorization 헤더에도 로그인 당시에 미리 accessToken 값을 넣어두는 식이면, 매번 프론트에서 토큰 값을 넘긴 ajax 호출을 해야만 auth-middleware가 통과되는 문제를 해결할 수 있지 않을까? 목적은 ajax 콜마다 header: { authorization: … }를 넣지 않아도 되도록 하는 것이다.
쿠키는 res.cookie에 한 번 넣어주기만 하면 이후의 req.cookie에도 살아서 (알아서) 포함되어서 요청이 가는 식인 거지? res.headers.authorization에 한 번 넣어주기만 하면 req.headers.authorization에도 자동으로 세팅되어 요청이 가게 될까?
⇒ 실험 결과, 안 됨. 이제와 생각해보니 당연한 게, 쿠키는 한 번 서버로부터 받으면 그 이후 요청마다 자동으로 헤더에 포함하여 요청하도록 만든 것이 애초의 목적이고, 그 외에 요청 헤더에 authorization 을 추가하는 것은 매 요청시마다 자동으로 되는 것이 아니기 때문에.
결론
페이지를 렌더링할 때 Ajax로 매번 authorization 헤더를 담아 보내지 않아도 로그인 된 사용자 식별이 가능하도록 하려면, 현재로선 쿠키를 이용하는 방식이 유일한 방법이겠다. 로그인 시 쿠키에 인증 정보를 담아 응답을 보내도록 하면, 그 이후로 받는 요청엔 알아서 그 쿠키가 담겨 오므로 location.href=’/API주소’ ⇒ res.render()의 요청-응답 사이클에도 로그인 여부를 판별할 수 있게 된다.
3. 로그인 인증의 2가지 방법 코드 정리(최종)
3. 로그인 인증의 2가지 방법 코드 정리(최종)
Authorization 헤더를 이용한 버전
1. 프론트에서 로그인 함수가 실행됨 ⇒ ‘/users/login’ 엔드포인트로 POST 요청 발생.
function login() { const loginEmail = $('#loginEmail').val(); const loginPassword = $('#loginPassword').val(); if (!loginEmail) { alert('이메일을 입력해주세요.'); } else if (!loginPassword) { alert('비밀번호를 입력해주세요.'); } else { $.ajax({ type: 'POST', url: '/users/login', data: { email: loginEmail, password: loginPassword, }, // ... }); } }
2. 서버측 ‘/users/login’ 엔드포인트에 연결된 로그인 메서드에서 JWT 토큰을 생성하여 응답으로 넘김.
login = async (req, res, next) => { ... const accessToken = jwt.sign( { user_id }, process.env.ACCESSTOKEN_SECRET_KEY, { expiresIn: '36000s' } ); res.status(200).json({ accessToken }) }
3. 다시 프론트의 로그인 함수가 성공 응답 객체를 받아 localStorage에 accessToken을 저장해 둠.
function login() { ..., success: function (response) { alert('로그인이 완료되었습니다.'); localStorage.setItem('accessToken', response.accessToken); window.location.href = '/'; }, error: function (err) { alert(err.responseJSON.message); }, }
4. 이후로 프론트 측에서 로그인 인증이 필요한 API 요청을 보내야 할 때마다 localStorage에서 꺼낸 accessToken을 authorization 헤더에 담아 요청함.
function me() { $.ajax({ type: 'GET', url: '/users/me', headers: { authorization: `Bearer ${localStorage.getItem('accessToken')}`, }, data: {}, success: function (response) { ... } }) }
5. 해당하는 엔드포인트(=모든 ‘로그인이 필요한’ 엔드포인트)를 받는 라우터는 auth 미들웨어를 통과하도록 서버측에 설계가 되어 있음.
// routes/user.routes.js router.get('/me', authMiddleware, userController.me);
6. auth 미들웨어는 다시 Request 객체의 authorization 헤더를 살펴 accessToken을 가져와 유효성 검사를 함. 검사를 통과하면 해당하는 유저 정보를 불러와 Response 객체의 locals.user에 실어놓고, 다음 턴으로 넘긴다.
// middlewares/auth-middleware.js ... module.exports = (req, res, next) => { const { authorization } = req.headers; const [authType, authToken] = (authorization || '').split(' '); ... try { const { user_id } = jwt.verify( authToken, process.env.ACCESSTOKEN_SECRET_KEY ); User.findByPk(user_id).then((user) => { res.locals.user = user; next(); }); } catch (err) { ... } }
7. auth 미들웨어를 잘 통과해 도달한 me() 메서드에서는 Response 객체에 실려온 locals.user를 그대로 응답으로 전해준다.
// controllers/user.controller.js me = async (req, res) => { res.send(res.locals.user); };
- 프론트에 json으로 유저 정보가 도달함.
cookie를 이용한 버전
1. 프론트에서 로그인 함수 실행됨. ⇒ ‘/users/login’ 엔드포인트로 POST 요청 발생.
function login() { const email = document.getElementById('email').value; const password = document.getElementById('password').value; $.ajax({ type: 'POST', url: '/users/login', data: { email, password }, success: function (response) { location.href = '/'; }, error: function (response) { alert(response.responseJSON.message); }, }); }
2. 해당 엔드포인트 연결된 로그인 메서드에서 JWT 토큰을 생성하여 Response 객체의 cookie에도 저장하고, 응답으로도 넘김.
// controllers/user.controller.js login = async (req, res, next) => { try { ... const { status, accessToken } = await this.userService.login(userInfo); res.cookie('accessToken', accessToken); return res.status(status).json({ accessToken }); } catch (error) { next(error); } }; // services/user.service.js login = async (userInfo) => { try { const user = await this.userRepository.findUser(userInfo, 'login'); ... const accessToken = jwt.sign( { user_id: user.id }, process.env.COOKIE_SECRET, { expiresIn: '1d' } ); return { status: 200, accessToken }; } catch (error) { throw error; } };
3. 다시 프론트의 로그인 함수가 성공 응답을 받으면 (localStorage에 accessToken을 저장해 두는 절차 없이) 그냥 홈페이지로 리다이렉션을 걸어줌.
4. 이후로 프론트 측에서 로그인 인증이 필요한 API 요청을 보내야 할 때마다 (authorization 헤더를 덧붙일 필요 없이) 그냥 ajax 요청을 보내면 된다.
function getMypage(id) { $.ajax({ type: 'GET', url: `/users/mypage/${id}`, headers: { authorization: `Bearer ${localStorage.getItem('accessToken')}`, }, success: function (response) { let { user } = response; ... }, }); }
혹은 단순한 링크 클릭으로 인한 GET 요청도 가능하다.
// header.ejs <li><a href="/admin_index">관리자 페이지</a></li>
5. 해당하는 엔드포인트에서는 로그인 인증 auth 미들웨어를 통과하도록 되어 있음. 이 때, accessToken을 가져오는 곳이 Request 객체의 cookie이다(authorization 헤더가 아님).
// middlewares/auth-middleware.js module.exports = async (req, res, next) => { try { const { accessToken } = req.cookies; ... } catch (err) { ... } }
6. 그래서 요청 헤더에 특별히 authorization: 토큰을 넣은 ajax 요청이 아님에도 다음과 같은 ‘유저 판별’이 가능하게 됨.
// routes/home.routes.js // 관리자 인트로 페이지 router.get('/admin_index_auth', authMiddleware, (req, res, next) => { try { // 로그인을 하지 않은 경우 if (!res.locals.user) { const error = new Error('로그인이 필요합니다') error.status = 401; throw error; } // 로그인을 했지만 관리자가 아닌 경우 if (res.locals.user.user_status !== 1) { const error = new Error('관리자 권한이 필요합니다'); error.status = 403; throw error; } // 관리자인 경우 res.render('managermain', { title: '관리자 메인' }); } catch (error) { // 던져진 에러를 받아서 next로 넘김 next(error); } })
- 결론: localStorage에 한 번 더 저장하는 과정을 건너뛰게 되었다. 요청 헤더를 특별히 담아 보낼 수 있는 ajax 요청을 통하지 않고도 사용자의 로그인 여부를 식별할 수 있게 되었다. 처음에 accessToken을 발급하는 login() 메서드에서 req.cookie에 담아주고, 이후 auth-middleware에서 req.cookies에서 accessToken 값을 참조하여 오는 2단계 프로세스가 핵심이다.
쿠키
쿠키
쿠키는 키=밸류 방식임
원리: 사용자가 브라우저의 쿠키 데이터를 비우거나 금지하지 않는다면 계속해서 유지되는 원리. 한 번 서버에서 쿠키를 담아보내면, 그 이후로 클라이언트의 요청마다 헤더에 쿠키가 자동으로 포함되어 전달되게 된다.
- 세션 쿠키는 브라우저가 종료될 때 함께 소멸함.
- 영속 쿠키는 브라우저를 종료해도 서버가 지정한 시간까지 브라우저 저장소에 저장됨. (=서버에서 expires / maxAge 옵션을 지정해준 경우.)
비연결성(Connectionless)과 비상태성(Stateless): 사용자의 요청마다 연결과 해제를 반복하며 연결 상태를 유지하지 않음. 연결 해제 후에도 상태 정보를 저장하지 않음.
자바스크립트로 쿠키 다루기
// 쿠키 조회
const cookies = document.cookie // cookie1=value1; cookie2=value2; …
// 쿠키 등록 및 수정
document.cookie = "username=John Doe; expires=Thu, 18 Dec 2013 12:00:00 UTC; path=/";
그래서 Refresh token은 어떻게 쓰는 것인가
refresh token은 새로운 access token을 요청할 때만 서버에 전송하도록 한다. (그동안 사용자 측에서 refresh token은 어디에 저장하고 있으라고?) 그리고 HTTPS를 비존으로 사용하여 acccess와 refresh 토큰을 주고받도록 한다. 한 마디로 정리하면, 리프레쉬 토큰은 탈취되면 큰일이니 항상 안전한 곳에 보관하고, https와 같은 안전한 전송수단만을 사용해야 하고, 액세스 토큰 발급시에만 전송하도록 한다.
refresh 토큰을 이용해 새로운 access 토큰 요청이 들어왔다면, 이 때 서버측에서 다른 나라의 IP 주소인지 계정 도용으로 신고된 아이디인지 등을 검증하는 작업을 거치도록 만든다. 만약 해커라고 판단되면 요청을 무시하고 해당 refresh 토큰을 서버에서 지워버리도록 한다.
그러니까 access 토큰은 한 번 줘버리고 서버는 알 수가 없는 stateless이고, refresh 토큰은 서버가 상태를 가지고 보관하는 대신 아주 가끔만 사용하는 거라고 보면 되겠다. 그래서 access 토큰은 탈취를 당했어도 손 쓸 방법이 없지만 refresh 토큰은 서버측에서 해당 토큰 정보를 ‘지워버리는’식으로 관리를 할 수 있는 것이다.
- 클라이언트는 refresh 토큰을 어디다 보관하는가?
(참고 - 쿠키의 수명과 범위, 보안에 대하여)
Express의 Cookie-parser와 서명 쿠키 다루기
쿠키 쓰기(만들기)
쿠키를 만드는 경우에는 res.cookie를 통해 만들며 res.cookie(키, 값, 옵션) 의 형태로 사용이 가능하다
쿠키 옵션
- maxAge : 만료 시간을 밀리초 단위로 설정
- expires : 만료 날짜를 GMT 시간으로 설정
- path : 쿠키의 경로 디폴트 값은 "/"
- domain : 도메인 네임 디폴트 값은 "loaded"
- secure : https에서만 쿠키를 사용할 수 있도록 한다
- httpOnly : 웹서버를 통해서만 쿠키에 접근할 수 있도록 한다
- signed : 쿠키에 대한 서명을 지정한다
예시:
// 서명을 위한 비밀키 넣기
app.use(cookieParser(process.env.COOKIE_SECRET));
// 쿠키 만들기(담기)
res.cookie('name', 'beomseok', {
expires: new Date(Date.now() + 900000),
httpOnly: true,
secure: true,
signed: true,
});
// (서명된) 쿠키 조회하기
req.signedCookies
// 쿠키 삭제하기 (expires와 maxage를 제외한 키, 값, 옵션이 정확히 일치해야 지워짐)
res.clearCookie('name', 'beomseok', {
httpOnly: true,
secure: true,
signed: true,
});
Express에서 Response 객체 Header 수정하기
Express에서 Response 객체 Header 수정하기
node의 쌩 http 모듈로는 이렇게 지정하면 되는 것 같길래 express의 response 객체에도 적용해봤는데 안 됨:
// 쌩 http 모듈에서
// req 쿠키에 접근하기:
req.headers.cookie
// res에 쿠키 담기 예시:
res.writeHead(302, {
Location: '/',
'Set-Cookie': `name=${encodeURIComponent(name)}; Expires=${expires.toGMTString()}; HttpOnly; Path=/`,
});
// Express.js에서 이렇게 해보았으나 res.headers를 undefined로 인식함.
res.headers.authorization = `Bearer ${accessToken}`;
차ㅈ아보니 Express.js에서는 이렇게 header에 수정을 가한다고 한다:
res.set('Content-Type', 'text/plain');
res.set({
'Content-Type': 'text/plain',
'Content-Length': '123',
'ETag': '12345'
})
// 혹은,
res.header(field, [value])
해결
res.header('authorization', `Bearer ${accessToken}`);
⇒ 이렇게 헤더에 추가는 되었지만 이게 다음 Request 때도 자동으로 담겨서 오지는 않음을 확인했다. Response 헤더를 조작하는 주 작업은 CORS를 다루는 용도인 듯 하다.
총 정리:
- req 헤더 읽기: req.headers;
- res 헤더 읽기:
res.getHeaders() (res.headers // => undefined)
- res 헤더 수정하기:
res.setHeaders - calls the native Node.js method res.set - sets headers res.header - an alias to res.set
- 왜 next(error)라고 넘기기만 하면 Express app의 마지막 미들웨어인 errorHandler 함수로 에러가 도달하게 되는 걸까?
Uploaded by N2T