diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index 84fce3e1f7..1086a2d634 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -187,25 +187,41 @@ module.exports = AuthenticationController = return doRequest requireOauth: () -> + # require this here because module may not be included in some versions + Oauth2Server = require "../../../../modules/oauth2-server/app/js/Oauth2Server" return (req, res, next = (error) ->) -> - return res.status(401).send() unless req.token? - options = - expectedStatusCodes: [401] - json: token: req.token - method: "POST" - uri: "/api/v1/sharelatex/oauth_authorize" - V1Api.request options, (error, response, body) -> + request = new Oauth2Server.Request(req) + response = new Oauth2Server.Response(res) + Oauth2Server.server.authenticate request, response, {}, (err, token) -> + if err? + # fall back to v1 on invalid token + return AuthenticationController._requireOauthV1Fallback req, res, next if err.code == 401 + # bubble up all other errors + return next(err) + req.oauth = + access_token: token.accessToken + req.oauth_token = token + req.oauth_user = token.user + return next() + + _requireOauthV1Fallback: (req, res, next) -> + return res.sendStatus 401 unless req.token? + options = + expectedStatusCodes: [401] + json: token: req.token + method: "POST" + uri: "/api/v1/sharelatex/oauth_authorize" + V1Api.request options, (error, response, body) -> + return next(error) if error? + return res.status(401).json({error: "invalid_token"}) unless body?.user_profile?.id + User.findOne { "overleaf.id": body.user_profile.id }, (error, user) -> return next(error) if error? - return res.status(401).json({error: "invalid_token"}) unless body?.user_profile?.id - User.findOne { "overleaf.id": body.user_profile.id }, (error, user) -> - return next(error) if error? - return res.status(401).send({error: "invalid_token"}) unless user? - req.oauth = - access_token: body.access_token - collabratec_customer_id: body.collabratec_customer_id - user_profile: body.user_profile - req.oauth_user = user - next() + return res.status(401).json({error: "invalid_token"}) unless user? + req.oauth = + access_token: body.access_token + user.collabratec_id = body.collabratec_customer_id unless user.collabratec_id? + req.oauth_user = user + next() _globalLoginWhitelist: [] addEndpointToLoginWhitelist: (endpoint) -> diff --git a/services/web/app/coffee/models/OauthAccessToken.coffee b/services/web/app/coffee/models/OauthAccessToken.coffee new file mode 100644 index 0000000000..22f68c743a --- /dev/null +++ b/services/web/app/coffee/models/OauthAccessToken.coffee @@ -0,0 +1,31 @@ +mongoose = require 'mongoose' +Settings = require 'settings-sharelatex' + +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +OauthAccessTokenSchema = new Schema( + { + accessToken: String + accessTokenExpiresAt: Date + oauthApplication_id: { type: ObjectId, ref: 'OauthApplication' } + refreshToken: String + refreshTokenExpiresAt: Date + scope: String + user_id: { type: ObjectId, ref: 'User' } + }, + { + collection: 'oauthAccessTokens' + } +) + +conn = mongoose.createConnection(Settings.mongo.url, { + server: {poolSize: Settings.mongo.poolSize || 10}, + config: {autoIndex: false} +}) + +OauthAccessToken = conn.model('OauthAccessToken', OauthAccessTokenSchema) + +mongoose.model 'OauthAccessToken', OauthAccessTokenSchema +exports.OauthAccessToken = OauthAccessToken +exports.OauthAccessTokenSchema = OauthAccessTokenSchema diff --git a/services/web/app/coffee/models/OauthApplication.coffee b/services/web/app/coffee/models/OauthApplication.coffee new file mode 100644 index 0000000000..af68376d45 --- /dev/null +++ b/services/web/app/coffee/models/OauthApplication.coffee @@ -0,0 +1,30 @@ +mongoose = require 'mongoose' +Settings = require 'settings-sharelatex' + +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +OauthApplicationSchema = new Schema( + { + id: String + clientSecret: String + grants: [ String ] + name: String + redirectUris: [ String ] + scopes: [ String ] + }, + { + collection: 'oauthApplications' + } +) + +conn = mongoose.createConnection(Settings.mongo.url, { + server: {poolSize: Settings.mongo.poolSize || 10}, + config: {autoIndex: false} +}) + +OauthApplication = conn.model('OauthApplication', OauthApplicationSchema) + +mongoose.model 'OauthApplication', OauthApplicationSchema +exports.OauthApplication = OauthApplication +exports.OauthApplicationSchema = OauthApplicationSchema diff --git a/services/web/app/coffee/models/OauthAuthorizationCode.coffee b/services/web/app/coffee/models/OauthAuthorizationCode.coffee new file mode 100644 index 0000000000..a37a469a1e --- /dev/null +++ b/services/web/app/coffee/models/OauthAuthorizationCode.coffee @@ -0,0 +1,30 @@ +mongoose = require 'mongoose' +Settings = require 'settings-sharelatex' + +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +OauthAuthorizationCodeSchema = new Schema( + { + authorizationCode: String + expiresAt: Date + oauthApplication_id: { type: ObjectId, ref: 'OauthApplication' } + redirectUri: String + scope: String + user_id: { type: ObjectId, ref: 'User' } + }, + { + collection: 'oauthAuthorizationCodes' + } +) + +conn = mongoose.createConnection(Settings.mongo.url, { + server: {poolSize: Settings.mongo.poolSize || 10}, + config: {autoIndex: false} +}) + +OauthAuthorizationCode = conn.model('OauthAuthorizationCode', OauthAuthorizationCodeSchema) + +mongoose.model 'OauthAuthorizationCode', OauthAuthorizationCodeSchema +exports.OauthAuthorizationCode = OauthAuthorizationCode +exports.OauthAuthorizationCodeSchema = OauthAuthorizationCodeSchema diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json index 7c3e73381b..d1b6123477 100644 --- a/services/web/npm-shrinkwrap.json +++ b/services/web/npm-shrinkwrap.json @@ -2139,6 +2139,18 @@ "resolved": "https://registry.npmjs.org/base64url/-/base64url-2.0.0.tgz", "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=" }, + "basic-auth": { + "version": "2.0.1", + "from": "basic-auth@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "from": "safe-buffer@5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + } + } + }, "basic-auth-connect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz", @@ -3550,6 +3562,16 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" }, + "co-bluebird": { + "version": "1.1.0", + "from": "co-bluebird@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/co-bluebird/-/co-bluebird-1.1.0.tgz" + }, + "co-use": { + "version": "1.1.0", + "from": "co-use@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/co-use/-/co-use-1.1.0.tgz" + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -8431,6 +8453,11 @@ "number-is-nan": "^1.0.0" } }, + "is-generator": { + "version": "1.0.3", + "from": "is-generator@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/is-generator/-/is-generator-1.0.3.tgz" + }, "is-glob": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", @@ -12576,6 +12603,43 @@ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" }, + "oauth2-server": { + "version": "3.0.1", + "from": "oauth2-server@latest", + "resolved": "https://registry.npmjs.org/oauth2-server/-/oauth2-server-3.0.1.tgz", + "dependencies": { + "bluebird": { + "version": "3.5.3", + "from": "bluebird@>=3.5.1 <4.0.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.3.tgz" + }, + "lodash": { + "version": "4.17.11", + "from": "lodash@>=4.17.10 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz" + }, + "mime-db": { + "version": "1.38.0", + "from": "mime-db@>=1.38.0 <1.39.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.38.0.tgz" + }, + "mime-types": { + "version": "2.1.22", + "from": "mime-types@>=2.1.18 <2.2.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.22.tgz" + }, + "statuses": { + "version": "1.5.0", + "from": "statuses@>=1.5.0 <2.0.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + }, + "type-is": { + "version": "1.6.16", + "from": "type-is@>=1.6.16 <2.0.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz" + } + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -14074,6 +14138,11 @@ "is-promise": "~1" } }, + "promisify-any": { + "version": "2.0.1", + "from": "promisify-any@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/promisify-any/-/promisify-any-2.0.1.tgz" + }, "promisify-call": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz", diff --git a/services/web/package.json b/services/web/package.json index 1f4bdf6390..046702b46f 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -79,6 +79,7 @@ "nodemailer-sendgrid-transport": "^0.2.0", "nodemailer-ses-transport": "^1.3.0", "nvd3": "^1.8.6", + "oauth2-server": "^3.0.1", "optimist": "0.6.1", "overleaf-error-type": "git+https://github.com/overleaf/overleaf-error-type.git", "passport": "^0.3.2", diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index 7f17d9d0a4..4c9502fb58 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee @@ -37,6 +37,10 @@ describe "AuthenticationController", -> ipMatcherAffiliation: sinon.stub() "../V1/V1Api": @V1Api = request: sinon.stub() "../../models/User": { User: @UserModel } + "../../../../modules/oauth2-server/app/js/Oauth2Server": @Oauth2Server = + Request: sinon.stub() + Response: sinon.stub() + server: authenticate: sinon.stub() @user = _id: ObjectId() email: @email = "USER@example.com" @@ -402,90 +406,115 @@ describe "AuthenticationController", -> describe "requireOauth", -> beforeEach -> + @res.sendStatus = sinon.stub() @res.send = sinon.stub() @res.status = sinon.stub().returns(@res) @middleware = @AuthenticationController.requireOauth() - describe "when token not provided", -> + describe "when Oauth2Server authenticates", -> beforeEach -> + @token = + accessToken: "token" + user: "user" + @Oauth2Server.server.authenticate.yields null, @token @middleware(@req, @res, @next) - it "should return 401 error", -> - @res.status.should.have.been.calledWith 401 + it "should set oauth_token on request", -> + @req.oauth_token.should.equal @token - describe "when token provided", -> + it "should set oauth on request", -> + @req.oauth.access_token.should.equal @token.accessToken + + it "should set oauth_user on request", -> + @req.oauth_user.should.equal "user" + + it "should call next", -> + @next.should.have.been.calledOnce + + describe "when Oauth2Server does not authenticate", -> beforeEach -> - @V1Api.request = sinon.stub().yields("error", {}, {}) - @req.token = "foo" - @middleware(@req, @res, @next) + @Oauth2Server.server.authenticate.yields code: 401 - it "should make request to v1 api with token", -> - @V1Api.request.should.have.been.calledWith { - expectedStatusCodes: [401] - json: token: "foo" - method: "POST" - uri: "/api/v1/sharelatex/oauth_authorize" - } - - describe "when v1 api returns error", -> - beforeEach -> - @V1Api.request = sinon.stub().yields("error", {}, {}) - @req.token = "foo" - @middleware(@req, @res, @next) - - it "should return status", -> - @next.should.have.been.calledWith "error" - - describe "when v1 api status code is not 200", -> - beforeEach -> - @V1Api.request = sinon.stub().yields(null, {statusCode: 401}, {}) - @req.token = "foo" - @middleware(@req, @res, @next) - - it "should return status", -> - @res.status.should.have.been.calledWith 401 - - describe "when v1 api returns authorized profile and access token", -> - beforeEach -> - @oauth_authorize = - access_token: "access_token" - user_profile: id: "overleaf-id" - @V1Api.request = sinon.stub().yields(null, {statusCode: 200}, @oauth_authorize) - @req.token = "foo" - - describe "in all cases", -> + describe "when token not provided", -> beforeEach -> @middleware(@req, @res, @next) - it "should find user", -> - @UserModel.findOne.should.have.been.calledWithMatch { "overleaf.id": "overleaf-id" } + it "should return 401 error", -> + @res.sendStatus.should.have.been.calledWith 401 - describe "when user find returns error", -> + describe "when token provided", -> beforeEach -> - @UserModel.findOne = sinon.stub().yields("error") + @V1Api.request = sinon.stub().yields("error", {}, {}) + @req.token = "foo" @middleware(@req, @res, @next) - it "should return error", -> + it "should make request to v1 api with token", -> + @V1Api.request.should.have.been.calledWith { + expectedStatusCodes: [401] + json: token: "foo" + method: "POST" + uri: "/api/v1/sharelatex/oauth_authorize" + } + + describe "when v1 api returns error", -> + beforeEach -> + @V1Api.request = sinon.stub().yields("error", {}, {}) + @req.token = "foo" + @middleware(@req, @res, @next) + + it "should return status", -> @next.should.have.been.calledWith "error" - describe "when user is not found", -> + describe "when v1 api status code is not 200", -> beforeEach -> - @UserModel.findOne = sinon.stub().yields(null, null) + @V1Api.request = sinon.stub().yields(null, {statusCode: 401}, {}) + @req.token = "foo" @middleware(@req, @res, @next) - it "should return unauthorized", -> + it "should return status", -> @res.status.should.have.been.calledWith 401 - describe "when user is found", -> + describe "when v1 api returns authorized profile and access token", -> beforeEach -> - @UserModel.findOne = sinon.stub().yields(null, "user") - @middleware(@req, @res, @next) + @oauth_authorize = + access_token: "access_token" + user_profile: id: "overleaf-id" + @V1Api.request = sinon.stub().yields(null, {statusCode: 200}, @oauth_authorize) + @req.token = "foo" - it "should add user to request", -> - @req.oauth_user.should.equal "user" + describe "in all cases", -> + beforeEach -> + @middleware(@req, @res, @next) - it "should add access_token to request", -> - @req.oauth.access_token.should.equal "access_token" + it "should find user", -> + @UserModel.findOne.should.have.been.calledWithMatch { "overleaf.id": "overleaf-id" } + + describe "when user find returns error", -> + beforeEach -> + @UserModel.findOne = sinon.stub().yields("error") + @middleware(@req, @res, @next) + + it "should return error", -> + @next.should.have.been.calledWith "error" + + describe "when user is not found", -> + beforeEach -> + @UserModel.findOne = sinon.stub().yields(null, null) + @middleware(@req, @res, @next) + + it "should return unauthorized", -> + @res.status.should.have.been.calledWith 401 + + describe "when user is found", -> + beforeEach -> + @UserModel.findOne = sinon.stub().yields(null, "user") + @middleware(@req, @res, @next) + + it "should add user to request", -> + @req.oauth_user.should.equal "user" + + it "should add access_token to request", -> + @req.oauth.access_token.should.equal "access_token" describe "requireGlobalLogin", -> beforeEach ->