├── logs └── .gitkeep ├── docker.config.json ├── Dockerfile ├── .dockerignore ├── .gitignore ├── libs ├── routes │ ├── oauth.js │ ├── api.js │ ├── users.js │ └── articles.js ├── config.js ├── db │ └── mongoose.js ├── model │ ├── client.js │ ├── accessToken.js │ ├── refreshToken.js │ ├── article.js │ └── user.js ├── log.js ├── app.js └── auth │ ├── auth.js │ └── oauth2.js ├── bin └── www ├── config.json ├── docker-compose.yml ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── package.json ├── generateData.js ├── README.md └── test └── server.test.js /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongoose": { 3 | "uri": "mongodb://mongo/apiDB" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6.10.3 2 | 3 | LABEL maintainer="Yuttasak Pannawat " 4 | 5 | RUN mkdir -p /app 6 | ADD package.json /app 7 | WORKDIR /app 8 | RUN npm install --verbose 9 | ENV NODE_PATH=/app/node_modules 10 | 11 | COPY . /app/ 12 | 13 | CMD node /app/bin/www 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # OS noise 2 | .DS_Store 3 | ._* 4 | *~ 5 | 6 | # Other CSM 7 | .hg 8 | .svn 9 | CVS 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # npm 23 | node_modules/ 24 | 25 | # App data 26 | /data/ 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS noise 2 | .DS_Store 3 | ._* 4 | *~ 5 | 6 | # Other CSM 7 | .hg 8 | .svn 9 | CVS 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # npm 23 | node_modules/ 24 | 25 | # App data 26 | /data/ 27 | -------------------------------------------------------------------------------- /libs/routes/oauth.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | 3 | var libs = process.cwd() + '/libs/'; 4 | 5 | var oauth2 = require(libs + 'auth/oauth2'); 6 | var log = require(libs + 'log')(module); 7 | var router = express.Router(); 8 | 9 | router.post('/token', oauth2.token); 10 | 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /libs/routes/api.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var passport = require('passport'); 3 | var router = express.Router(); 4 | 5 | router.get('/', passport.authenticate('bearer', { session: false }), function (req, res) { 6 | res.json({ 7 | msg: 'API is running' 8 | }); 9 | }); 10 | 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /libs/config.js: -------------------------------------------------------------------------------- 1 | var nconf = require('nconf'); 2 | 3 | nconf.argv().env(); 4 | 5 | if (process.env.ENV_IN === 'docker') { 6 | nconf.file('docker', { 7 | file: process.cwd() + '/docker.config.json' 8 | }); 9 | } 10 | 11 | nconf.file('defaults', { 12 | file: process.cwd() + '/config.json' 13 | }); 14 | 15 | module.exports = nconf; 16 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var libs = process.cwd() + '/libs/'; 4 | var config = require(libs + 'config'); 5 | var log = require(libs + 'log')(module); 6 | var app = require(libs + 'app'); 7 | 8 | app.set('port', process.env.PORT || config.get('port') || 3000); 9 | 10 | var server = app.listen(app.get('port'), function () { 11 | log.info('Express server listening on port ' + app.get('port')); 12 | }); 13 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 1337, 3 | "security": { 4 | "tokenLife" : 3600 5 | }, 6 | "mongoose": { 7 | "uri": "mongodb://localhost/apiDB" 8 | }, 9 | "default": { 10 | "user": { 11 | "username": "myapi", 12 | "password": "abc1234" 13 | }, 14 | "client": { 15 | "name": "Android API v1", 16 | "clientId": "android", 17 | "clientSecret": "SomeRandomCharsAndNumbers" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /libs/db/mongoose.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | var libs = process.cwd() + '/libs/'; 4 | 5 | var log = require(libs + 'log')(module); 6 | var config = require(libs + 'config'); 7 | 8 | mongoose.connect(config.get('mongoose:uri')); 9 | 10 | var db = mongoose.connection; 11 | 12 | db.on('error', function (err) { 13 | log.error('Connection error:', err.message); 14 | }); 15 | 16 | db.once('open', function callback() { 17 | log.info('Connected to DB!'); 18 | }); 19 | 20 | module.exports = mongoose; 21 | -------------------------------------------------------------------------------- /libs/model/client.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | Schema = mongoose.Schema, 3 | 4 | Client = new Schema({ 5 | name: { 6 | type: String, 7 | unique: true, 8 | required: true 9 | }, 10 | clientId: { 11 | type: String, 12 | unique: true, 13 | required: true 14 | }, 15 | clientSecret: { 16 | type: String, 17 | required: true 18 | } 19 | }); 20 | 21 | module.exports = mongoose.model('Client', Client); 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | node_api: 6 | build: . 7 | environment: 8 | - PORT=1337 9 | - ENV_IN=docker 10 | ports: 11 | - "1337:1337" 12 | - "3000:3000" 13 | volumes: 14 | - ./logs:/app/logs 15 | networks: 16 | - db 17 | restart: always 18 | 19 | mongo: 20 | image: mongo:3.4.4 21 | volumes: 22 | - ./data/mongo:/data/db 23 | ports: 24 | - "27017:27017" 25 | networks: 26 | - db 27 | restart: always 28 | 29 | networks: 30 | db: 31 | driver: bridge 32 | -------------------------------------------------------------------------------- /libs/model/accessToken.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | var Schema = mongoose.Schema; 3 | 4 | var AccessToken = new Schema({ 5 | userId: { 6 | type: String, 7 | required: true 8 | }, 9 | 10 | clientId: { 11 | type: String, 12 | required: true 13 | }, 14 | 15 | token: { 16 | type: String, 17 | unique: true, 18 | required: true 19 | }, 20 | 21 | created: { 22 | type: Date, 23 | default: Date.now 24 | } 25 | }); 26 | 27 | module.exports = mongoose.model('AccessToken', AccessToken); 28 | -------------------------------------------------------------------------------- /libs/model/refreshToken.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | Schema = mongoose.Schema, 3 | 4 | RefreshToken = new Schema({ 5 | userId: { 6 | type: String, 7 | required: true 8 | }, 9 | clientId: { 10 | type: String, 11 | required: true 12 | }, 13 | token: { 14 | type: String, 15 | unique: true, 16 | required: true 17 | }, 18 | created: { 19 | type: Date, 20 | default: Date.now 21 | } 22 | }); 23 | 24 | module.exports = mongoose.model('RefreshToken', RefreshToken); 25 | -------------------------------------------------------------------------------- /libs/model/article.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | var Schema = mongoose.Schema; 3 | 4 | var Image = new Schema({ 5 | kind: { 6 | type: String, 7 | enum: ['thumbnail', 'detail'], 8 | required: true 9 | }, 10 | url: { type: String, required: true } 11 | }); 12 | 13 | var Article = new Schema({ 14 | title: { type: String, required: true }, 15 | author: { type: String, required: true }, 16 | description: { type: String, required: true }, 17 | images: [Image], 18 | modified: { type: Date, default: Date.now } 19 | }); 20 | 21 | Article.path('title').validate(function (v) { 22 | return v.length > 5 && v.length < 70; 23 | }); 24 | 25 | module.exports = mongoose.model('Article', Article); 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | services: 14 | mongodb: 15 | image: mongo 16 | ports: 17 | - 27017:27017 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: '16' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Start server 31 | run: npm start & 32 | 33 | - name: Generate demo data 34 | run: npm run-script generate 35 | 36 | - name: Run tests 37 | run: npm test 38 | -------------------------------------------------------------------------------- /libs/routes/users.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var passport = require('passport'); 3 | var router = express.Router(); 4 | 5 | var libs = process.cwd() + '/libs/'; 6 | 7 | var db = require(libs + 'db/mongoose'); 8 | 9 | router.get('/info', passport.authenticate('bearer', { session: false }), 10 | function (req, res) { 11 | // req.authInfo is set using the `info` argument supplied by 12 | // `BearerStrategy`. It is typically used to indicate scope of the token, 13 | // and used in access control checks. For illustrative purposes, this 14 | // example simply returns the scope in the response. 15 | res.json({ 16 | user_id: req.user.userId, 17 | name: req.user.username, 18 | scope: req.authInfo.scope 19 | }); 20 | } 21 | ); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-2018 Evgeny Aleksandrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /libs/log.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | 3 | function logger(module) { 4 | 5 | return new winston.createLogger({ 6 | transports: [ 7 | new winston.transports.File({ 8 | level: 'info', 9 | filename: process.cwd() + '/logs/all.log', 10 | handleExceptions: true, 11 | format: winston.format.json(), 12 | maxSize: 5242880, //5mb 13 | maxFiles: 2 14 | }), 15 | new winston.transports.Console({ 16 | level: 'debug', 17 | defaultMeta: { service: 'your-service-name' }, 18 | handleExceptions: true, 19 | format: winston.format.combine( 20 | winston.format.splat(), 21 | winston.format.label({ label: getFilePath(module) }), 22 | winston.format.colorize(), 23 | winston.format.printf(nfo => { 24 | return `${nfo.level}: [${nfo.label}] ${nfo.message}`; 25 | }) 26 | ) 27 | }) 28 | ], 29 | exitOnError: false 30 | }); 31 | } 32 | 33 | function getFilePath(module) { 34 | // Add filename in log statements 35 | return module.filename.split('/').slice(-2).join('/'); 36 | } 37 | 38 | module.exports = logger; 39 | -------------------------------------------------------------------------------- /libs/model/user.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | crypto = require('crypto'), 3 | 4 | Schema = mongoose.Schema, 5 | 6 | User = new Schema({ 7 | username: { 8 | type: String, 9 | unique: true, 10 | required: true 11 | }, 12 | hashedPassword: { 13 | type: String, 14 | required: true 15 | }, 16 | salt: { 17 | type: String, 18 | required: true 19 | }, 20 | created: { 21 | type: Date, 22 | default: Date.now 23 | } 24 | }); 25 | 26 | User.methods.encryptPassword = function (password) { 27 | return crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex'); 28 | }; 29 | 30 | User.virtual('userId') 31 | .get(function () { 32 | return this.id; 33 | }); 34 | 35 | User.virtual('password') 36 | .set(function (password) { 37 | this._plainPassword = password; 38 | this.salt = crypto.randomBytes(128).toString('hex'); 39 | this.hashedPassword = this.encryptPassword(password); 40 | }) 41 | .get(function () { return this._plainPassword; }); 42 | 43 | 44 | User.methods.checkPassword = function (password) { 45 | return this.encryptPassword(password) === this.hashedPassword; 46 | }; 47 | 48 | module.exports = mongoose.model('User', User); 49 | -------------------------------------------------------------------------------- /libs/app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var bodyParser = require('body-parser'); 4 | var passport = require('passport'); 5 | 6 | var libs = process.cwd() + '/libs/'; 7 | require(libs + 'auth/auth'); 8 | 9 | var config = require('./config'); 10 | var log = require('./log')(module); 11 | var oauth2 = require('./auth/oauth2'); 12 | 13 | var api = require('./routes/api'); 14 | var users = require('./routes/users'); 15 | var articles = require('./routes/articles'); 16 | 17 | var app = express(); 18 | 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: false })); 21 | app.use(passport.initialize()); 22 | 23 | app.use('/', api); 24 | app.use('/api', api); 25 | app.use('/api/users', users); 26 | app.use('/api/articles', articles); 27 | app.use('/api/oauth/token', oauth2.token); 28 | 29 | // Catch 404 and forward to error handler 30 | app.use(function (req, res, next) { 31 | res.status(404); 32 | log.debug('%s %d %s', req.method, res.statusCode, req.url); 33 | res.json({ 34 | error: 'Not found' 35 | }); 36 | return; 37 | }); 38 | 39 | // Error handlers 40 | app.use(function (err, req, res, next) { 41 | res.status(err.status || 500); 42 | log.error('%s %d %s', req.method, res.statusCode, err.message); 43 | res.json({ 44 | error: err.message 45 | }); 46 | return; 47 | }); 48 | 49 | module.exports = app; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NodeAPI", 3 | "version": "1.0.0", 4 | "author": "Evgeny Aleksandrov", 5 | "description": "Simple example of RESTful API with Node.js and passport", 6 | "private": true, 7 | "scripts": { 8 | "start": "node ./bin/www", 9 | "test": "node ./test/server.test.js", 10 | "generate": "node ./generateData.js" 11 | }, 12 | "dependencies": { 13 | "body-parser": "^1.20.x", 14 | "express": "^4.21.x", 15 | "faker": "^5.1.0", 16 | "mongoose": "^6.13.x", 17 | "nconf": "^0.11.4", 18 | "oauth2orize": "^1.11.x", 19 | "passport": "^0.6.x", 20 | "passport-http": "^0.3.x", 21 | "passport-http-bearer": "^1.0.x", 22 | "passport-oauth2-client-password": "^0.1.x", 23 | "winston": "^3.2.x" 24 | }, 25 | "devDependencies": { 26 | "superagent": "^6.1.0", 27 | "tape": "^5.0.1" 28 | }, 29 | "jshintConfig": { 30 | "curly": true, 31 | "eqeqeq": true, 32 | "immed": true, 33 | "latedef": false, 34 | "newcap": false, 35 | "noarg": true, 36 | "sub": true, 37 | "undef": true, 38 | "boss": true, 39 | "eqnull": true, 40 | "strict": false, 41 | "scripturl": true, 42 | "evil": true, 43 | "globals": { 44 | "location": true, 45 | "printStackTrace": false 46 | }, 47 | "browser": true, 48 | "node": true 49 | }, 50 | "license": "MIT" 51 | } 52 | -------------------------------------------------------------------------------- /generateData.js: -------------------------------------------------------------------------------- 1 | var faker = require('faker'); 2 | 3 | var libs = process.cwd() + '/libs/'; 4 | 5 | var log = require(libs + 'log')(module); 6 | var db = require(libs + 'db/mongoose'); 7 | var config = require(libs + 'config'); 8 | 9 | var User = require(libs + 'model/user'); 10 | var Client = require(libs + 'model/client'); 11 | var AccessToken = require(libs + 'model/accessToken'); 12 | var RefreshToken = require(libs + 'model/refreshToken'); 13 | 14 | User.deleteMany({}, function (err) { 15 | var user = new User({ 16 | username: config.get('default:user:username'), 17 | password: config.get('default:user:password') 18 | }); 19 | 20 | user.save(function (err, user) { 21 | if (!err) { 22 | log.info('New user - %s:%s', user.username, user.password); 23 | } else { 24 | return log.error(err); 25 | } 26 | }); 27 | }); 28 | 29 | Client.deleteMany({}, function (err) { 30 | var client = new Client({ 31 | name: config.get('default:client:name'), 32 | clientId: config.get('default:client:clientId'), 33 | clientSecret: config.get('default:client:clientSecret') 34 | }); 35 | 36 | client.save(function (err, client) { 37 | 38 | if (!err) { 39 | log.info('New client - %s:%s', client.clientId, client.clientSecret); 40 | } else { 41 | return log.error(err); 42 | } 43 | 44 | }); 45 | }); 46 | 47 | AccessToken.deleteMany({}, function (err) { 48 | if (err) { 49 | return log.error(err); 50 | } 51 | }); 52 | 53 | RefreshToken.deleteMany({}, function (err) { 54 | if (err) { 55 | return log.error(err); 56 | } 57 | }); 58 | 59 | setTimeout(function () { 60 | db.disconnect(); 61 | }, 3000); 62 | -------------------------------------------------------------------------------- /libs/auth/auth.js: -------------------------------------------------------------------------------- 1 | var passport = require('passport'); 2 | var BasicStrategy = require('passport-http').BasicStrategy; 3 | var ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy; 4 | var BearerStrategy = require('passport-http-bearer').Strategy; 5 | 6 | var libs = process.cwd() + '/libs/'; 7 | 8 | var config = require(libs + 'config'); 9 | 10 | var User = require(libs + 'model/user'); 11 | var Client = require(libs + 'model/client'); 12 | var AccessToken = require(libs + 'model/accessToken'); 13 | var RefreshToken = require(libs + 'model/refreshToken'); 14 | 15 | // 2 Client Password strategies - 1st is required, 2nd is optional 16 | // https://tools.ietf.org/html/draft-ietf-oauth-v2-27#section-2.3.1 17 | 18 | // Client Password - HTTP Basic authentication 19 | passport.use(new BasicStrategy( 20 | function (username, password, done) { 21 | Client.findOne({ clientId: username }, function (err, client) { 22 | if (err) { 23 | return done(err); 24 | } 25 | 26 | if (!client) { 27 | return done(null, false); 28 | } 29 | 30 | if (client.clientSecret !== password) { 31 | return done(null, false); 32 | } 33 | 34 | return done(null, client); 35 | }); 36 | } 37 | )); 38 | 39 | // Client Password - credentials in the request body 40 | passport.use(new ClientPasswordStrategy( 41 | function (clientId, clientSecret, done) { 42 | Client.findOne({ clientId: clientId }, function (err, client) { 43 | if (err) { 44 | return done(err); 45 | } 46 | 47 | if (!client) { 48 | return done(null, false); 49 | } 50 | 51 | if (client.clientSecret !== clientSecret) { 52 | return done(null, false); 53 | } 54 | 55 | return done(null, client); 56 | }); 57 | } 58 | )); 59 | 60 | // Bearer Token strategy 61 | // https://tools.ietf.org/html/rfc6750 62 | 63 | passport.use(new BearerStrategy( 64 | function (accessToken, done) { 65 | AccessToken.findOne({ token: accessToken }, function (err, token) { 66 | 67 | if (err) { 68 | return done(err); 69 | } 70 | 71 | if (!token) { 72 | return done(null, false); 73 | } 74 | 75 | if (Math.round((Date.now() - token.created) / 1000) > config.get('security:tokenLife')) { 76 | 77 | AccessToken.deleteMany({ token: accessToken }, function (err) { 78 | if (err) { 79 | return done(err); 80 | } 81 | }); 82 | 83 | return done(null, false, { message: 'Token expired' }); 84 | } 85 | 86 | User.findById(token.userId, function (err, user) { 87 | 88 | if (err) { 89 | return done(err); 90 | } 91 | 92 | if (!user) { 93 | return done(null, false, { message: 'Unknown user' }); 94 | } 95 | 96 | var info = { scope: '*' }; 97 | done(null, user, info); 98 | }); 99 | }); 100 | } 101 | )); 102 | -------------------------------------------------------------------------------- /libs/auth/oauth2.js: -------------------------------------------------------------------------------- 1 | var oauth2orize = require('oauth2orize'); 2 | var passport = require('passport'); 3 | var crypto = require('crypto'); 4 | 5 | var libs = process.cwd() + '/libs/'; 6 | 7 | var config = require(libs + 'config'); 8 | var log = require(libs + 'log')(module); 9 | 10 | var db = require(libs + 'db/mongoose'); 11 | var User = require(libs + 'model/user'); 12 | var AccessToken = require(libs + 'model/accessToken'); 13 | var RefreshToken = require(libs + 'model/refreshToken'); 14 | 15 | // Create OAuth 2.0 server 16 | var aserver = oauth2orize.createServer(); 17 | 18 | // Generic error handler 19 | var errFn = function (cb, err) { 20 | if (err) { 21 | return cb(err); 22 | } 23 | }; 24 | 25 | // Destroy any old tokens and generates a new access and refresh token 26 | var generateTokens = function (data, done) { 27 | 28 | // Curries in `done` callback so we don't need to pass it 29 | var errorHandler = errFn.bind(undefined, done), 30 | refreshToken, 31 | refreshTokenValue, 32 | token, 33 | tokenValue; 34 | 35 | RefreshToken.deleteMany(data, errorHandler); 36 | AccessToken.deleteMany(data, errorHandler); 37 | 38 | tokenValue = crypto.randomBytes(32).toString('hex'); 39 | refreshTokenValue = crypto.randomBytes(32).toString('hex'); 40 | 41 | data.token = tokenValue; 42 | token = new AccessToken(data); 43 | 44 | data.token = refreshTokenValue; 45 | refreshToken = new RefreshToken(data); 46 | 47 | refreshToken.save(errorHandler); 48 | 49 | token.save(function (err) { 50 | if (err) { 51 | 52 | log.error(err); 53 | return done(err); 54 | } 55 | done(null, tokenValue, refreshTokenValue, { 56 | 'expires_in': config.get('security:tokenLife') 57 | }); 58 | }); 59 | }; 60 | 61 | // Exchange username & password for access token 62 | aserver.exchange(oauth2orize.exchange.password(function (client, username, password, scope, done) { 63 | 64 | User.findOne({ username: username }, function (err, user) { 65 | 66 | if (err) { 67 | return done(err); 68 | } 69 | 70 | if (!user || !user.checkPassword(password)) { 71 | return done(null, false); 72 | } 73 | 74 | var model = { 75 | userId: user.userId, 76 | clientId: client.clientId 77 | }; 78 | 79 | generateTokens(model, done); 80 | }); 81 | 82 | })); 83 | 84 | // Exchange refreshToken for access token 85 | aserver.exchange(oauth2orize.exchange.refreshToken(function (client, refreshToken, scope, done) { 86 | 87 | RefreshToken.findOne({ token: refreshToken, clientId: client.clientId }, function (err, token) { 88 | if (err) { 89 | return done(err); 90 | } 91 | 92 | if (!token) { 93 | return done(null, false); 94 | } 95 | 96 | User.findById(token.userId, function (err, user) { 97 | if (err) { return done(err); } 98 | if (!user) { return done(null, false); } 99 | 100 | var model = { 101 | userId: user.userId, 102 | clientId: client.clientId 103 | }; 104 | 105 | generateTokens(model, done); 106 | }); 107 | }); 108 | })); 109 | 110 | // token endpoint 111 | // 112 | // `token` middleware handles client requests to exchange authorization grants 113 | // for access tokens. Based on the grant type being exchanged, the above 114 | // exchange middleware will be invoked to handle the request. Clients must 115 | // authenticate when making requests to this endpoint. 116 | 117 | exports.token = [ 118 | passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), 119 | aserver.token(), 120 | aserver.errorHandler() 121 | ]; 122 | -------------------------------------------------------------------------------- /libs/routes/articles.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var passport = require('passport'); 3 | var router = express.Router(); 4 | 5 | var libs = process.cwd() + '/libs/'; 6 | var log = require(libs + 'log')(module); 7 | 8 | var db = require(libs + 'db/mongoose'); 9 | var Article = require(libs + 'model/article'); 10 | 11 | // List all articles 12 | router.get('/', passport.authenticate('bearer', { session: false }), function (req, res) { 13 | 14 | Article.find(function (err, articles) { 15 | if (!err) { 16 | return res.json(articles); 17 | } else { 18 | res.statusCode = 500; 19 | 20 | log.error('Internal error(%d): %s', res.statusCode, err.message); 21 | 22 | return res.json({ 23 | error: 'Server error' 24 | }); 25 | } 26 | }); 27 | }); 28 | 29 | // Create article 30 | router.post('/', passport.authenticate('bearer', { session: false }), function (req, res) { 31 | 32 | var article = new Article({ 33 | title: req.body.title, 34 | author: req.body.author, 35 | description: req.body.description, 36 | images: req.body.images 37 | }); 38 | 39 | article.save(function (err) { 40 | if (!err) { 41 | log.info('New article created with id: %s', article.id); 42 | return res.json({ 43 | status: 'OK', 44 | article: article 45 | }); 46 | } else { 47 | if (err.name === 'ValidationError') { 48 | res.statusCode = 400; 49 | res.json({ 50 | error: 'Validation error' 51 | }); 52 | } else { 53 | res.statusCode = 500; 54 | 55 | log.error('Internal error(%d): %s', res.statusCode, err.message); 56 | 57 | res.json({ 58 | error: 'Server error' 59 | }); 60 | } 61 | } 62 | }); 63 | }); 64 | 65 | // Get article 66 | router.get('/:id', passport.authenticate('bearer', { session: false }), function (req, res) { 67 | 68 | Article.findById(req.params.id, function (err, article) { 69 | 70 | if (!article) { 71 | res.statusCode = 404; 72 | 73 | return res.json({ 74 | error: 'Not found' 75 | }); 76 | } 77 | 78 | if (!err) { 79 | return res.json({ 80 | status: 'OK', 81 | article: article 82 | }); 83 | } else { 84 | res.statusCode = 500; 85 | log.error('Internal error(%d): %s', res.statusCode, err.message); 86 | 87 | return res.json({ 88 | error: 'Server error' 89 | }); 90 | } 91 | }); 92 | }); 93 | 94 | // Update article 95 | router.put('/:id', passport.authenticate('bearer', { session: false }), function (req, res) { 96 | var articleId = req.params.id; 97 | 98 | Article.findById(articleId, function (err, article) { 99 | if (!article) { 100 | res.statusCode = 404; 101 | log.error('Article with id: %s Not Found', articleId); 102 | return res.json({ 103 | error: 'Not found' 104 | }); 105 | } 106 | 107 | article.title = req.body.title; 108 | article.description = req.body.description; 109 | article.author = req.body.author; 110 | article.images = req.body.images; 111 | 112 | article.save(function (err) { 113 | if (!err) { 114 | log.info('Article with id: %s updated', article.id); 115 | return res.json({ 116 | status: 'OK', 117 | article: article 118 | }); 119 | } else { 120 | if (err.name === 'ValidationError') { 121 | res.statusCode = 400; 122 | return res.json({ 123 | error: 'Validation error' 124 | }); 125 | } else { 126 | res.statusCode = 500; 127 | 128 | return res.json({ 129 | error: 'Server error' 130 | }); 131 | } 132 | log.error('Internal error (%d): %s', res.statusCode, err.message); 133 | } 134 | }); 135 | }); 136 | }); 137 | 138 | module.exports = router; 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node REST API 2 | 3 | [![CI Status](https://github.com/ealeksandrov/NodeAPI/workflows/CI/badge.svg?branch=master)](https://github.com/ealeksandrov/NodeAPI/actions) 4 | [![Dependency Status](https://img.shields.io/david/ealeksandrov/NodeAPI.svg)](https://david-dm.org/ealeksandrov/NodeAPI) 5 | [![Dependency Status](https://img.shields.io/david/dev/ealeksandrov/NodeAPI.svg)](https://david-dm.org/ealeksandrov/NodeAPI) 6 | [![License](https://img.shields.io/github/license/ealeksandrov/NodeAPI.svg)](LICENSE.md) 7 | 8 | `NodeAPI` is REST API server implementation built on top `Node.js` and `Express.js` with `Mongoose.js` for `MongoDB` integration. Access control follows `OAuth 2.0` spec with the help of `OAuth2orize` and `Passport.js`. 9 | 10 | This is updated code that follows [RESTful API With Node.js + MongoDB](https://aleksandrov.ws/2013/09/12/restful-api-with-nodejs-plus-mongodb) article. 11 | 12 | ## Running project 13 | 14 | ## Manual 15 | 16 | You need to have [Node.js](https://nodejs.org) and [MongoDB](https://www.mongodb.com) installed. 17 | 18 | ### Node setup on macOS 19 | 20 | ```sh 21 | # Update Homebrew before installing all dependencies 22 | brew update 23 | 24 | # Install Node (+npm) with Homebrew 25 | brew install node 26 | 27 | # Install npm dependencies in project folder 28 | npm install 29 | ``` 30 | 31 | ### MongoDB setup on macOS 32 | 33 | ```sh 34 | # Install MongoDB with Homebrew 35 | brew tap mongodb/brew 36 | brew install mongodb-community 37 | 38 | # Create directory for MongoDB data 39 | mkdir -p ./data/mongo 40 | 41 | # Run MongoDB daemon process with path to data directory 42 | mongod --dbpath ./data/mongo 43 | ``` 44 | 45 | ### Run server 46 | 47 | ```sh 48 | npm start 49 | # alias for 50 | node bin/www 51 | ``` 52 | 53 | ### Create demo data 54 | 55 | ```sh 56 | npm run-script generate 57 | # alias for 58 | node generateData.js 59 | ``` 60 | 61 | ## Docker 62 | 63 | You need to have [Docker](https://www.docker.com/community-edition) installed. 64 | 65 | ### Run server 66 | 67 | ```sh 68 | docker-compose up -d --build 69 | ``` 70 | 71 | ### Create demo data 72 | 73 | ```sh 74 | docker exec nodeapi_node_api_1 node generateData.js 75 | ``` 76 | 77 | ## Make Requests 78 | 79 | Create and refresh access tokens: 80 | 81 | ```sh 82 | http POST http://localhost:1337/api/oauth/token grant_type=password client_id=android client_secret=SomeRandomCharsAndNumbers username=myapi password=abc1234 83 | http POST http://localhost:1337/api/oauth/token grant_type=refresh_token client_id=android client_secret=SomeRandomCharsAndNumbers refresh_token=[REFRESH_TOKEN] 84 | ``` 85 | 86 | Create your article data: 87 | 88 | ```sh 89 | http POST http://localhost:1337/api/articles title='New Article' author='John Doe' description='Lorem ipsum dolar sit amet' images:='[{"kind":"thumbnail", "url":"http://habrahabr.ru/images/write-topic.png"}, {"kind":"detail", "url":"http://habrahabr.ru/images/write-topic.png"}]' Authorization:'Bearer ACCESS_TOKEN' 90 | ``` 91 | 92 | Update your article data: 93 | 94 | ```sh 95 | http PUT http://localhost:1337/api/articles/EXISTING_ARTICLE_ID title='Updated Article' author='Jane Doe' description='This is now updated' Authorization:'Bearer ACCESS_TOKEN' 96 | ``` 97 | 98 | Get your data: 99 | 100 | ```sh 101 | http http://localhost:1337/api/users/info Authorization:'Bearer ACCESS_TOKEN' 102 | http http://localhost:1337/api/articles Authorization:'Bearer ACCESS_TOKEN' 103 | ``` 104 | 105 | ## Tests 106 | 107 | ```sh 108 | npm test 109 | # alias for 110 | node ./test/server.test.js 111 | ``` 112 | 113 | ## Modules used 114 | 115 | Some of non-standard modules used: 116 | 117 | * [express](https://www.npmjs.com/package/express) 118 | * [mongoose](https://www.npmjs.com/package/mongoose) 119 | * [nconf](https://www.npmjs.com/package/nconf) 120 | * [winston](https://www.npmjs.com/package/winston) 121 | * [faker](https://www.npmjs.com/package/faker) 122 | * [oauth2orize](https://www.npmjs.com/package/oauth2orize) 123 | * [passport](https://www.npmjs.com/package/passport) 124 | 125 | Test modules: 126 | 127 | * [tape](https://www.npmjs.com/package/tape) 128 | * [superagent](https://www.npmjs.com/package/superagent) 129 | 130 | ## Tools used 131 | 132 | * [httpie](https://github.com/jkbr/httpie) - command line HTTP client 133 | 134 | ### JSHint 135 | 136 | ```sh 137 | npm install jshint -g 138 | jshint libs/**/*.js generateData.js 139 | ``` 140 | 141 | ## Author 142 | 143 | Created and maintained by Evgeny Aleksandrov ([@ealeksandrov](https://twitter.com/ealeksandrov)). 144 | 145 | Updated by: 146 | 147 | * [Istock Jared](https://github.com/IstockJared) 148 | * [Marko Arsić](https://marsic.info/) 149 | * and other [contributors](https://github.com/ealeksandrov/NodeAPI/graphs/contributors) 150 | 151 | ## License 152 | 153 | `NodeAPI` is available under the MIT license. See the [LICENSE.md](LICENSE.md) file for more info. 154 | -------------------------------------------------------------------------------- /test/server.test.js: -------------------------------------------------------------------------------- 1 | var libs = process.cwd() + '/libs/'; 2 | var config = require(libs + 'config'); 3 | 4 | var test = require('tape'); 5 | var request = require('superagent'); 6 | var baseUrl = 'http://localhost:1337/api'; 7 | 8 | var userCredentials = { 9 | username: config.get('default:user:username'), 10 | password: config.get('default:user:password') 11 | }; 12 | var clientCredentials = { 13 | client_id: config.get('default:client:clientId'), 14 | client_secret: config.get('default:client:clientSecret') 15 | }; 16 | var accessToken; 17 | var refreshToken; 18 | 19 | var articleExample = { 20 | title: 'New Article', author: 'John Doe', description: 'Lorem ipsum dolar sit amet', images: [ 21 | { kind: 'thumbnail', url: 'http://habrahabr.ru/images/write-topic.png' }, 22 | { kind: 'detail', url: 'http://habrahabr.ru/images/write-topic.png' } 23 | ] 24 | }; 25 | var articleUpdated = { title: 'Updated Article', author: 'Jane Doe', description: 'This is now updated' }; 26 | var articleId; 27 | 28 | function getTokensFromBody(body) { 29 | if (!('access_token' in body) || !('refresh_token' in body)) { 30 | return false; 31 | } 32 | 33 | accessToken = body['access_token']; 34 | refreshToken = body['refresh_token']; 35 | 36 | return true; 37 | } 38 | 39 | test('Unauthorized request', function (t) { 40 | request 41 | .get(baseUrl + '/') 42 | .end(function (err, res) { 43 | t.equal(res.status, 401, 'response status shoud be 401'); 44 | t.end(); 45 | }); 46 | }); 47 | 48 | test('Get token from username-password', function (t) { 49 | request 50 | .post(baseUrl + '/oauth/token') 51 | .send({ grant_type: 'password' }) 52 | .send(userCredentials) 53 | .send(clientCredentials) 54 | .end(function (err, res) { 55 | t.equal(res.status, 200, 'response status shoud be 200'); 56 | t.true(getTokensFromBody(res.body), 'tokens shoud be in response body'); 57 | t.end(); 58 | }); 59 | }); 60 | 61 | test('Get token from refresh token', function (t) { 62 | request 63 | .post(baseUrl + '/oauth/token') 64 | .send({ grant_type: 'refresh_token', refresh_token: refreshToken }) 65 | .send(clientCredentials) 66 | .end(function (err, res) { 67 | t.equal(res.status, 200, 'response status shoud be 200'); 68 | t.true(getTokensFromBody(res.body), 'tokens shoud be in response body'); 69 | t.end(); 70 | }); 71 | }); 72 | 73 | test('Authorized request', function (t) { 74 | request 75 | .get(baseUrl + '/') 76 | .set('Authorization', 'Bearer ' + accessToken) 77 | .end(function (err, res) { 78 | t.equal(res.status, 200, 'response status shoud be 200'); 79 | t.end(); 80 | }); 81 | }); 82 | 83 | test('Create article', function (t) { 84 | request 85 | .post(baseUrl + '/articles') 86 | .send(articleExample) 87 | .set('Authorization', 'Bearer ' + accessToken) 88 | .end(function (err, res) { 89 | t.equal(res.status, 200, 'response status shoud be 200'); 90 | if ('article' in res.body) { 91 | t.equal(res.body['article']['title'], articleExample['title'], 'created article title shoud be correct'); 92 | articleId = res.body['article']['_id']; 93 | } 94 | t.end(); 95 | }); 96 | }); 97 | 98 | test('Check created article', function (t) { 99 | request 100 | .get(baseUrl + '/articles/' + articleId) 101 | .set('Authorization', 'Bearer ' + accessToken) 102 | .end(function (err, res) { 103 | t.equal(res.status, 200, 'response status shoud be 200'); 104 | if ('article' in res.body) { 105 | t.equal(res.body['article']['title'], articleExample['title'], 'created article title shoud be correct'); 106 | t.equal(res.body['article']['images'].length, articleExample['images'].length, 'created article images count shoud be correct'); 107 | } 108 | t.end(); 109 | }); 110 | }); 111 | 112 | test('Update article', function (t) { 113 | request 114 | .put(baseUrl + '/articles/' + articleId) 115 | .set('Authorization', 'Bearer ' + accessToken) 116 | .send(articleUpdated) 117 | .end(function (err, res) { 118 | t.equal(res.status, 200, 'response status shoud be 200'); 119 | if ('article' in res.body) { 120 | t.equal(res.body['article']['title'], articleUpdated['title'], 'updated article title shoud be correct'); 121 | } 122 | t.end(); 123 | }); 124 | }); 125 | 126 | test('Test articles list', function (t) { 127 | request 128 | .get(baseUrl + '/articles') 129 | .set('Authorization', 'Bearer ' + accessToken) 130 | .end(function (err, res) { 131 | t.equal(res.status, 200, 'response status shoud be 200'); 132 | var articleFound = false; 133 | for (var i = 0; i < res.body.length; i++) { 134 | var article = res.body[i]; 135 | if (article['_id'] === articleId) { 136 | articleFound = true; 137 | t.equal(article['title'], articleUpdated['title'], 'updated article title shoud be correct'); 138 | } 139 | } 140 | t.true(articleFound, 'created/updated article shoud be in a list'); 141 | t.end(); 142 | }); 143 | }); 144 | 145 | test('Test users/info', function (t) { 146 | request 147 | .get(baseUrl + '/users/info') 148 | .set('Authorization', 'Bearer ' + accessToken) 149 | .end(function (err, res) { 150 | t.equal(res.status, 200, 'response status shoud be 200'); 151 | t.equal(res.body['name'], userCredentials['username'], 'username shoud be correct'); 152 | t.end(); 153 | }); 154 | }); 155 | --------------------------------------------------------------------------------