目標
- 以RESTful API實作用戶註冊、登入、取得用戶資訊及驗證用戶身分
- 使用mongodb資料庫及mongoose產生Schema
- 使用passport進行中間件擴展,支援多種驗證方式,如OAuth、本地驗證...等
- 以jwt進行token的產生及識別
- 使用bcrypt對用戶密碼加解密
2016/9/24 新增
- user權限(role)
- 編輯user
- 加入刪除user的權限
- 加入swagger ui
Installation
Package Dependence
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "user_authentication_api", | |
"description": "api server", | |
"node-main": "./run.js", | |
"dependencies": { | |
"bcrypt-nodejs": "^0.0.3", | |
"body-parser": "^1.15.2", | |
"express": "^4.14.0", | |
"jwt-simple": "^0.5.0", | |
"morgan": "^1.7.0", | |
"moment": "^2.14.1", | |
"mongoose": "^4.6.1", | |
"passport": "^0.3.2", | |
"passport-jwt": "^2.1.0" | |
}, | |
"devDependencies": { | |
"mocha": "^3.0.2", | |
"nodemon": "^1.9.1", | |
"supertest": "^2.0.0", | |
"should": "^11.1.0" | |
}, | |
"scripts": { | |
"dev": "node dev.js", | |
"test": "./node_modules/.bin/mocha test", | |
"product": "node run.js" | |
}, | |
"author": "will.chen", | |
"license": "ISC" | |
} |
- PassportJS - for user password security
- Simple JWT - token use
- Mongoose - mongodb object modeling
目錄結構
NodeJS_User_Authentication
|-config
|-database.js - token密鑰及db連線訊息
|-initial.js - 初始化配置(role permissions)
|-middleware
|-jwtauth.js - 以passport-jwt建構本地jwt驗證策略
|-models
|- role.js - 創建role schema
|- users.js - 創建user schema並提供儲存用戶、密碼加密、密碼比對方法
|-node_modules - package dependences
|-routes
|- initial.js - 初始化role permissions
|- users.js - 用戶路由,包括登入、註冊取得用戶資訊
|-services
|- error
|-builder.js - 錯誤訊息、狀態碼定義
|- permissions
|- validator.js - 驗證user操作權限的相關邏輯
|- test
|- routes
|- users.js
|- all.test.js 測試入口
package.json
readme.md
run.js
Step By Step
config/database.js,配置db連線字串及token密鑰,建議使用環境變數避免hard code於程式碼內
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module.exports = { | |
'secret': process.env.SECRET_KEY || 'user_auth_demo', | |
'database': process.env.MONGO_CONNECTION || 'mongodb://username:password@localhost:27017/user_auth_demo' | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let adminRoleLevel = Number.MAX_SAFE_INTEGER; | |
let userRoleLevel = 0; | |
module.exports = { | |
'admin_account': process.env.ADMIN_ACCOUNT || 'superadmin', | |
'admin_password': process.env.ADMIN_PASSWORD || 'superadmin', | |
'admin_role_level': adminRoleLevel, | |
'roles': [ | |
{ | |
'role': 'admin', | |
'level': adminRoleLevel | |
}, | |
{ | |
'role': 'user', | |
'level': userRoleLevel | |
} | |
], | |
'user_role_level': userRoleLevel | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let mongoose = require('mongoose'); | |
let Schema = mongoose.Schema; | |
let bcrypt = require('bcrypt-nodejs'); | |
// set up a mongoose model | |
let UserSchema = new Schema({ | |
username: { | |
type: String, | |
unique: true, | |
required: true | |
}, | |
displayName: { | |
type: String, | |
unique: true, | |
required: true | |
}, | |
password: { | |
type: String, | |
required: true | |
}, | |
roleId: { | |
type: String, | |
required: true | |
} | |
}); | |
UserSchema.pre('save', function(next) { | |
let user = this; | |
//密碼變更或新密碼時 | |
if (user.isModified('password') || this.isNew) { | |
bcrypt.genSalt(10, function(err, salt) { | |
if (err) { | |
return next(err); | |
} | |
bcrypt.hash(user.password, salt, null, function(err, hash) { | |
if (err) { | |
return next(err); | |
} | |
//使用hash取代明文密碼 | |
user.password = hash; | |
next(); | |
}); | |
}); | |
} else { | |
return next(); | |
} | |
}); | |
/** | |
* mongoose支持擴展方法,因此撰寫密碼驗證 | |
* @param {[string]} password [密碼] | |
* @param {Function} callback [description] | |
* @return {[type]} [description] | |
*/ | |
UserSchema.methods.comparePassword = function(candidatePassword, callback) { | |
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => { | |
if (err) { | |
return callback(err); | |
} | |
callback(null, isMatch); | |
}); | |
}; | |
module.exports = mongoose.model('User', UserSchema); | |
}); | |
} else { | |
return next(); | |
} | |
}); | |
/** | |
* mongoose支持擴展方法,因此撰寫密碼驗證 | |
* @param {[string]} password [密碼] | |
* @param {Function} callback [description] | |
* @return {[type]} [description] | |
*/ | |
UserSchema.methods.comparePassword = function(candidatePassword, callback) { | |
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => { | |
if (err) { | |
return callback(err); | |
} | |
callback(null, isMatch); | |
}); | |
}; | |
module.exports = mongoose.model('User', UserSchema); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let mongoose = require('mongoose'); | |
let Schema = mongoose.Schema; | |
let RoleSchema = new Schema({ | |
role: { | |
type: String, | |
unique: true, | |
required: true | |
}, | |
level: { | |
type: Number, | |
unique: true, | |
required: true | |
} | |
}); | |
module.exports = mongoose.model('Role', RoleSchema); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let passport = require("passport"); | |
let passportJWT = require("passport-jwt"); | |
let User = require(__base + 'models/user'); | |
let config = require(__base + 'config/database'); | |
let errorBuilder = require(__base + 'services/error/builder'); | |
let ExtractJwt = passportJWT.ExtractJwt;//extract jwt token | |
let Strategy = passportJWT.Strategy;//策略選擇為jwt | |
let params = { | |
secretOrKey: config.secret, | |
jwtFromRequest: ExtractJwt.fromAuthHeader() //creates a new extractor that looks for the JWT in the authorization header with the scheme 'JWT',e.g JWT + 'token' | |
}; | |
module.exports = function() { | |
let strategy = new Strategy(params, function(payload, done) { | |
//驗證token是否失效 | |
if (payload.exp <= Date.now()) { | |
return done(errorBuilder.unauthorized('Access token has expired'), false); | |
} | |
//根據解析後id取得user,並驗證user是否存在 | |
User.findOne({ _id: payload.iss }, function(err, user) { | |
if (err) return done(err, false); | |
if (user) done(null, user); | |
else done(null, false); | |
}); | |
}); | |
passport.use(strategy); | |
return { | |
initialize: function() { | |
return passport.initialize(); | |
}, | |
authenticate: function() { | |
return passport.authenticate("jwt", { session: false }); | |
} | |
}; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict'; | |
let initial_config = require(__base + 'config/initial'); // get initial config file | |
let errorBuilder = require(__base + 'services/error/builder'); | |
// get the mongoose model | |
let User = require(__base + 'models/user'); | |
let Role = require(__base + 'models/role'); | |
exports.initialize = (req, res, next) => { | |
let errorHandler = (error) => { | |
next(error); | |
} | |
setRoles().then(() => { | |
setAdminUser().then(() => { | |
res.json({ | |
success: true, | |
message: 'Successful initialize.' | |
}) | |
}, errorHandler) | |
}, errorHandler) | |
} | |
//private methods | |
function setAdminUser() { | |
let deferred = Promise.defer(); | |
let dbErrorHandler = (error) => { | |
deferred.reject(errorBuilder.badRequest(err.errmsg)); | |
} | |
Role.findOne({ $query: {}, $orderby: { level: -1 } }).exec().then(role => { | |
let adminUser = new User({ | |
displayName: initial_config.admin_account, | |
username: initial_config.admin_account, | |
password: initial_config.admin_password, | |
roleId: role._id | |
}) | |
adminUser.save().then(() => { | |
deferred.resolve(); | |
}).catch(dbErrorHandler); | |
}).catch(dbErrorHandler); | |
return deferred.promise; | |
} | |
function setRoles() { | |
let promises = []; | |
let result = Promise.defer(); | |
let roles = initial_config.roles; | |
roles.forEach(role => { | |
let deferred = Promise.defer(); | |
let newRole = new Role(role); | |
newRole.save(error => { | |
if (error) deferred.reject(errorBuilder.badRequest(error.errmsg)); | |
else deferred.resolve(); | |
}); | |
promises.push(deferred.promise); | |
}) | |
Promise.all(promises).then(() => { | |
result.resolve(); | |
}, error => { | |
result.reject(error); | |
}) | |
return result.promise; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict'; | |
let config = require(__base + 'config/database'); // get db config file | |
let errorBuilder = require(__base + 'services/error/builder'); | |
let User = require(__base + 'models/user.js'); // get the mongoose model | |
let jwt = require('jwt-simple'); | |
let moment = require('moment'); | |
exports.login = (req, res, next) => { | |
User.findOne({ | |
username: req.body.username | |
}, (error, user) => { | |
if (error) next(errorBuilder.badRequest(error)); | |
if (!user) next(errorBuilder.badRequest('User not found.')); | |
else { | |
user.comparePassword(req.body.password, (error, isMatch) => { //使用user schema中定義的comparePassword檢查請求密碼是否正確 | |
if (isMatch && !error) { | |
let expires = moment().add(1, 'day').valueOf(); | |
let token = jwt.encode({ | |
iss: user.id, //加密對象 | |
exp: expires | |
}, config.secret); | |
res.json({ success: true, token: 'JWT ' + token }); //JWT for passport-jwt extract fromAuthHeader | |
} else { | |
next(errorBuilder.badRequest('Wrong password.')); | |
} | |
}) | |
} | |
}) | |
} | |
exports.me = (req, res, next) => { //get users/me之前經過中間件驗證用戶權限,當驗證通過便取得正確用戶訊息,直接回傳即可 | |
let responseBody = { | |
username: req.user.username, | |
displayName: req.user.displayName | |
} | |
res.send(responseBody); | |
} | |
exports.signup = (req, res, next) => { | |
let requireProperties = ['displayName', 'password', 'username']; | |
let propertyMissingMsg = ''; | |
let requireValid = requireProperties.every(property => { | |
if (!req.body.hasOwnProperty(property)) { | |
propertyMissingMsg = 'Please pass ' + property; | |
return false; | |
} | |
return true; | |
}) | |
if (!requireValid) { | |
next(errorBuilder.badRequest(propertyMissingMsg)); | |
return; | |
} | |
if (!req.body.username || !req.body.password) next(errorBuilder.badRequest('Please pass username and password')) | |
else { | |
let newUser = new User({ | |
username: req.body.username, | |
displayName: req.body.displayName, | |
password: req.body.password | |
}) | |
User.findOne({ username: newUser.username }, (error, user) => { | |
if (error) next(errorBuilder.internalServerError()); | |
else if (user) { | |
next(errorBuilder.badRequest('username already exist.')); | |
} else { | |
newUser.save((error) => { | |
if (error) next(errorBuilder.internalServerError()); | |
else res.json({ success: true, message: 'Successful signup.' }); | |
}) | |
} | |
}) | |
} | |
} |
- 登入 - 檢查用戶是否存在、解密並檢查密碼正確性、產生jwt token
- 註冊 - 檢查request body 是否包含username、password、displayName,檢查username是否重複
- 取得用戶資訊- 中間件jwtauth.js通過驗證時設定req.user,因此直接回傳req.user
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
global.__base = __dirname + '/'; | |
const express = require('express'); | |
const bodyParser = require('body-parser'); | |
const app = express(); | |
//the routing modules | |
const users = require(__base + 'routes/users'); | |
const initial = require(__base + 'routes/initial'); | |
app.set('port', process.env.PORT || 3000); | |
let config = require(__base + 'config/database'); // get db config file | |
let morgan = require('morgan'); | |
let mongoose = require('mongoose'); | |
let jwtauth = require(__base + 'middleware/jwtauth')(); | |
app.use(bodyParser.urlencoded({ extended: false })); | |
app.use(bodyParser.json()); | |
// log to console | |
app.use(morgan('dev')); | |
app.use(jwtauth.initialize()); | |
mongoose.Promise = global.Promise; | |
mongoose.connect(config.database); | |
let apiRoutes = express.Router(); | |
apiRoutes.route('/initialize') | |
.post(initial.initialize) | |
apiRoutes.route('/users') | |
.post(users.signup) | |
apiRoutes.route('/users/login') | |
.post(users.login) | |
apiRoutes.use(jwtauth.authenticate()).route('/users/me') | |
.get(users.me) | |
apiRoutes.use(jwtauth.authenticate()).route('/users/:id') | |
.delete(users.delete) | |
.get(users.info) | |
.put(users.edit) | |
app.use('/api', apiRoutes); | |
app.use(errorHandler); | |
app.listen(app.get('port'), () => { | |
console.log('Express server listening on port ' + app.get('port')); | |
}); | |
function errorHandler(err, req, res, next) { | |
res.status(err.status || 500).json(err); | |
} | |
module.exports = app; |
使用postman工具進行API驗證
* 註冊用戶
* 登入 - 並取得token
* 取得用戶資訊- 未帶入token
* 取得用戶資訊 - 帶入token
留言
張貼留言