이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.
- Github에서 소스코드 보기: https://github.com/a-mean-blogger/login-api/tree/459a532d67226667ca82cfce9cdc213c33ac5123
REST API에서 로그인은 일반적인 로그인과 그 방식이 다릅니다.
일반적인 로그인은 서버의 session을 통해 이루어지는데, 서버는 접속중인 클라이언트들을 이 session을 통해 관리합니다. 사이트에 접속하면 session에 해당 클라이언트가 기록되고 로그인을 하게 되면 해당 클라이언트가 로그인한 것을 저장하게 됩니다. 이후 해당 클라이언트는 로그인이 요구되는 정보에 접근할 수 있게 됩니다. 이 방식은 접속자수가 늘어나면 서버의 메모리 사용량이 증가하게 되고 성능에 영향을 미치게 됩니다. (서버를 증설하고 session관리용 서버를 분리하는 등의 작업을 통해 이를 극복할 수 있습니다.)
REST API에서는 서버가 session을 가지지 않습니다. 물론 REST API서버에도 session을 추가하여 사용할 수 있지만 이는 REST가 지향하는 바가 아닙니다. 대신 REST API는 토큰(token) 인증방식을 사용하게 됩니다.
로그인 API로 아이디와 패스워드가 일치함이 확인되면 서버는 토큰을 발행하고, 로그인 후 이용가능한 API들에는 유효한 토큰이 있는 경우에만 사용할 수 있게 됩니다. 이때 토큰은 당연하게도 위조하기가 어려워야 하며 사용자를 인식할 수 있는 정보가 들어있어야 합니다. 이 포스팅에서는 JWT(JSON Web Token, http://jwt.io) package를 사용해서 토큰을 생성하고 확인하겠습니다.
로그인을 하려면 user data가 있어야 겠죠. 이번 포스팅에서는 1) Node JS API/기본 REST API 만들기와 Node JS 첫걸음/게시판 만들기의 user 부분을 섞어 user API를 만들고, 2) JWT를 이용하여 사용자 인증(authenticate)을 하는 auth API를 만들어 보겠습니다. 위 두 강좌의 코드를 모두 이해하고 있다는 가정하에 강의를 진행합니다.
크게 auth API와 user API로 나눌 수 있으며 각각 endpoint들은 다음과 같습니다.
자세한 스펙은 다음과 같습니다.
{ "username": "test1", "password": "Password1" }
{ "success": true, "message": null, "errors": null, "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScE" }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody: N/A
{ "success": true, "message": null, "errors": null, "data": { "_id": "598ddb6322ac1011e0702cb0", "username": "test1", "name": "test1", "email": "", "iat": 1504732677, "exp": 1504819077 } }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody: N/A
{ "success": true, "message": null, "errors": null, "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzQxNzUsImV4cCI6MTUwNDgyMDU3NX0.heYRtT1RZJYqgcJDaWwpKEmFUGwLn2r8OyYX-03_Nx4" }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody: N/A
{ "success": true, "message": null, "errors": null, "data": [ { "_id": "598ddb6322ac1011e0702cb0", "username": "test1", "name": "test1", "__v": 0, "email": "" }, { "_id": "59a748199e6e4e138033c687", "username": "test2", "name": "test2", "email": "", "__v": 0 } ] }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody: N/A
{ "success": true, "message": null, "errors": null, "data": { "_id": "598ddb6322ac1011e0702cb0", "username": "test1", "name": "test1", "__v": 0, "email": "" } }
{ "username": "test3", "password": "password1", "passwordConfirmation": "password1", "name": "test3", "email": "" }
{ "success": true, "message": null, "errors": null, "data": { "__v": 0, "username": "test3", "password": "$2a$10$.UAMVa/QcC.8ckk8sDEZXu1KFhNjINNDJZPx4o9tmaR1kmTmst3Be", "name": "test3", "email": "", "_id": "59b06d017479637420168a4c" } }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody:
{ "currentPassword": "Password1", "username": "test3", "newPassword": "Password1", "passwordConfirmation": "Password1", "name": "test5", "email": "[email protected]" }
{ "success": true, "message": null, "errors": null, "data": { "email": "[email protected]", "name": "test5", "username": "test5", "_id": "59b06d017479637420168a4c", "password": "$2a$10$oEWKzAadbQsxRCNWV4t2lutx8X2m9XtflMCHkD2jYbZEiiGJzVtsG" } }
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1OThkZGI2MzIyYWMxMDExZTA3MDJjYjAiLCJ1c2VybmFtZSI6InRlc3QxIiwibmFtZSI6InRlc3QxIiwiZW1haWwiOiIiLCJpYXQiOjE1MDQ3MzI2NzcsImV4cCI6MTUwNDgxOTA3N30.4eG2zGpSeY2XezKB4Djf6usy7DdygIybR1VKUBj-ScEBody: N/A
{ "success": true, "message": null, "errors": null, "data": { "_id": "59b06d017479637420168a4c", "username": "test5", "name": "test5", "email": "[email protected]", "__v": 0 } }
프로젝트 생성에 앞서, 아래의 환경변수들을 설정합니다
프로젝트 폴더를 생성합니다. 해당 폴더에서 command line(cmd, git bash 등)에 npm init을 입력하여 node.js 프로젝트를 생성합니다.
$ npm init
프로젝트에 필요한 package들도 설치해줍니다.
$ npm install express mongoose body-parser bcrypt-nodejs jsonwebtoken --save
Node JS API/기본 REST API 만들기와 비교해서 2개의 package가 추가되었습니다.
폴더 형태는 Node JS API/기본 REST API 만들기랑 비슷합니다. util.js는 Node JS 첫걸음/게시판 만들기에서와 마찬가지로 서버 전반에 사용되는 함수를 모아두는 곳입니다.
// server.js var express = require('express'); var app = express(); var path = require('path'); var mongoose = require('mongoose'); var bodyParser = require('body-parser'); // Database mongoose.Promise = global.Promise; mongoose.connect(process.env.MONGO_DB_LOGIN_API, {useMongoClient: true}); var db = mongoose.connection; db.once('open', function () { console.log('DB connected!'); }); db.on('error', function (err) { console.log('DB ERROR:', err); }); // Middlewares app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); app.use(function (req, res, next) { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); res.header('Access-Control-Allow-Headers', 'content-type, x-access-token'); //1 next(); }); // API app.use('/api/users', require('./api/users')); //2 app.use('/api/auth', require('./api/auth')); //2 // Server var port = 3000; app.listen(port, function(){ console.log('listening on port:' + port); });
server.js는 Node JS API/기본 REST API 만들기와 거의 동일합니다. 다른 부분만 살펴봅시다.
1. CORS에 x-access-token이 추가되었습니다. jwt로 생성된 토큰은 header의 x-access-token 항목을 통해 전달됩니다.
2. Route으로 users와 auth가 있습니다.
// models/user.js var mongoose = require('mongoose'); var bcrypt = require('bcrypt-nodejs'); // schema var userSchema = mongoose.Schema({ username:{ type:String, required:[true,'Username is required!'], match:[/^.{4,12}$/,'Should be 4-12 characters!'], trim:true, unique:true }, password:{ type:String, required:[true,'Password is required!'], select:false }, name:{ type:String, required:[true,'Name is required!'], match:[/^.{4,12}$/,'Should be 4-12 characters!'], trim:true, unique:true }, email:{ type:String, match:[/^[a-zA-Z0-9._%+-][email protected][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,'Should be a vaild email address!'], trim:true } },{ toObject:{virtuals:true} }); // virtuals userSchema.virtual('passwordConfirmation') .get(function(){ return this._passwordConfirmation; }) .set(function(value){ this._passwordConfirmation=value; }); userSchema.virtual('originalPassword') .get(function(){ return this._originalPassword; }) .set(function(value){ this._originalPassword=value; }); userSchema.virtual('currentPassword') .get(function(){ return this._currentPassword; }) .set(function(value){ this._currentPassword=value; }); userSchema.virtual('newPassword') .get(function(){ return this._newPassword; }) .set(function(value){ this._newPassword=value; }); // password validation var passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/; var passwordRegexErrorMessage = 'Should be minimum 8 characters of alphabet and number combination!'; userSchema.path('password').validate(function(v) { var user = this; // create user if(user.isNew){ if(!user.passwordConfirmation){ user.invalidate('passwordConfirmation', 'Password Confirmation is required!'); } if(!passwordRegex.test(user.password)){ user.invalidate('password', passwordRegexErrorMessage); } else if(user.password !== user.passwordConfirmation) { user.invalidate('passwordConfirmation', 'Password Confirmation does not matched!'); } } // update user if(!user.isNew){ if(!user.currentPassword){ user.invalidate('currentPassword', 'Current Password is required!'); } if(user.currentPassword && !bcrypt.compareSync(user.currentPassword, user.originalPassword)){ user.invalidate('currentPassword', 'Current Password is invalid!'); } if(user.newPassword && !passwordRegex.test(user.newPassword)){ user.invalidate('newPassword', passwordRegexErrorMessage); } else if(user.newPassword !== user.passwordConfirmation) { user.invalidate('passwordConfirmation', 'Password Confirmation does not matched!'); } } }); // hash password userSchema.pre('save', function (next){ var user = this; if(!user.isModified('password')){ return next(); } else { user.password = bcrypt.hashSync(user.password); return next(); } }); // model methods userSchema.methods.authenticate = function (password) { var user = this; return bcrypt.compareSync(password,user.password); }; // model & export var User = mongoose.model('user',userSchema); module.exports = User;
user model은 Node JS 첫걸음/게시판 만들기에서 그대로 가져왔습니다. 이전에 열심히 만든 보람이 있네요. 설명은 생략합니다.
다음으로 util.js를 살펴봅시다.
//util.js var jwt = require('jsonwebtoken'); var util = {}; util.successTrue = function(data){ //1 return { success:true, message:null, errors:null, data:data }; }; util.successFalse = function(err, message){ //2 if(!err&&!message) message = 'data not found'; return { success:false, message:message, errors:(err)? util.parseError(err): null, data:null }; }; util.parseError = function(errors){ //3 var parsed = {}; if(errors.name == 'ValidationError'){ for(var name in errors.errors){ var validationError = errors.errors[name]; parsed[name] = { message:validationError.message }; } } else if(errors.code == '11000' && errors.errmsg.indexOf('username') > 0) { parsed.username = { message:'This username already exists!' }; } else { parsed.unhandled = errors; } return parsed; }; // middlewares util.isLoggedin = function(req,res,next){ //4 var token = req.headers['x-access-token']; if (!token) return res.json(util.successFalse(null,'token is required!')); else { jwt.verify(token, process.env.JWT_SECRET, function(err, decoded) { if(err) return res.json(util.successFalse(err)); else{ req.decoded = decoded; next(); } }); } }; module.exports = util;
1. success json 을 만드는 함수입니다. API가 return하는 json의 형태를 통일시키기 위해 바로 함수를 통해 json 오브젝트를 만들고 이를 return하게 됩니다.
2. API가 성공하지 못한 경우 return하는 json의 형태를 통일시키기 위해 error 오브젝트나 message를 받아서 error json을 만드는 함수 입니다.
3. mongoose를 통해 resource를 조작하는 과정에서 발생하는 에러를 일정한 형태로 만들어 주는 함수입니다. resource조작중에 에러가 mongoose에서 오거나(validation 등) DB에서 올 수 있는데(DB 에러 등) 이때 에러 형태가 다르기 때문에 통일해 주는 함수입니다. Node JS 첫걸음/게시판 만들기의 util.js에 있는 함수와 동일합니다.
4. 미들웨어로 token이 있는지 없는지 확인하고 token이 있다면 jwt.verify
함수를 이용해서 토큰 hash를 확인하고 토큰에 들어있는 정보를 해독합니다. 해독한 정보는 req.decoded에 저장하고 있으며 이후 로그인 유무는 decoded가 있는지 없는지를 통해 알 수 있습니다. util.js를 먼저 설명하다보니 토큰 생성부분보다 토큰 인증부분을 먼저 설명하게 되었네요.
// api/user.js var express = require('express'); var router = express.Router(); var User = require('../models/User'); var util = require('../util'); // index router.get('/', util.isLoggedin, function(req,res,next){ User.find({}) .sort({username:1}) .exec(function(err,users){ res.json(err||!users? util.successFalse(err): util.successTrue(users)); }); }); // create router.post('/', function(req,res,next){ var newUser = new User(req.body); newUser.save(function(err,user){ res.json(err||!user? util.successFalse(err): util.successTrue(user)); }); }); // show router.get('/:username', util.isLoggedin, function(req,res,next){ User.findOne({username:req.params.username}) .exec(function(err,user){ res.json(err||!user? util.successFalse(err): util.successTrue(user)); }); }); // update router.put('/:username', util.isLoggedin, checkPermission, function(req,res,next){ User.findOne({username:req.params.username}) .select({password:1}) .exec(function(err,user){ if(err||!user) return res.json(util.successFalse(err)); // update user object user.originalPassword = user.password; user.password = req.body.newPassword? req.body.newPassword: user.password; for(var p in req.body){ user[p] = req.body[p]; } // save updated user user.save(function(err,user){ if(err||!user) return res.json(util.successFalse(err)); else { user.password = undefined; res.json(util.successTrue(user)); } }); }); }); // destroy router.delete('/:username', util.isLoggedin, checkPermission, function(req,res,next){ User.findOneAndRemove({username:req.params.username}) .exec(function(err,user){ res.json(err||!user? util.successFalse(err): util.successTrue(user)); }); }); module.exports = router; // private functions function checkPermission(req,res,next){ //* User.findOne({username:req.params.username}, function(err,user){ if(err||!user) return res.json(util.successFalse(err)); else if(!req.decoded || user._id != req.decoded._id) return res.json(util.successFalse(null,'You don\'t have permission')); else next(); }); }
user api도 Node JS 첫걸음/게시판 만들기의 user route에서 가져와 수정하였습니다. view를 찾아주는 route인 new와 edit을 제거하고 완벽한 CRUD를 만들기 위해 destroy를 추가한 형태입니다. 요청들을 처리하는 부분은 완전히 같고 html을 render하여 return하는 대신에 json을 리턴하게 되었습니다.
*checkPermission 부분의 로직은 token의 _id와 DB에서 찾은 user의 _id를 확인하는 것으로 바뀌었습니다.
마지막으로 오늘의 핵심인 auth api를 살펴봅시다.
// api/auth.js var express = require('express'); var router = express.Router(); var User = require('../models/User'); var util = require('../util'); var jwt = require('jsonwebtoken'); // login router.post('/login', function(req,res,next){ var isValid = true; var validationError = { name:'ValidationError', errors:{} }; if(!req.body.username){ isValid = false; validationError.errors.username = {message:'Username is required!'}; } if(!req.body.password){ isValid = false; validationError.errors.password = {message:'Password is required!'}; } if(!isValid) return res.json(util.successFalse(validationError)); else next(); }, function(req,res,next){ User.findOne({username:req.body.username}) .select({password:1,username:1,name:1,email:1}) .exec(function(err,user){ if(err) return res.json(util.successFalse(err)); else if(!user||!user.authenticate(req.body.password)) return res.json(util.successFalse(null,'Username or Password is invalid')); else { var payload = { _id : user._id, username: user.username }; var secretOrPrivateKey = process.env.JWT_SECRET; var options = {expiresIn: 60*60*24}; jwt.sign(payload, secretOrPrivateKey, options, function(err, token){ if(err) return res.json(util.successFalse(err)); res.json(util.successTrue(token)); }); } }); } ); // me router.get('/me', util.isLoggedin, function(req,res,next) { User.findById(req.decoded._id) .exec(function(err,user){ if(err||!user) return res.json(util.successFalse(err)); res.json(util.successTrue(user)); }); } ); // refresh router.get('/refresh', util.isLoggedin, function(req,res,next) { User.findById(req.decoded._id) .exec(function(err,user){ if(err||!user) return res.json(util.successFalse(err)); else { var payload = { _id : user._id, username: user.username }; var secretOrPrivateKey = process.env.JWT_SECRET; var options = {expiresIn: 60*60*24}; jwt.sign(payload, secretOrPrivateKey, options, function(err, token){ if(err) return res.json(util.successFalse(err)); res.json(util.successTrue(token)); }); } }); } ); module.exports = router;
로그인 역시 Node JS 첫걸음/게시판 만들기과 유사하나, 아이디와 비밀번호가 일치함을 확인한 후에 jwt.sign
함수를 통해 token을 생성하여 return하게 됩니다. jwt.sign은 payload, secretOrPrivateKey, options, Callback 함수의 4개의 파라메터를 전달받습니다.
me는 token을 해독해서 DB에서 user 정보를 return하는 API입니다.
refresh함수는 token의 유효기간이 끝나기전에 새로운 토큰을 발행하는 API입니다.
auth API 구조를 보면, RESTful하지 못한 것을 알 수 있습니다. 왜냐하면 token은 서버에 존재하는 resource가 아니기 때문입니다. 서버에서 생산은 되지만 서버에 저장되진 않죠. 그래서 token을 해독한다든지, token을 재생산한다든지 등의 행동은 아직 RESTful에서 우세한 의견이 없습니다.
nodemon을 사용해서 서버를 실행합시다
nodemon
Postman을 실행하고 우선 [GET]api/users API를 호출해 봅시다. user index는 로그인이 되어 있어야, 즉 토큰이 있어야만 실행할 수 있는 API입니다.
success가 false이고 token이 필요하다는 메세지를 볼 수 있습니다.
[POST]api/users API로 user를 생성해 봅시다.
success가 true인것으로 봐서 user가 성공적으로 생성된 것을 알 수 있습니다.
토큰을 얻기 위해 [POST]/api/auth/login API를 호출해봅시다.
[그림없음]
success가 true이고 data에 token문자열이 있습니다.
이제 이 token을 이용해서 다시 [GET]api/users를 요청해봅시다. token은 Headers 탭의 x-access-token에 넣어줍니다.
success가 true이고 data에 user list가 보입니다.
Front end 사이트는 로그인후 생성된 token를 저장하고 있다가 API에 넣어서 사용하게 됩니다. Front end 쪽 예제는 다른 포스팅에서 살펴보겠습니다.
댓글
이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기