이 글은 Token-Based Authentication With AngularJS & NodeJS를 번역한 글입니다.
전통적인 인증 시스템
토큰 기반 인증 시스템을 설명하기 전에, 먼저 전통적인 인증 시스템을 살펴보자.
여기까지는 문제가 없다. 웹 어플리케이션은 잘 동작하고, 사용자 인증을 거쳐서 특정 endpoint로의 접근을 제한할 수 있다. 하지만 안드로이드와 같은 다른 클라이언트에서 동작하는 어플리케이션을 만들고 싶다면 어떨까? 전통적인 인증 방식으로 동작하는 어플리캐이션을 모바일 클라이언트에서도 동일하게 사용할 수 있을까? 그렇지 않다. 그 이유는 다음과 같다.
이러한 경우 클라이언트 독립적인 어플리캐이션이 필요하다.
토큰 기반 인증 시스템
토큰 기반 인증에서는 쿠키과 세션은 사용되지 않는다. 토큰은 서버로의 모든 요청에 대해 사용자를 인증하기 위해 사용된다. 위의 시나리오를 토큰 기반 인증 시스템으로 다시 설계해보자.
그렇다면 JWT가 무엇일까?
JWT
JWT는 JSON Web Token의 약자이며 인증 헤더 내에서 사용되는 토큰 포맷이다. 이 토큰은 두 개의 시스템끼리 안전한 방법으로 통신할 수 있도록 설계하는 것을 도와준다. 이 튜토리얼에서는 JWT를 "Bearer 토큰"으로 부르도록 하겠다. Bearer 토큰은 3가지 요소로 구성된다 : Header, Payload, Signature.
JTW의 장점은 계정 서버와 API 서버가 분리되어 있을 때, API 서버가 계정 서버에게 토큰의 유효성 여부를 물어보지 않고도 스스로 판단할 수 있다는 것이다.
JWT 스키마와 토큰 예제는 아래와 같다.
몇가지 언어에서 이미 구현되어 있기 때문에 Bearer 토큰을 생성하는 코드를 직접 구현할 필요는 없다.
Language | Livbary URL |
NodeJS | |
PHP | |
Java | |
Ruby | |
.NET | http://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet |
Python |
연습 예제
토큰 기반 인증에 대해 몇가지 기본 정보를 다뤘기 때문에 이제 연습 예제를 진행해보자. 아래 스키마를 잘 봐두길 바란다.
장점
토큰 기반 인증은 심각한 문제들을 해결해주는 몇가지 이점을 갖는다. 그 중 일부는 아래와 같다.
이것들이 토큰 기반 인증의 가장 일반적인 장점이다. 이로써 이론적인 설명을 마치고 연습 예제를 보도록 하자.
예제 어플리케이션
토킨 기반 인증을 시연하기 위해 2개의 어플리캐이션을 살펴보자.
백엔드 프로젝트 예제에서는 서비스가 실행되고 서비스의 결과가 JSON 포맷으로 반환될 것이다. 서비스에서는 결코 view가 리턴되지 않는다. 프론트엔드 예제는 HTML을 위한 AngularJS 프로젝트이며, 프론트엔드 앱은 백엔드 서비스로 요청을 보내는 AngularJS 서비스에 의해 작성될 것이다.
토큰 기반 인증 백엔드
./models/User.js
// mongoose를 사용하기 위해 해당 모듈을 import
var mongoose = require('mongoose');
// 스키마 정의
// email, password, token 필드를 가지며 각각의 필드는 string 타입이다.
var Schema = mongoose.Schema;
var UserSchema = new Schema({
email: String,
password: String,
token: String
});
// 스키마를 이용해서 모델을 정의
// 'User' : mongodb에 저장될 collection이름(테이블명)
// UserSchema : 모델을 정의하는데 사용할 스키마
module.exports = mongoose.model('User', UserSchema);
./server.js
mongoose.connect(process.env.MONGO_URL); //mongodb://localhost/dbname
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(morgan("dev")); // 모든 요청을 console에 기록
app.use(methodOverride()); // DELETE, PUT method 사용
app.use(function(req, res, next) {
//모든 도메인의 요청을 허용하지 않으면 웹브라우저에서 CORS 에러를 발생시킨다.
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type, Authorization');
next();
});
// 로그인
// 다른 endpoint에 접근할 수 있는 토큰을 얻는다.
app.post('/authenticate', function(req, res) {
User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
if (user) {
res.json({
type: true,
data: user,
token: user.token
});
} else {
res.json({
type: false,
data: "Incorrect email/password"
});
}
}
});
});
// 신규가입
// 계정과 토큰을 생성한다.
app.post('/signin', function(req, res) {
User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
if (user) {
res.json({
type: false,
data: "User already exists!"
});
} else {
var userModel = new User();
userModel.email = req.body.email;
userModel.password = req.body.password;
userModel.save(function(err, user) { // DB 저장 완료되면 콜백 함수 호출
user.token = jwt.sign(user, process.env.JWT_SECRET); // user 정보로부터 토큰 생성
user.save(function(err, user1) {
res.json({
type: true,
data: user1,
token: user1.token
});
});
})
}
}
});
});
// 나의 정보
// 토큰 검사 후 계정 정보 반환
// 토큰 추출하기 위해 ensureAuthorized 먼저 실행
app.get('/me', ensureAuthorized, function(req, res) {
User.findOne({token: req.token}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
res.json({
type: true,
data: user
});
}
});
});
// 요청 헤더 내의 authorization 헤더에서 토큰 추출
// 토큰이 존재하면, 토큰을 req.token에 할당
function ensureAuthorized(req, res, next) {
var bearerToken;
var bearerHeader = req.headers["authorization"];
if (typeof bearerHeader !== 'undefined') {
var bearer = bearerHeader.split(" ");
bearerToken = bearer[1];
req.token = bearerToken;
next(); // 다음 콜백함수 진행
} else {
res.send(403);
}
}
process.on('uncaughtException', function(err) {
console.log(err);
});
// Start Server
app.listen(port, function () {
console.log( "Express server listening on port " + port);
});
결론
클라이언트 독립적인 서비스를 구현할 때 토큰 기반 인증/인가 방식은 인증 시스템을 구축하는데 많은 도움을 준다. 이 기술을 사용함으로써 서비스(또는 API) 개발에만 집중할 수 있다. 인증/인가 부분은 토큰 기반 인증 시스템에 의해 서비스의 앞단에서 하나의 '레이어'로써 핸들링될 것이다. 웹 브라우저, 안드로이드, iOS, 데스크탑 클라이언트 등 어떤 클라이언트를 통해서도 서비스에 접근하고 서비스를 사용할 수 있다.
데모 사이트 : http://token-based-auth.herokuapp.com/
개선사항
1. 비밀번호가 해쉬 암호화되어 저장되어야하고 로그인시 해쉬값을 전달받아 비교해야 한다.
// bcrypt 사용
UserScheme.pre('save', function (callback) {
// salt와 암호화된 비밀번호 생성
var user = this;
if (!user.isModified('password')) return next();
bcrypt.genSalt(SALT_WORK_FACTOR, function(err, salt) {
if (err) return next(err);
bcrypt.hash(user.password, salt, function(err, hash) {
if (err) return next(err);
user.password = hash;
next();
});
});
}
// 비밀번호 검증
UserSchema.methods.verifyPassword = function(password, cb) {
bcrypt.compare(password, this.password, function(err, isMatch) {
if (err) return cb(err);
cb(isMatch);
});
};
// 로그인
app.post('/authenticate', function(req, res) {
...
user.comparePassword(password, function(isMatch) {
if (!isMatch) {
console.log("Attempt failed to login with " + user.username);
return res.send(401);
}
...
});
...
}
2. 토큰의 유효성 검사가 수행되어야 하며, 토큰이 만료되어야 한다.
function ensureAuthorized(req, res, next) {
...
jwt.verify(bearerToken, process.env.JWT_SECRET)
...
}
3. 위의 개선사항들이 반영된 소스들은 다음 사이트들을 참고하길 바란다.
참고자료