[개념 정리] 로그인 - 세션 기반 인증 VS 토큰 기반 인증
로그인 구현 방식으로는 대표적으로 세션 기반 인증 방식과 토큰 기반 인증 방식 두 가지가 있다.
프로젝트 진행 시에는 토큰 기반 인증 방식만 사용해보았는데,
각각의 특징과 장단점을 살펴보자.
세션 기반 인증 방식
- 세션: 서버와 클라이언트의 연결이 활성화된 상태.
- 세션ID: 웹 서버 또는 DB에 저장되는 클라이언트에 대한 고유한 ID.
세션 기반 로그인 프로세스는 아래와 같다.
- 최초 로그인 -> 세션 ID 생성 -> 서버에서 세션ID를 쿠키로 설정하여 클라이언트에게 전달
- 클라이언트가 서버에 요청을 보낼 때, 해당 세션 ID를 쿠키로 담아 전에 로그인한 아이디인지 확인
- 맞다면 로그인 유지
단점
- 사용자의 상태에 관한 데이터를 서버에 저장했을 때, 로그인 상태인 유저의 수가 늘어난다면 서버 메모리 과부하가 일어날 수 있다.
- DB 중 RDBMS에 저장하면 직렬화 및 역직렬화에 관한 오버헤드가 발생한다.
아래는 세션 기반 인증 방식을 node.js로 구현한 예시 코드이다.
const escapeHtml = require('escape-html')
const express = reuqire('express')
const session = require('express-session')
const app = express()
app.use(session({
name: "session-id",
secret: "01F173DF07BE5D18D098A287567DDC3B54833D5CE6D8CBFB4B257EC5775D1A95",
resave: false,
saveUninitialized: false
}))
//왈왈이대박을 SHA256으로 해싱
//>> 01F173DF07BE5D18D098A287567DDC3B54833D5CE6D8CBFB4B257EC5775D1A95
//미들웨어: auth
const isAuthenticated = (req, res, next) => {
if(req.session.user) next()
else nest('route')
}
//만약 isAuthenticated가 true라면 나오는 페이지
app.get('/', isAuthenticated, function(req, res){
res.send(escapeHtml(req.session.user) + '님 환영합니다!')
})
//만약 isAuthenticated가 false라면 나오는 페이지
app.get('/', function(req,res){
res.send('<p>로그인</p><form action="/login" method="post">' +
'Username: <input name="user"><br>' +
'Password: <input name="pass" type="password"><br>' +
'<input type="submit" text="Login"></form>')
})
app.post('/login', express.urlencoded({extended: false}),
function(req, res){
if(req.body.user === "walwal" && req.body.pass === "1234"){
req.session.regenerate(function(err){
if(err) next(err)
//input: user에 있는 값을 req.session.user에 할당
req.session.user = req.body.user
//세션 생성 >> 쿠키값 설정 >> 이후 다시 리다이렉팅
req.session.save(function(err){
if(err) return nest(err)
res.redirect('/')
})
})
}else res.redirect('/')
})
app.listen(3000, () => console.log("server is started: http://localhost:3000"))
이러한 방식으로 하여 세션을 생성하고, 쿠키값을 자동으로 설정할 수 있다.
일반적으로 세션을 다루는 라이브러리는 세션ID를 생성하면 쿠키에 해당 값을 설정한다.
쿠키가 아닌 로컬 스토리지나, 세션 스토리지에 담을 수도 있으나 쿠키에 담는 것이 관례다.
토큰 기반 인증 방식
토큰 기반 인증 방식은 토큰을 처리하는 서버를 하나 두고,
다른 서비스를 제공하는 서버는 모두 stateless하게 만들자는 이론이 담긴 방식이다.
여러 개의 서버를 운용하는 경우,
토큰 기반 인증 기능과 특정 도메인을 함께 처리하는 서버가 있다면
그 도메인에 에러 발생 시 인증 기능까지 마비되고,
인증이 필요한 이외의 기능을 모두 사용할 수 없다는 문제가 생긴다.
토큰은 주로 JWT(JSON Web Token)이 사용된다.
토큰을 활용한 인증 프로세스는 아래와 같다.
- 인증 로직 -> JWT토크 생성 (access 토큰 & refresh 토큰)
- 이후 사용자가 access 토큰을 http header-authorizatino 또는 http header-Cookie에 담아 인증이 필요한 서버 요청 시 함께 보낸다.
JWT(JSON Web Token)
JWT란 헤더, 페이로드, 서명으로 이루어져 있으며, 객체로 인코딩되어 메시지 인증, 암호화에 사용된다.
- 헤더(Header): 토큰 유형, 서명 알고리즘이 base64URI로 인코딩 된다.
- 페이로드(Payload): 데이터, 토큰 발급자, 토큰 유효기간이 base64URI로 인코딩 된다.
- 서명(Signature): 인코딩된 header + 인코딩된 payload + 비밀 키를 기반으로 헤더에 명시된 알고리즘으로 다시 생성한 값이다.
장점
- 사용자 인증에 필요한 모든 정보는 토큰 자체에 포함하기 때문에 별도의 인증 저장소가 필요 없다.
- 다른 유형의 토큰과 비교하여 경량화되어있다. 예컨대, SAML(Security Assertion Markup Language Tokens)란 토큰에 비하면 훨씬 가볍다.
- 디코딩하면 JSON이 나오기 때문에 JSON을 기반으로 쉽게 직렬화, 역직렬화가 가능하다.
단점
- 토큰이 비대해질 경우 서버 과부하에 영향을 줄 수 있다. (흔치 않다.)
- 토큰을 탈취당할 경우 디코딩되면 데이터가 노출된다. (가장 큰 문제)
Access 토큰 & Refresh 토큰
토큰 기반 인증 방식을 구현할 때는 access 토큰과 refresh 토큰 두 가지를 기반으로 구현한다.
refresh 토큰이란 access 토큰이 만료되었을 때 다시 access 토큰을 얻기 위해 사용하는 토큰이다.
access 토큰이 만료될 때마다 인증하는 비용이 줄어든다.
두 토큰의 만료 기한을 다르게 설정해야 하며, access 토큰은 짧게, refresh 토큰은 길게 한다.
- 로그인 시 두 토큰을 동시에 얻게 된다.
- access 토큰이 만료되거나, 사용자가 새로고침을 할 때 refresh 토큰을 기반으로 새로운 access 토큰을 얻는다.
주의점
http header - authorization 또는 http header - Cookie에 토큰을 담아 요청할 때
아래의 규칙을 지키기를 권장한다.
- Bearer <token> 형식으로 Bearer 를 앞에 붙여 토큰 기반 인증 방식임을 알려주어야 한다.
- https를 사용해야 한다.
- 쿠키에 저장한다면 sameSite: 'Strict'를 지정해야 한다.
- access token의 만료 기한이 짧아야 한다.
- url에 토큰을 전달해서는 안된다.
아래는 토큰 기반 인증 방식의 node.js 예시 코드이다.
const ACCESS_TOKEN_SECRET = "01F173DF07BE5D18D098A287567DDC3B54833D5CE6D8CBFB4B257EC5775D1A95"
const REFRESH_TOKEN_SECRET = "53D95539D12BBBDA26EB54EB892EC5E9BA643BB8335BC4BC0D0C709C47447A01"
const PORT = 12010
const express = require('express')
//쿠키 핸들링을 쉽게 해주는 모듈
const cookieparser = require('cookie-parser')
const jwt = require('jsonwebtoken')
const cors = require('cors')
const app = express()
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({extended:false}))
app.use(cookieparser())
//mock data
const userInfo = {
username: 'walwal',
password: '1234',
email: 'walwal@test.com'
}
const user = {
username: userInfo.username,
email: userInfo.email
}
//access 토큰 만료 기한: 10분
const accessOpt = {
expiresIn: '10m'
}
//refresh 토큰 만료 기한: 하루
const refreshOpt = {
expiresIn: '1d'
}
const cookieOpt = {
httpOnly: true,
sameSite: 'Strict',
secure: true,
maxAge: 24 * 60 * 60 * 100
}
const isAuthenticated = (req, res, next) => {
if(!req.headers.authorization){
return next('route')
}
let auth = req.headers.authorization
if(auth.startsWith("Bearer")){
auth = auth.substring(7, auth.length)
}
const user = jwt.verify(auth, ACCESS_TOKEN_SECRET)
if(user) return next()
else return next('route')
}
app.get('/', isAuthenticated, function(req, res) {
return res.status(200).send("허용된 요청입니다.")
})
app.get('/', (req, res) => {
return res.status(401).send("허용되지 않은 요청입니다.")
})
app.post('/login', (req, res) => {
const {username, password} = req.body
console.log(req.body)
console.log(username, password)
if(username === userInfo.username && password === userInfo.password){
const accessToken = jwt.sign(user, ACCESS_TOKEN_SECRET, accessOpt)
const refreshToken = jwt.sign(user, REFRESH_TOKEN_SECRET, refreshOpt)
console.log("jwt토큰이 생성되었습니다.")
console.log(refreshToken)
console.log(accessToken)
res.cookie('jwt', refreshToken, cookieOpt)
return res.json({accessToken, refreshToken})
} else {
return res.status(401).json({message: "인증되지 않은 요청입니다."})
}
})
//access 토큰 요청 전에 refresh 토큰 요청 먼저
app.post('/refresh', (req, res) => {
console.log("REFRESH 요청")
console.log(req.cookies)
if(req.cookies.jwt){
const refreshToken = req.cookies.jwt
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET,
(err, decoded)=> {
if(err) {
return res.status(401).json({message: "인증되지 않은 요청입니다."})
} else {
console.log(decoded)
const accessToken = jwt.sign(user, ACCESS_TOKEN_SECRET, accessOpt)
return res.json({accessToken})
}
})
} else {
return res.status(401).json({message: "인증되지 않은 요청입니다."})
}
})
app.listen(PORT, ()=> {
console.log(`서버 시작: http://localhost:${PORT}`)
console.log(`로그인 요청: http://localhost:${PORT}/login`)
console.log(`refresh 요청: http://localhost:${PORT}/refresh`)
})
참고: inflearn 강의 'CS 지식의 정석 - 큰돌'