Token
해싱
복호화가 가능한 다른 암호화 방식들과 달리, 해싱은 암호화만 가능하다.
해싱은 해시 함수(Hash Function)을 사용하여 암호화를 진행하는데, 해시 함수는 다음과 같은 특징을 가진다.
- 항상 같은 길이의 문자열을 리턴한다.
- 서로 다른 문자열에 동일한 해시 함수를 사용하면 반드시 다른 결과값이 나온다.
- 동일한 문자열에 동일한 해시 함수를 사용하면 항상 같은 결과값이 나온다.
아래 표는 대표적인 해시 함수중 하나인 SHA1에 특정 입력 값을 넣었을 때 어떤 결과가 리턴되는지 보여주는 예시이다. 이 링크에서 SHA1 함수를 직접 사용해볼 수도 있다.
비밀번호 | 해시 함수(SHA1) 리턴 값 |
‘password’ | ‘5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8’ |
‘Password’ | ‘8BE3C943B1609FFFBFC51AAD666D0A04ADF83C9D’ |
‘kimcoding’ | ‘61D17C8312E8BC24D126BE182BC674704F954C5A’ |
레인보우 테이블과 솔트
그런데 항상 같은 결과값이 나온다는 특성을 이용해 해시 함수를 거치기 이전의 값을 알아낼 수 있도록 기록해놓은 표인 레인보우 테이블이 존재한다. 레인보우 테이블에 기록된 값의 경우에는 유출이 되었을 때 해싱을 했더라도 해싱 이전의 값을 알아낼 수 있으므로 보안상 위협이 될 수 있다.
이 때 활용할 수 있는 것이 솔트(Salt)이다. 솔트는 소금이라는 뜻으로, 말 그대로 소금을 치듯 해싱 이전 값에 임의의 값을 더해 데이터가 유출 되더라도 해싱 이전의 값을 알아내기 더욱 어렵게 만드는 방법이다.
비밀번호 + 솔트 | 해시 함수(SHA1) 리턴 값 |
‘password’ + ‘salt’ | ‘C88E9C67041A74E0357BEFDFF93F87DDE0904214’ |
‘Password’ + ‘salt’ | ‘38A8FDE622C0CF723934BA7138A72BEACCFC69D4’ |
‘kimcoding’ + ‘salt’ | ‘8607976121653D418DDA5F6379EB0324CA8618E6’ |
솔트를 사용하게 되면 해싱 값이 유출되더라도, 솔트가 함께 유출 된 것이 아니라면 암호화 이전의 값을 알아내는 것은 불가능에 가깝다.
해싱의 목적
그런데, 왜 복호화가 불가능한 암호화 방식을 사용하는 걸까? 바로 해싱의 목적은 데이터 그 자체를 사용하는 것이 아니라, 동일한 값의 데이터를 사용하고 있는지 여부만 확인하는 것이 목적이기 때문이다.
예시를 들어보자. 사이트 관리자는 사용자의 비밀번호를 알고있을 필요가 없다. 오히려 사용자들의 비밀번호를 알고 있다면, 이를 얼마든지 악용할 수 있기 때문에 심각한 문제가 생길 수도 있다. 그래서 보통 비밀번호를 데이터베이스에 저장할 때, 복호화가 불가능하도록 해싱하여 저장하게 된다. 해싱은 복호화가 불가능하므로 사이트 관리자도 정확한 비밀번호를 알 수 없게 된다.
그럼 서버측에서 비밀번호를 모르는 상태에서 어떻게 로그인 요청을 처리할 수 있는 걸까? 방법은 간단하다. 해싱한 값끼리 비교해서 일치하는지 확인하는 것이다. 꼭 정확한 값을 몰라도, 해싱한 값이 일치한다면 정확한 비밀번호를 입력했다는 뜻이 되기 때문에, 해싱 값으로만 로그인 요청을 처리하는데에도 전혀 문제가 없다.
이처럼 해싱은 민감한 데이터를 다루어야 하는 상황에서 데이터 유출의 위험성은 줄이면서 데이터의 유효성을 검증하기 위해서 사용되는 단방향 암호화 방식이다.
토큰 기반 인증
토큰을 쓰는 이유
세션 기반 인증은 서버에 유저정보를 담는 인증 방식이었다.
서버에서는 제한된 정보를 유저가 요청할 때마다 정보를 줘도 되는지 확인하기 위해서 서버가 가지고 있는 세션 값과 일치하는지 확인해야 했다. 이 확인 작업을 덜어 줄 수 있는 것이 토큰이다.
토큰 기반 인증 중 대표적인 방법인 JWT (JSON Web Token)을 배워보자.
클라이언트에 인증 정보 보관 -> 안전한가?
클라이언트에서 인증 정보를 보관하는 방법으로 토큰 기반 인증이 만들어졌다.
그런데 클라이언트는 XSS, CSRF 공격에 노출이 될 위험이 있으니 민감한 정보를 담고 있어서는 안된다고 배웠다.
인증에 사용되는 토큰을 클라이언트에 담아도 되나? 라는 의문이 들 수 있다.
토큰은 유저 정보를 암호화한 상태로 담을 수 있고, 암호화했기 때문에 클라이언트에 담을 수 있다.
JWT의 종류
두가지의 토큰이 존재한다.
1. Access Token
2. Refresh Token
Access token은 보호된 정보(유저의 이메일, 연락처, 사진 등)에 접근할 수 있는 권한 부여에 사용한다.
클라이언트가 처음 인증을 받게 될 때(로그인 시) access, refresh token 두 가지를 다 받지만, 실제로 권한을 얻는 데 사용하는 토큰은 access token이다.
권한을 부여받는데에는 access token만 있으면 되므로 access token이 해킹당한다면 해커가 서버에 나인 것 처럼 요청을 보낼 수 있다.
그래서 access token에 짧은 유효기간을 줘서 탈취되더라도 오래 사용 할 수 없도록 하고, access token의 유효기간이 만료되면
refresh token으로 새로운 access token을 발급받아서 사용 하도록 한다. 이때 다시 로그인 할 필요는 없다.
만약 refresh token도 해킹당한다면 이는 큰 문제로 이어지므로 유저의 편의보다 정보를 지키는 것이 더 중요한 웹사이트 들은 refresh token을 사용하지 않는 곳이 많다.
JWT의 구조
JWT는 위 그림과 같이 . 으로 나누어진 3부분이 존재한다.
1) Header
Header는 어떤 종류의 토큰인지(지금은 JWT), 어떤 알고리즘으로 sign(암호화)할지가 적혀있다.
JSON Web Token이라는 이름에 맞게 JSON 형태로 작성된다.
{
"alg": "HS256",
"typ": "JWT"
}
JSON 객체를 base64 방식으로 인코딩하면 JWT의 첫 번째 부분이 완성된다.
2) Payload
Payload에는 정보가 담겨 있다.
어떤 정보에 접근 가능한지에 대한 권한을 담거나 유저 이름과 같은 개인정보를 담을 수 있다. 혹은 둘 다 담을 수 있다. 필요한 데이터는 이곳에 담아 Sign 시킨다.
Payload 에는 민감한 정보는 되도록 담지 않는 것이 좋다. 디코딩이 쉬운 base64방식으로 인코딩되기 때문이다.
{
"sub": "someInformation",
"name": "phillip",
"iat": 151623391
}
JSON 객체를 base64로 인코딩하면 JWT의 두 번째 블록이 완성된다.
3) Signature
base64로 인코딩된 첫 번째, 두 번째 부분이 완성되면, 원하는 비밀 키(암호화에 추가할 salt)를 사용하여 암호화한다.
HMAC SHA256 알고리즘(암호화 방법)을 사용하면 signature는 아래와 같은 방식으로 생성된다.
HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
따라서 누군가 권한을 속이기 위해 알아낸 헤더와 페이로드를 사용해서 토큰을 위조하더라도, 서버의 비밀키까지 알고있지 않다면 전혀 다른 signature가 만들어지기 때문에 서버가 해당 토큰이 올바르지 못함을 확인할 수 있다.
토큰기반 인증 절차
- 클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보낸다.
- 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 Signature 된 토큰을 생성한다.
2-1. access/refresh 토큰을 모두 생성한다.
토큰에 담길 정보(payload)는 유저를 식별할 정보, 권한이 부여된 카테고리(사진, 연락처, 기타 등등)이 될 수 있다.
두 종류의 토큰이 같은 정보를 담을 필요는 없다 (이 스프린트에서는 같은 정보를 담아준다). - 토큰을 클라이언트에게 보내주면, 클라이언트는 토큰을 저장한다.
- 클라이언트가 HTTP 헤더(authorization 헤더)에 토큰을 담아 보낸다.
4-1. bearer authentication을 이용한다. 참고 링크1(요약), 링크2(상세) - 서버는 토큰을 해독해서 발급해준 토큰이 맞다면 클라이언트의 요청을 처리한 후에 응답을 보내준다.
토큰기반 인증의 장점
- Statelessness & Scalability (무상태성 & 확장성)
서버는 클라이언트에 대한 정보를 저장할 필요가 없다 (토큰 해독이 되는지만 판단하면 된다).
클라이언트는 새로운 요청을 보낼 때마다 토큰을 헤더에 포함시키면 된다.
서버를 여러 개 가지고 있는 서비스라면 같은 토큰으로 여러 서버에서 인증이 가능하기 때문에 더 편리하다. - 안전하다.
signature을 받은 토큰을 사용하고, 암호화 키를 노출할 필요가 없기 때문에 안전하다. - 권한 부여가 쉽다.
토큰의 payload(내용물) 안에 어떤 정보에 접근 가능한지 정할 수 있다.
ex) 서비스의 사진 사용 권한만 부여
토큰기반 인증의 단점
JWT토큰의 단점은 용량이 크다는 것이다.
내용물이 들어있기 때문에 랜덤한 토큰을 사용 할 때보다 용량이 크다.
요청을 할 때마다 토큰이 오고 가므로 데이터 양이 증가한다.
랜덤스트링을 사용해서 매번 사용자 정보를 조회하는 작업의 비용과 내용물이 들어있는 jwt토큰을 사용해서 발생하는 데이터 비용 중 어떤 것이 더 큰지를 보고 선택해 사용하면 된다.
과제 : JSON Web Token 을 이용하여 토큰방식 인증을 구현
1. 서버 환경 변수 설정
.env
env란?
Node.js의 process.env는 사용자 환경 변수를 담는 객체다.
value는 문자열로 자동 형변환된다.
ACCESS_SECRET=secret key
REFRESH_SECRET=secret key
jwt.sign 함수에서 사용할 시크릿 키를 설정해준다.
※ key vs salt
키는 대략적으로 암호와 동일하다.
이를 사용하여 메시지를 암호화한 다음 동일한 키를 사용하여 원래 일반 텍스트로 복호화할 때 사용한다.
솔트는 암호화 기능이 아닌 암호화 해시 기능에서 가장 일반적으로 발생한다.
해싱할 때 데이터(예: 비밀번호)만 해싱하는 것이 아니라 데이터+salt를 해싱한다.
여기서 salt은 일반적으로 무작위로 생성된 문자열이다.
https://stackoverflow.com/questions/7303266/difference-between-salt-and-key-encryption
※ jwt.sign
jwt.sign(payload, secretOrPrivateKey, [options, callback])
// Returns the JsonWebToken as string
jwt.sign function takes the payload, secret and options as its arguments. The payload can be used to find out which user is the owner of the token. Options can have an expire time until which token is valid. The generated token will be a string.
We are then sending the generated token back to the client in the response body. The client should preserve this token for future requests.
※ jwt.verify
jwt.verify(token, jwtSecret, (error, decoded) => {
if(error) {
console.error(error);
return;
}
console.log(decoded);
});
https://helloinyong.tistory.com/111
tokenFunction.js
jwt.sign()함수는 비동기 함수이므로 Promise 처리를 해줘야 한다.
require('dotenv').config();
const { sign, verify } = require('jsonwebtoken');
// sign => 암호화 verify => 검증
module.exports = {
generateToken: async (user, checkedKeepLogin) => {
const payload = {
id: user.id,
email: user.email,
};
let result = {
accessToken: sign(payload, process.env.ACCESS_SECRET, {
expiresIn: '1d', // 1일간 유효한 토큰을 발행합니다.
}),
};
if (checkedKeepLogin) {
result.refreshToken = sign(payload, process.env.REFRESH_SECRET, {
expiresIn: '7d', // 일주일간 유효한 토큰을 발행합니다.
});
}
return result;
},
verifyToken: async (type, token) => {
let secretKey, decoded;
switch (type) {
case 'access':
secretKey = process.env.ACCESS_SECRET;
break;
case 'refresh':
secretKey = process.env.REFRESH_SECRET;
break;
default:
return null;
}
try {
decoded = await verify(token, secretKey);
} catch (err) {
console.log(`JWT Error: ${err.message}`);
return null;
}
return decoded;
},
};
2. 서버 CORS 및 프로토콜 설정
server/index.js
CORS 요청을 받기 위해 'credentials = true'를 작성한다.
const express = require('express');
const cors = require('cors');
const app = express();
app.use(
cors({
origin: 'http://localhost:3000',
methods: ['GET', 'POST', 'OPTIONS'],
credentials: true, //client에서도 withCredentials=true 설정해야 함
})
);
인증서 파일이 존재하는 경우에만 HTTPS 프로토콜을 사용하는 서버 실행
없는 경우 HTTP 프로토콜을 사용하는 서버 실행
let server;
if (fs.existsSync('./key.pem') && fs.existsSync('./cert.pem')) {
const privateKey = fs.readFileSync(__dirname + '/key.pem', 'utf8');
const certificate = fs.readFileSync(__dirname + '/cert.pem', 'utf8');
const credentials = {
key: privateKey,
cert: certificate,
};
server = https.createServer(credentials, app);
server.listen(HTTPS_PORT, () => console.log(`🚀 HTTPS Server is starting on ${HTTPS_PORT}`));
} else {
server = app.listen(HTTPS_PORT, () => console.log(`🚀 HTTP Server is starting on ${HTTPS_PORT}`));
}
3. 로그인
server/controllers/login.js
module.exports = async (req, res) => {
const { userId, password } = req.body.loginInfo;
const { checkedKeepLogin } = req.body;
// checkedKeepLogin이 false라면 Access Token만 보내야합니다.
// checkedKeepLogin이 true라면 Access Token과 Refresh Token을 함께 보내야합니다.
const userInfo = {
...USER_DATA.filter((user) => user.userId === userId && user.password === password)[0],
};
// console.log(userInfo);
/*
* TODO: 로그인 로직을 구현하세요.
*
* userInfo에는 요청의 바디를 이용해 db에서 조회한 유저정보가 담겨있습니다. 콘솔에서 userInfo를 출력해보세요.
* 유저의 정보가 출력된다면 해당 유저가 존재하는 것임으로 로그인 성공에 대한 응답을 전송해야합니다.
* 만약 undefined가 출력된다면 해당하는 유저가 존재하지 않는 것임으로 로그인 실패에 대한 응답을 전송해야합니다.
*
* 로그인 성공 시에는 쿠키에 JWT를 담아 전송해야합니다.
* 로그인 상태가 유지되어야 한다면 Access Token과 Refresh Token 모두 보내야합니다.
* Access Token은 Session 쿠키로 Refresh Token은 Persistent Cookie로 보내야합니다.
* Access Token의 쿠키 아이디는 access_jwt, Refresh Token의 쿠키 아이디는 refresh_jwt로 작성하세요.
*
* 로그인 상태가 유지되길 원하지 않는다면 Access Token만 보내야합니다.
*
* 클라이언트에게 바로 응답을 보내지않고 서버의 /useinfo로 리다이렉트해야 합니다.
* express의 res.redirect 메서드를 참고하여 서버의 /userinfo로 리다이렉트 될 수 있도록 구현하세요.
*/
//userInfo => 사용자 입력값과 일치하는 DB의 유저 정보를 담은 객체
//빈 객체일 경우 일치하는 유저 정보가 없음
if(!userInfo.userId) {
return res.status(401).send("Not Authorized")
};
//async로 구현된 토큰 생성자 generateToken 실행. async로 구현되었기때문에 await 사용.
//generateToken => checkedKeepLogin 여부에 따라 accesstoken 또는 accesstoken & refreshtoken 생성함
const { accessToken, refreshToken } = await generateToken(userInfo, checkedKeepLogin);
//로그인 유지를 체크했을 경우 refresh token 생성됨
if (refreshToken) {
res.cookie('refresh_jwt', refreshToken, {
domain: 'localhost',
path: '/',
sameSite: 'none',
httpOnly: true,
secure: true,
// 토큰 만료기간과 쿠키 만료기간은 다른 개념!
// 클라이언트가 받은 쿠키가 언제까지 유지될 것인지 설정.
expires: new Date(Date.now() + 24 * 3600 * 1000 * 7) //7일 후 소멸되는 persistent cookie
})
}
//로그인 유지 안함
//access token 기본제공
res.cookie('access_jwt', accessToken, {
domain: 'localhost',
path: '/',
sameSite: 'none',
httpOnly: true,
secure: true,
//expires 옵션이 없는 session cookie
})
return res.redirect('/userinfo')
};
4. 로그아웃
res.clearCookie('쿠키의 key', cookieOptions) method로 쿠키를 삭제한다.
기본적으로 access token을 삭제하고, refresh token이 있는 경우 refresh token 또한 삭제한다.
server/controllers/logout.js
module.exports = (req, res) => {
/*
* TODO: 로그아웃 로직을 구현하세요. 로그아웃 요청은 쿠키에 저장된 토큰을 삭제하는 과정을 포함해야 합니다.
*
* cookie-parser의 clearCookie('쿠키의 키') 메서드로 해당 키를 가진 쿠키를 삭제할 수 있습니다.
* 만약 res.clearCookie('user') 코드가 실행된다면 `user=....` 쿠키가 삭제됩니다.
* Refresh Token을 발급받았던 유저라면 Refresh Token 또한 삭제되어야 합니다.
*
* 로그아웃 성공에 대한 상태 코드는 205가 되어야합니다.
*/
const refreshToken = req.cookies['refresh_jwt'];
// Refresh Token을 발급받았던 유저라면 Refresh Token까지 삭제
if(refreshToken) {
res.clearCookie('refresh_jwt', {
domain: 'localhost',
path: '/',
sameSite: 'none',
httpOnly: true,
secure: true,
})
}
res.clearCookie('access_jwt', {
domain: 'localhost',
path: '/',
sameSite: 'none',
httpOnly: true,
secure: true,
})
return res.status(205).send('logout');
};
5. 마이페이지
server/controllers/userInfo.js
const { USER_DATA } = require('../../db/data');
// JWT는 verifyToken으로 검증할 수 있습니다. 먼저 tokenFunctions에 작성된 여러 메서드들의 역할을 파악하세요.
const { verifyToken, generateToken } = require('../helper/tokenFunctions');
module.exports = async (req, res) => {
/*
* TODO: 토큰 검증 여부에 따라 유저 정보를 전달하는 로직을 구현하세요.
*
* Access Token에 대한 검증이 성공하면 복호화된 payload를 이용하여 USER_DATA에서 해당하는 유저를 조회할 수 있습니다.
* Access Token이 만료되었다면 Refresh Token을 검증해 Access Token을 재발급하여야 합니다.
* Access Token과 Refresh Token 모두 만료되었다면 상태 코드 401을 보내야합니다.
*/
// 왜 cookieId까지 들어가는거지...?ㅜㅜ
// console.log(req.cookies);
// 토큰을 받는다.
const accessToken = req.cookies['access_jwt'];
const refreshToken = req.cookies['refresh_jwt'];
//토큰을 만들때 사용한 secret을 가지고 온건지 검증한다.
const accessPayload = await verifyToken('access', accessToken);
// verify 되었다면 accessPayload 안에 decoded 문자열이 있다.
// verify 되지 않았다면 accessPayload 안에 null이 들어있다.
// iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
// exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정되어있어야 한다.
// console.log(accessPayload);
//case1: access token이 검증됐을 경우
if(accessPayload) {
const userInfo = { ...USER_DATA.filter((user) => user.id === accessPayload.id)[0]};
//복호화된 값을 DB와 비교
if(!userInfo) {
return res.status(401).send('Not Authorized');
}
//password는 delete등으로 삭제하여 보낸다
return res.send({ ...userInfo, password: undefined});
}
//case2: access token이 만료되어 refresh token을 검증하는 경우
else if(refreshToken) {
const refreshPayload = await verifyToken('refresh', refreshToken);
//refresh token 복호화 결과를 담은 refreshPayload
// 검증되지 않은 경우
if (!refreshPayload) {
return res.status(401).send('Not Authorized');
}
//검증된 경우 userinfo로 accesstoken을 다시 발급
//refresh token 유효시간이 이내일 경우 이와 같이 로그인 유지가 가능하다
const userInfo = USER_DATA.filter((user) => user.id === refreshPayload.id)[0];
const { accessToken } = await generateToken(userInfo);
res.cookie('access_jwt', accessToken, {
domain: 'localhost',
path: '/',
sameSite: 'none',
httpOnly: true,
secure: true,
// Expires 옵션이 없는 Session Cookie
});
//password는 delete등으로 삭제하여 보낸다
return res.send({ ...userInfo, password: undefined });
}
return res.status(401).send('Not Authorized');
};
정리
1. OPTIONS /login CORS 요청
2. POST /login
- 로그인 성공: 302 redirect('/userinfo')
- 로그인 유지시 refresh token(영속성쿠키) + access token(세션쿠키) 전달
- 로그인 유지 안했을시 access token만 세션쿠키로 전달
- 로그인 실패: 401 Not Authorized
3. OPTIONS /userinfo CORS 요청
4. GET /userinfo 304 (로그인된 상태로 브라우저 새로고침 및 재접속 했을시 여기부터 시작)
- access token 검증
- 검증됐을 경우 DB의 유저 정보와 대조 => 맞을시 GET 요청 성공
- access token 만료 && refresh token 있을시 검증
- 검증됐을 경우 refresh token과 DB의 유저 정보와 대조 => 맞을시 access token 생성하여 세션쿠키로 전달 && 로그인 유지 성공
- 모든 검증에 실패(유효 시간 만료)했을 경우 401 Not Authorized
(토큰이 만료되어 재로그인 해야한다)
5. POST /logout 205
access token(과 경우에 따라 refresh token까지) 포함하는 쿠키를 삭제한다. => 즉시 로그인이 해제됨으로 로그아웃 상태가 된다.
'코드스테이츠 SEB FE 41기 > Section 별 내용 정리' 카테고리의 다른 글
section4/Unit1/[자료구조/알고리즘] 기초(11/17) (0) | 2022.11.17 |
---|---|
section3/Unit8/ [React]Coz’ Mini Hackathon(11/15) (0) | 2022.11.15 |
section3/Unit6/[네트워크] 심화(11/9) (0) | 2022.11.09 |
section3/Unit4/[사용자 친화 웹] 웹 표준 & 접근성(11/5) (0) | 2022.11.07 |
section3/Unit4/[사용자 친화 웹] 웹 표준 & 접근성(11/4) (0) | 2022.11.03 |