diff --git a/.autod.conf.js b/.autod.conf.js index 90d6d8d..4a9b334 100644 --- a/.autod.conf.js +++ b/.autod.conf.js @@ -9,7 +9,6 @@ module.exports = { ], devdep: [ 'egg', - 'egg-ci', 'egg-bin', 'autod', 'eslint', diff --git a/.travis.yml b/.travis.yml index 5ec1467..031c048 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ sudo: false language: node_js node_js: - - '4' - '6' - '7' install: @@ -10,3 +9,13 @@ script: - npm run ci after_script: - npminstall codecov && codecov +env: + global: + # EGG_PASSPORT_TWITTER_CONSUMER_SECRET + - secure: "aNsMN4elXkDQ1rvikWW3wOSMzVj5G7VLBhVJWq2yj7Om+tfSMn+uzZppnBhHuYR6PjWu9UEliuk+EdF2nrdaFwb1LY3yfUwyqkDCj1/UXVPewchx2W4vwvWw0zKanE5UFMou8zNq44Gm3PWdeE3EcqeNN4qJb6i6kqnJTMBn+bxij0wRULiv4NnvUCliroSvBLu6bW3gE4Il8kqSXw5kJ+4haFF6kYFW5bjrg+zHB13zBrfv5KBdwiMtHsjsOFoHhaR/ijWaPLGpBVja19OnDrsMvWL0TLiEmyqCmECXrmL4bMPSby4w6Chx8OJNhIzhH1OgMYy7kucJn3s3x3rAZNNtLENsSI+VC4zh0spAt9gbdF4WCltVme9BNVOYy2lbfjUsvKmh+4WaX7F/yNITIq7CvaNH/7Fw+9c2BY4Blq846Qt0lDfqEczr9eqrRVUQrFVlElXWV/voGWChpv8ce+uYbjrRpf9ajOBBZy3080gwj5bEnu/Xx7bSjXILZv4mbbkGXYuMEU6fYdnMd19+UxpP2H21cGqmlCd0jL9r+zPsRGT9uu34uh8t5ASsiGnWhXAsk+i83ZxUXN6eXn5i3gQdYuATUNPFcyJLNgQZ1XNKQHavnNqf80f6KU8ppCiUNbAl5u+afzsv92JD64L26amBoFIq1mD83cMUmKjdB1Y=" + # EGG_PASSPORT_WEIBO_CLIENT_ID + - secure: "dmar092ZvqXn/MVsH96s/upjgaIy3lRZEzQM5jnDekedMDbR2ZQs8N5IhE6w97JagGL4hm1TF/ZSOrmuFLU/WeOfArEkrKbJYaSRU43E49hiaW63/j9JwNEB0YXfwNgs2f5gr7FIw2/d/NUitheqnPbaamo+2l8gt3W/F04m/lDxlVoFmtwMrWYA+K4g/OhgQwe0WSMQzEPJ74W48hdQF/eSsYFQSAx6iQu8oM6Kz9C5GWX75vXeI1RJglv61wH7TKnj+YJY6RnW/VVxRmFu0utc6nSOaxDoncX9R8CuCYcj/HDrsF0K006ph/nIsDwDEtyA62KJYi/fluowUCeBIvleFj7r/c1Yx2O35TIEYsJNOBZWn+oeWmni+7htYVRYX7zKFIy6AENYDXWIVJe1tiCMeNTaTUUar/lF+UFu+sF7Z8p21izCD/gqYYvqWy0cZ/+Qb5tisyfzUWShMKhC/mrldb+bXtSzmSZfX7gP5JQeEuxdUF7HM3RHwaW+x1ORBjbvyZF5t1J3Pl095SGb/46wCLOkVmBsIvj0Gj+BcNWxaLro0IVyFF1SzjuWHcqHFx3RKe1puknIv8BKpoqMQCryBPsMRje09nXwbdP5L32ZpNjhf5i1lCVdzXGOAVfYa2GbuXoB+960SgFjmtxeRJP89QCyRFPRPV2MrUWvQPU=" + # EGG_PASSPORT_TWITTER_CONSUMER_KEY + - secure: "a66F9RKyMDITxM3iEJA811mvHOY3wFgJFRVLU1uHz9BmmpUoqL9uKEhrcdJFgjfLTfxQfv6hk6HR4lg9MUyDf3NgF0U4KzC2guOHykMRd4NM7iVyO/FMMTkLiTd+QVYPAhqg9FsYEW9tLg2AJCU/pm7997zOJ0Qk9ibUCBWjrIKtZHPqcRWJ3dgXxSXc9WhinN5bbAzqj2GFXxNpGun1lHxNOzpF9g8p5R8crm2JEx1bywFzU1PxH1vKvF4EA37dCUnF4Qg1MTB1Wrdz5Agk7qda0kN/WCbWpcNAAWAwSAaAQKJemD28Hh52E6bj8ZmVerXJY0rzzk5utZxNOIAOKeBWWx+b9F2WRPiNnE4fsXos0ldwGGodOOM5MYfb3QSqDANQoMV+KTu26NV/Sy47epV0ohSc8L9Vg/pgUzPIxDMHdZgODP6UNRWeyA/Vi4TC04bCJQsIxxqxMk9rDe4nI7dz7nf33oEYl4BGrGBWCGkysAEo8Vz31ZPM6rDjXhmxmH+CtbWoFWnvqNxsEePjJl3tDXxBlzoqhSiHau+Z+Du8WdAOjZFWOuby46vmJU1a3DwdPt3V2aIhLLV1Mln2RcU11E7Mi13KKalcTugFsi57XaeJP30pcqkA9tRgozCxWXdoThQItVgu3ikCCpJPf2MUu9ciCN+Cl65LXLIMq5c=" + # EGG_PASSPORT_WEIBO_CLIENT_SECRET + - secure: "FvjTYNVg+0H4+AJzZeZDQu2uu52kbdGfpKWnIh4owQXBGT3T3nIfzbCZPpDVaJcv/JyGSzkP4WuH/KvxuLu1ZzgRTu1r48rAIiuBcmQDzlSUQ0e5Gyfj3bwCASPgrkXTc6vC/SwvWIsAf6NAPwkS/NMaP1R71IJ0RSLs04JdU4zmoLTD6Yt896Tjf99MtAzqY+VfvLh8W4v5fa7a3WoNeXJW46deqh0yV/vQbH4HwbAipExTSMUlMk1xK+s9wqiafJEepU2wvIQps2sOLTytDV+6BDYKjyVEjs3cRJkpa8gFo16aOs09r3wuwtd5QJUQTTbOYhCTuQ6DKbxQHvOoT2Bw9uyP/jVF2xTPGxLOupn7cis+tgj/bmke/mJrZtb3+uZaKNXjGUXhz7a9mYWQgN+8hOVM2ia5goOWxGEMcaEbk5jir9XQRjyYeljfbyT3KXgAL8k61cYdPCGqXfvZVpYJbPHMOAQ+lyaV8TEqmPgJewWDUvhINc7QLmZCY5NwbqabAhYeKqIZG9oG/17gKBwDSDHtzTRN5Y//LQuctMBRSQN1tO0bw/ND0tOEGI71uXCwN/xMDC/vMAr0FqkFb2gH+hxAIrSM8/e6fUm0gMog0YzVwJtvEqSTRlrSB/bKsy7e/3Jf1HiiMvqDmPWTW/P8+zBcxJ74Ez8Joe02vr0=" diff --git a/README.md b/README.md index ae3fa5b..264788e 100644 --- a/README.md +++ b/README.md @@ -40,56 +40,211 @@ exports.passport = { }; ``` -### Using github and twitter strategy +### Using Github and Twitter strategy ```js -// app.js +// config/config.default.js +exports.passportGithub = { + key: 'my oauth2 clientID', + secret: 'my oauth2 clientSecret', +}; + +exports.passportTwitter: { + key: 'my oauth1 consumerKey', + secret: 'my oauth1 consumerSecret', +}; +``` + +### Authenticate Requests -const GithubStrategy = require('passport-github2').Strategy; -const TwitterStrategy = require('passport-twitter').Strategy; +Use `app.passport.mount(strategy[, options])`, specifying the `'github'` and `'twitter'` strategy, to authenticate requests. +```js +// app/router.js module.exports = app => { - const githubStrategy = new GithubStrategy({ - consumerKey: app.config.github.consumerKey, - consumerSecret: app.config.github.consumerSecret, - // authURL: '/passport/auth/github', - // callbackURL: '/passport/auth/github/callback', - // scope: [ 'user:email' ], - }, function* (accessToken, refreshToken, profile, done) { - const user = yield User.findOrCreate(...); - // user must contains `id` property - return user; + app.get('/', 'home.index'); + + // authenticates routers + app.passport.mount('github'); + // this is a passport router helper, it's equal to the below codes + // + // const github = app.passport.authenticate('github'); + // app.get('/passport/github', github); + // app.get('/passport/github/callback', github); + + // custom options.login url and options.successRedirect + app.passport.mount('twitter', { + loginURL: '/account/twitter', + // auth success redirect to / + successRedirect: '/', }); +}; +``` - const twitterStrategy = new TwitterStrategy({ - consumerKey: app.config.twitter.consumerKey, - consumerSecret: app.config.twitter.consumerSecret, - // authURL: '/passport/auth/twitter', - // callbackURL: '/passport/auth/twitter/callback', - }, function* (token, tokenSecret, profile, done) { - const user = yield User.findOrCreate(...); - // user must contains `id` property - return user; - }); +### Verify and store user + +Use `app.passport.verify(function* (ctx, user) {})` hook: - app.passport.use(githubStrategy); - app.passport.use(twitterStrategy); +```js +// app.js +module.exports = app => { + app.passport.verify(function* (ctx, user) { + // check user + assert(user.provider, 'user.provider should exists'); + assert(user.id, 'user.id should exists'); + + // find user from database + // + // Authorization Table + // column | desc + // --- | -- + // provider | provider name, like github, twitter, facebook, weibo and so on + // uid | provider unique id + // user_id | current application user id + const auth = yield ctx.model.Authorization.findOne({ + uid: user.id, + provider: user.provider, + }); + const existsUser = yield ctx.model.User.findOne({ id: auth.user_id }); + if (existsUser) { + return existsUser; + } + // call user service to register a new user + const newUser = yield ctx.service.user.register(user); + return newUser; + }); }; ``` -### Authenticate Requests +## How to develop an `egg-passport-${provider}` plugin + +See example: [egg-passport-twitter](https://github.com/eggjs/egg-passport-twitter). -Use `app.passport.authenticate()`, specifying the `'github'` and `'twitter'` strategy, to authenticate requests. +- Plugin dependencies on [egg-passport](https://github.com/eggjs/egg-passport) to use `app.passport` APIs. + +```json +// package.json +{ + "eggPlugin": { + "name": "passportTwitter", + "dependencies": [ + "passport" + ] + }, +} +``` + +- Define config and set default values + +**Must use `key` and `secret` instead of `consumerKey|clientID` and `consumerSecret|clientSecret`.** ```js -// app/router.js +// config/config.default.js +exports.passportTwitter: { + key: '', + secret: '', + callbackURL: '/passport/twitter/callback', +}; +``` + +- Init `Strategy` in `app.js` and format user in `verify callback` + +```js +// app.js +const debug = require('debug')('egg-passport-twitter'); +const assert = require('assert'); +const Strategy = require('passport-twitter').Strategy; module.exports = app => { - app.get('/passport/auth/github', app.passport.authenticate('github')); - app.get('/passport/auth/twitter', app.passport.authenticate('twitter')); + const config = app.config.passportTwitter; + // must set passReqToCallback to true + config.passReqToCallback = true; + assert(config.key, '[egg-passport-twitter] config.passportTwitter.key required'); + assert(config.secret, '[egg-passport-twitter] config.passportTwitter.secret required'); + // convert to consumerKey and consumerSecret + config.consumerKey = config.key; + config.consumerSecret = config.secret; + + // register twitter strategy into `app.passport` + // must require `req` params + app.passport.use('twitter', new Strategy(config, (req, token, tokenSecret, params, profile, done) => { + // format user + const user = { + provider: 'twitter', + id: profile.id, + name: profile.username, + displayName: profile.displayName, + photo: profile.photos && profile.photos[0] && profile.photos[0].value, + token, + tokenSecret, + params, + profile, + }; + debug('%s %s get user: %j', req.method, req.url, user); + // let passport do verify and call verify hook + app.passport.doVerify(req, user, done); + })); }; ``` +- That's all! + +## APIs + +### extent `application` + +- `app.passport.mount(strategy, options)`: Mount the login and the login callback routers to use the given `strategy`. +- `app.passport.authenticate(strategy, options)`: Create a middleware that will authorize a third-party account using the given `strategy` name, with optional `options`. +- `app.passport.verify(handler)`: Verify authenticated user +- `app.passport.serializeUser(handler)`: Serialize user before store into session +- `app.passport.deserializeUser(handler)`: Deserialize user after restore from session + +### extend `context` + +- `ctx.user`: get the current authenticated user +- `ctx.isAuthenticated()`: Test if request is authenticated +- `* ctx.login(user[, options])`: Initiate a login session for `user`. +- `ctx.logout()`: Terminate an existing login session + +## Unit Tests + +This plugin has includes some mock methods to helper you writing unit tests more conveniently. + +### `app.mockUser([user])`: Mock an authenticated user + +```js +const mm = require('egg-mock'); + +describe('mock user demo', () => { + let app; + before(() => { + app = mm.app(); + return app.ready(); + }); + after(() => app.close()); + + afterEach(mm.restore); + + it('should show authenticated user info', () => { + app.mockUser(); + return request(app.callback()) + .get('/') + .expect(/user name: mock_name/) + .expect(200); + }); +}); +``` + +### `app.mockUserContext([user])`: Mock a context instance with authenticated user + +```js +it('should get authenticated user and call service', function* () { + const ctx = app.mockUserContext(); + const result = yield ctx.service.findUser({ id: ctx.user.id }); + assert(result.user.id === ctx.user.id); +}); +``` + ## Questions & Suggestions Please open an issue [here](https://github.com/eggjs/egg/issues). diff --git a/app.js b/app.js index 5e725bd..b1bc0c1 100644 --- a/app.js +++ b/app.js @@ -1,10 +1,12 @@ 'use strict'; -const KoaPassport = require('./lib/passport'); +const assert = require('assert'); +const Passport = require('./lib/passport'); module.exports = app => { - app.passport = new KoaPassport(); + app.passport = new Passport(app); + assert(app.config.coreMiddleware.includes('session'), '[egg-passport] session middleware must exists'); app.config.coreMiddleware.push('passportInitialize'); app.config.coreMiddleware.push('passportSession'); }; diff --git a/app/extend/application.unittest.js b/app/extend/application.unittest.js new file mode 100644 index 0000000..7116b0e --- /dev/null +++ b/app/extend/application.unittest.js @@ -0,0 +1,54 @@ +'use strict'; + +module.exports = { + /** + * mock an authenticated user + * + * @param {Object} [user] - mock user data + */ + mockUser(user) { + user = Object.assign({ + provider: 'mock', + id: '10086', + name: 'mock_name', + displayName: 'mock displayName', + photo: 'https://tva2.sinaimg.cn/crop.0.0.180.180.180/61c56ebcjw1e8qgp5bmzyj2050050aa8.jpg', + profile: { + photos: [ + { value: 'http://tva2.sinaimg.cn/crop.0.0.180.180.180/61c56ebcjw1e8qgp5bmzyj2050050aa8.jpg' }, + ], + _raw: '{}', + _json: { + id: '10086', + screen_name: 'mock_name', + displayName: 'mock displayName', + }, + }, + }, user); + + const createContext = this.createContext; + this.mm(this, 'createContext', (req, res) => { + req.user = user; + const ctx = createContext.call(this, req, res); + return ctx; + }); + }, + + /** + * mock a context instance with authenticated user + * + * @param {Object} [user] - mock user data + * @return {Context} ctx - context instance + */ + mockUserContext(user) { + this.mockUser(user); + const ctx = this.mockContext(); + // ctx.req is not the http request + // login, logout, isAuthenticated, isUnauthenticated + ctx.req.login = () => Promise.resolve(); + ctx.req.logout = () => {}; + ctx.req.isAuthenticated = () => true; + ctx.req.isUnauthenticated = () => false; + return ctx; + }, +}; diff --git a/app/extend/context.js b/app/extend/context.js new file mode 100644 index 0000000..298e995 --- /dev/null +++ b/app/extend/context.js @@ -0,0 +1,48 @@ +'use strict'; + +module.exports = { + get user() { + return this.req[this.app.passport._userProperty]; + }, + + // https://github.com/jaredhanson/passport/blob/master/lib/http/request.js + // proxy login, logout, isAuthenticated, isUnauthenticated to ctx.req + + /** + * Initiate a login session for `user`. + * + * @param {Object} user - authenticated user + * @param {Object} [options] - login options + * - {Boolean} options.session - Save login state in session, defaults to true + * @return {Promise} success or not promise instance + * + * @api public + */ + login(user, options) { + return new Promise((resolve, reject) => { + this.req.login(user, options, err => { + if (err) return reject(err); + resolve(); + }); + }); + }, + + /** + * Terminate an existing login session. + * + * @api public + */ + logout(...args) { + this.req.logout(...args); + }, + + /** + * Test if request is authenticated. + * + * @return {Boolean} - if authenticated return true + * @api public + */ + isAuthenticated() { + return this.req.isAuthenticated(); + }, +}; diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 2efd0fa..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,16 +0,0 @@ -environment: - matrix: - - nodejs_version: '4' - - nodejs_version: '6' - - nodejs_version: '7' - -install: - - ps: Install-Product node $env:nodejs_version - - npm i npminstall && node_modules\.bin\npminstall - -test_script: - - node --version - - npm --version - - npm run ci - -build: off diff --git a/config/config.default.js b/config/config.default.js deleted file mode 100644 index 3d0200e..0000000 --- a/config/config.default.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -/** - * @member Config#passport - * @property {String} SOME_KEY - some description - */ -exports.passport = { - -}; diff --git a/lib/framework.js b/lib/framework.js new file mode 100644 index 0000000..8b10fcd --- /dev/null +++ b/lib/framework.js @@ -0,0 +1,95 @@ +'use strict'; + +const debug = require('debug')('egg-passport:framework'); +const connectFramework = require('passport/lib/framework/connect')(); +const MockResponse = require('./response'); + +/** + * Framework support for egg + * @return {Object} framework + */ +module.exports = { + initialize, + session, + authenticate, +}; + +function initialize(passport) { + // https://github.com/jaredhanson/passport/blob/master/lib/middleware/initialize.js + return function* passportInitialize(next) { + const req = this.req; + req._passport = { + instance: passport, + }; + + // ref to ctx + req.ctx = this; + req.session = this.session; + req.query = this.query; + req.body = this.request.body; + + if (req.session && req.session[passport._key]) { + // load data from existing session + req._passport.session = req.session[passport._key]; + } + + yield next; + }; +} + +// refactor passport session with koa middleware for performance +// https://github.com/jaredhanson/passport/blob/master/lib/strategies/session.js +function session() { + return function* passportSession(next) { + const req = this.req; + let sessionUser; + if (req._passport.session) { + sessionUser = req._passport.session.user; + } + + if (sessionUser || sessionUser === 0) { + const user = yield this.app.passport.deserializeUser(this, sessionUser); + if (!user) { + req._passport.session.user = undefined; + } else { + req[this.app.passport._userProperty] = user; + } + } + + yield next; + }; +} + +function authenticate(passport, name, options) { + // Don't support authenticate with callback + + // function authenticate(req, res, next) + const connectMiddleware = connectFramework.authenticate(passport, name, options); + + debug('use authenticate:%s, options: %j', name, options); + return function* passportAuthenticate(next) { + const req = this.req; + const res = new MockResponse(this); + debug('do authenticate:%s -> %s %s %j', name, this.method, this.url, this.header); + + // A simple way to use connectMiddleware + let resEnd = false; + const p = new Promise((resolve, reject) => { + res.once('end', () => { + debug('response end on authenticate:%s -> %s %s', name, this.method, this.url); + resEnd = true; + resolve(); + }); + connectMiddleware(req, res, err => { + if (err) return reject(err); + resolve(); + }); + }); + yield p; + + // response end + if (resEnd) return; + + yield next; + }; +} diff --git a/lib/passport.js b/lib/passport.js index 21084bc..2b15c80 100644 --- a/lib/passport.js +++ b/lib/passport.js @@ -1,18 +1,158 @@ 'use strict'; -const KoaPassport = require('koa-passport').KoaPassport; +const debug = require('debug')('egg-passport:passport'); +const co = require('co'); +const Passport = require('passport').Passport; +const SessionStrategy = require('passport').strategies.SessionStrategy; +const framework = require('./framework'); -class EggPassport extends KoaPassport { - verify(handler) { +class EggPassport extends Passport { + constructor(app) { + super(); + + this.app = app; + this._verifyHooks = []; + this._serializeUserHooks = []; + this._deserializeUserHooks = []; + } + + /** + * Overide the initialize authenticator to make sure `__monkeypatchNode` run once. + */ + init() { + this.framework(framework); + this.use(new SessionStrategy()); + } + + /** + * Middleware that will authorize a third-party account using the given + * `strategy` name, with optional `options`. + * + * Examples: + * + * passport.authorize('twitter', { failureRedirect: '/account' }); + * + * @param {String} strategy - strategy provider name + * @param {Object} [options] - optional params + * @return {Function} middleware + * @api public + */ + authenticate(strategy, options = {}) { + // try to use successReturnToOrRedirect first + if (!options.hasOwnProperty('successRedirect') && !options.hasOwnProperty('successReturnToOrRedirect')) { + // app use set `ctx.session.returnTo = ctx.path` before auth redirect + options.successReturnToOrRedirect = '/'; + } + if (!options.hasOwnProperty('failWithError')) { + options.failWithError = true; + } + return super.authenticate(strategy, options); + } + + session() { + return this._framework.session(); + } + + mount(strategy, options = {}) { + options.loginURL = options.loginURL || `/passport/${strategy}`; + options.callbackURL = options.callbackURL || `/passport/${strategy}/callback`; + const auth = this.authenticate(strategy, options); + this.app.get(options.loginURL, auth); + this.app.get(options.callbackURL, auth); + } + + doVerify(req, user, done) { + const hooks = this._verifyHooks; + if (hooks.length === 0) return done(null, user); + co(function* () { + const ctx = req.ctx; + for (const handler of hooks) { + user = yield handler(ctx, user); + if (!user) { + break; + } + } + done(null, user); + }) + .catch(done); + } + + /** + * Verify authenticated user + * + * @param {Function} handler - verify handler + */ + verify(handler) { + this._verifyHooks.push(handler); } serializeUser(handler) { + if (typeof handler === 'function') { + // serializeUser(function* (ctx, user)) + this._serializeUserHooks.push(handler); + } else if (arguments.length === 3) { + // passport => http/request.js call passport.serializeUser(verifiedUser, req, done) + const verifiedUser = arguments[0]; + const req = arguments[1]; + const done = arguments[2]; + return this._handleSerializeUser(req.ctx, verifiedUser, done); + } else { + debug(arguments); + throw new Error('Unkown serializeUser called'); + } + } + + * deserializeUser(handler) { + if (typeof handler === 'function') { + // deserializeUser(function* (ctx, user)) + this._deserializeUserHooks.push(handler); + } else { + // yield passport.deserializeUser(ctx, sessionUser) + const ctx = arguments[0]; + const sessionUser = arguments[1]; + return yield this._handleDeserializeUser(ctx, sessionUser); + } + } + + _handleSerializeUser(ctx, verifiedUser, done) { + const hooks = this._serializeUserHooks; + debug('serializeUserHooks length: %d', hooks.length); + // make sure profile proerty cleanup + if (verifiedUser && verifiedUser.profile) { + verifiedUser.profile = undefined; + } + + if (hooks.length === 0) return done(null, verifiedUser); + co(function* () { + let sessionUser = verifiedUser; + for (const handler of hooks) { + sessionUser = yield handler(ctx, sessionUser); + if (!sessionUser) { + break; + } + } + debug('serializeUser %j => %j', verifiedUser, sessionUser); + done(null, sessionUser); + }) + .catch(done); } - deserializeUser(handler) { + * _handleDeserializeUser(ctx, sessionUser) { + const hooks = this._deserializeUserHooks; + debug('deserializeUserHooks length: %d', hooks.length); + if (hooks.length === 0) return sessionUser; + let user = sessionUser; + for (const handler of hooks) { + user = yield handler(ctx, user); + if (!user) { + break; + } + } + debug('serializeUser %j => %j', sessionUser, user); + return user; } } diff --git a/lib/response.js b/lib/response.js new file mode 100644 index 0000000..b2f229a --- /dev/null +++ b/lib/response.js @@ -0,0 +1,39 @@ +'use strict'; + +const debug = require('debug')('egg-passport:response'); +const EventEmitter = require('events'); + +class MockResponse extends EventEmitter { + constructor(ctx) { + super(); + this.ctx = ctx; + } + + redirect(url) { + debug('redirect -> %s', url); + this.ctx.redirect(url); + this.emit('end'); + } + + setHeader(...args) { + debug('setHeader -> %j', args); + this.ctx.set(...args); + } + + end(content) { + debug('end -> %j', content); + if (content) this.ctx.body = content; + this.emit('end'); + } + + set statusCode(status) { + debug('statusCode -> %s', status); + this.ctx.status = status; + } + + get statusCode() { + return this.ctx.status; + } +} + +module.exports = MockResponse; diff --git a/package.json b/package.json index b5b134d..269376a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "egg-passport", - "version": "0.0.0", + "version": "1.0.0", "description": "passport plugin for egg, base on passportjs", "eggPlugin": { "name": "passport", @@ -10,7 +10,10 @@ }, "keywords": [ "egg", - "plugin" + "egg-plugin", + "eggPlugin", + "egg-passport", + "passport" ], "files": [ "app", @@ -19,20 +22,25 @@ "app.js" ], "dependencies": { - "koa-passport": "^1.4.0" + "co": "^4.6.0", + "debug": "^2.6.1", + "passport": "^0.3.2" }, "devDependencies": { "autod": "^2.7.1", "egg": "^0.12.0", "egg-bin": "^2.2.0", - "egg-ci": "^1.1.0", "egg-mock": "^3.0.0", + "egg-passport-github": "*", + "egg-passport-twitter": "*", + "egg-passport-weibo": "*", "eslint": "^3.15.0", "eslint-config-egg": "^3.2.0", + "passport-localapikey": "^0.0.3", "supertest": "^3.0.0" }, "engines": { - "node": ">=4.0.0" + "node": ">=6.0.0" }, "scripts": { "test": "npm run lint && npm run test-local", @@ -42,9 +50,6 @@ "ci": "npm run lint && npm run cov", "autod": "autod" }, - "ci": { - "version": "4, 6, 7" - }, "repository": { "type": "git", "url": "git+https://github.com/eggjs/egg-passport.git" diff --git a/test/fixtures/plugin-test/app.js b/test/fixtures/plugin-test/app.js new file mode 100644 index 0000000..cf1a5a2 --- /dev/null +++ b/test/fixtures/plugin-test/app.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = app => { + app.passport.verify(function* (ctx, user) { + if (user.provider === 'localapikey') { + if (user.apikey === 'eggapp') { + user.name = 'eggapp'; + user.displayName = 'my name is egg'; + user.photo = 'https://zos.alipayobjects.com/rmsportal/JFKAMfmPehWfhBPdCjrw.svg'; + user.profile = { + _json: user, + }; + } else { + return null; + } + } + + return user; + }); + + app.passport.serializeUser(function* (ctx, user) { + user.currentUrl = ctx.url; + return user; + }); + + app.passport.deserializeUser(function* (ctx, user) { + user.lastUrl = user.currentUrl; + user.currentUrl = ctx.url; + return user; + }); +}; diff --git a/test/fixtures/plugin-test/app/controller/home.js b/test/fixtures/plugin-test/app/controller/home.js new file mode 100644 index 0000000..30d4e80 --- /dev/null +++ b/test/fixtures/plugin-test/app/controller/home.js @@ -0,0 +1,27 @@ +'use strict'; + +exports.index = function* () { + if (this.isAuthenticated()) { + this.body = `
+

${this.path}

+
+ Authenticated user: ${this.user.displayName} / ${this.user.id} | Logout +
${JSON.stringify(this.user, null, 2)}
+
+ Home | User +
`; + } else { + this.session.returnTo = this.path; + this.body = ` +
+

${this.path}

+
+ Login with + Weibo | Github | + Bitbucket | Twitter +
+ Home | User +
+ `; + } +}; diff --git a/test/fixtures/plugin-test/app/controller/user.js b/test/fixtures/plugin-test/app/controller/user.js new file mode 100644 index 0000000..46ba24c --- /dev/null +++ b/test/fixtures/plugin-test/app/controller/user.js @@ -0,0 +1,6 @@ +'use strict'; + +exports.logout = function* () { + this.logout(); + this.redirect(this.get('referer') || '/'); +}; diff --git a/test/fixtures/plugin-test/app/plugin/passport-localapikey/app.js b/test/fixtures/plugin-test/app/plugin/passport-localapikey/app.js new file mode 100644 index 0000000..f3026cf --- /dev/null +++ b/test/fixtures/plugin-test/app/plugin/passport-localapikey/app.js @@ -0,0 +1,24 @@ +'use strict'; + +const debug = require('debug')('egg-passport-localapikey'); +const Strategy = require('passport-localapikey').Strategy; + +module.exports = app => { + const config = app.config.passportLocalapikey; + // must set passReqToCallback to true + config.passReqToCallback = true; + + // register localapikey strategy into `app.passport` + // must require `req` params + app.passport.use('localapikey', new Strategy(config, (req, apikey, done) => { + // format user + const user = { + provider: 'localapikey', + id: apikey, + apikey, + }; + debug('%s %s get user: %j', req.method, req.url, user); + // let passport do verify and call verify hook + app.passport.doVerify(req, user, done); + })); +}; diff --git a/test/fixtures/plugin-test/app/plugin/passport-localapikey/config/config.default.js b/test/fixtures/plugin-test/app/plugin/passport-localapikey/config/config.default.js new file mode 100644 index 0000000..f028f78 --- /dev/null +++ b/test/fixtures/plugin-test/app/plugin/passport-localapikey/config/config.default.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.passportLocalapikey = {}; diff --git a/test/fixtures/plugin-test/app/plugin/passport-localapikey/package.json b/test/fixtures/plugin-test/app/plugin/passport-localapikey/package.json new file mode 100644 index 0000000..f141285 --- /dev/null +++ b/test/fixtures/plugin-test/app/plugin/passport-localapikey/package.json @@ -0,0 +1,9 @@ +{ + "name": "egg-passport-localapikey", + "eggPlugin": { + "name": "passportLocalapikey", + "dependencies": [ + "passport" + ] + } +} diff --git a/test/fixtures/plugin-test/app/router.js b/test/fixtures/plugin-test/app/router.js index 8bfd244..fe256b2 100644 --- a/test/fixtures/plugin-test/app/router.js +++ b/test/fixtures/plugin-test/app/router.js @@ -1,7 +1,27 @@ 'use strict'; module.exports = app => { - app.get('/', function* () { - this.body = 'hi, egg'; + app.get('/', 'home.index'); + app.get('/user', 'home.index'); + + const weiboAuth = app.passport.authenticate('weibo'); + app.get('/passport/weibo', weiboAuth); + app.get('/passport/weibo/callback', weiboAuth); + + const twitterAuth = app.passport.authenticate('twitter'); + app.get('/passport/twitter', twitterAuth); + app.get('/passport/twitter/callback', twitterAuth); + + const githubAuth = app.passport.authenticate('github', { + successReturnToOrRedirect: '/', }); + app.get('/passport/github', githubAuth); + app.get('/passport/github/callback', githubAuth); + // app.get('/passport/github/callback2', app.passport.authenticate('github', function* (err, user, info, status) { + // console.log(err, user, info, status); + // })); + + app.get('/passport/localapikey', app.passport.authenticate('localapikey')); + + app.get('/logout', 'user.logout'); }; diff --git a/test/fixtures/plugin-test/app/service/user.js b/test/fixtures/plugin-test/app/service/user.js new file mode 100644 index 0000000..523107f --- /dev/null +++ b/test/fixtures/plugin-test/app/service/user.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = app => { + return class UserService extends app.Service { + * find() { + return this.ctx.user; + } + }; +}; diff --git a/test/fixtures/plugin-test/config/config.default.js b/test/fixtures/plugin-test/config/config.default.js index 5365ab0..c8d02f2 100644 --- a/test/fixtures/plugin-test/config/config.default.js +++ b/test/fixtures/plugin-test/config/config.default.js @@ -1,3 +1,18 @@ 'use strict'; exports.keys = 'foo'; + +exports.passportWeibo = { + key: process.env.EGG_PASSPORT_WEIBO_CLIENT_ID, + secret: process.env.EGG_PASSPORT_WEIBO_CLIENT_SECRET, +}; + +exports.passportTwitter = { + key: process.env.EGG_PASSPORT_TWITTER_CONSUMER_KEY, + secret: process.env.EGG_PASSPORT_TWITTER_CONSUMER_SECRET, +}; + +exports.passportGithub = { + key: 'wrong-client-id', + secret: 'wrong-client-secret', +}; diff --git a/test/fixtures/plugin-test/config/plugin.js b/test/fixtures/plugin-test/config/plugin.js new file mode 100644 index 0000000..99ce3fa --- /dev/null +++ b/test/fixtures/plugin-test/config/plugin.js @@ -0,0 +1,23 @@ +'use strict'; + +const path = require('path'); + +exports.passportWeibo = { + enable: true, + package: 'egg-passport-weibo', +}; + +exports.passportTwitter = { + enable: true, + package: 'egg-passport-twitter', +}; + +exports.passportGithub = { + enable: true, + package: 'egg-passport-github', +}; + +exports.passportLocalapikey = { + enable: true, + path: path.join(__dirname, '../app/plugin/passport-localapikey'), +}; diff --git a/test/plugin.test.js b/test/plugin.test.js index 0d24016..d28803a 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -1,5 +1,6 @@ 'use strict'; +const assert = require('assert'); const request = require('supertest'); const mm = require('egg-mock'); @@ -14,9 +15,85 @@ describe('test/plugin.test.js', () => { after(() => app.close()); - it('should GET /', () => { + afterEach(mm.restore); + + it('should show login tips when user unauthenticated', () => { + return request(app.callback()) + .get('/') + .expect(/Login with/) + .expect(200); + }); + + it('should show authenticated user info', () => { + app.mockUser(); + return request(app.callback()) + .get('/') + .expect(/Authenticated user:/) + .expect(/mock displayName \/ 10086/) + .expect(200); + }); + + it('should get mock authenticated user context', function* () { + const ctx = app.mockUserContext(); + assert(ctx.user); + assert(ctx.user.id === '10086'); + assert(ctx.user.provider = 'mock'); + assert(ctx.isAuthenticated() === true); + + const user = yield ctx.service.user.find(); + assert(user); + }); + + it('should redirect to weibo oauth url', () => { + return request(app.callback()) + .get('/passport/weibo') + .expect(/Found/) + .expect('Location', /https:\/\/api.weibo.com\/oauth2\/authorize\?response_type=code&redirect_uri=/) + .expect(302); + }); + + it('should GET callback also redirect to weibo oauth url', () => { + return request(app.callback()) + .get('/passport/weibo/callback') + .expect(/Found/) + .expect('Location', /https:\/\/api.weibo.com\/oauth2\/authorize\?response_type=code&redirect_uri=/) + .expect(302); + }); + + it('should 401 when apikey missing', () => { return request(app.callback()) + .get('/passport/localapikey') + .expect(/AuthenticationError Error<\/title>/) + .expect(/Unauthorized/) + .expect(401); + }); + + it('should return 401 json format when apikey missing', () => { + return request(app.callback()) + .get('/passport/localapikey') + .set('Accept', 'application/json') + .expect({ + message: 'Unauthorized', + }) + .expect(401); + }); + + it('should auth success and redirect', function* () { + let cookie; + yield request(app.callback()) + .get('/passport/localapikey?apikey=eggapp') + .expect('Location', '/') + .expect(res => { + cookie = res.headers['set-cookie'].join(';'); + }) + .expect(302); + + assert(cookie); + yield request(app.callback()) .get('/') + .set('Cookie', cookie) + .expect(/Authenticated user/) + .expect(/my name is egg \/ eggapp/) .expect(200); }); });