diff --git a/CHANGELOG.md b/CHANGELOG.md index c462fc24fa..6b589a30fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,9 @@ Jump directly to a version: | | [2.0.8](#208) | +__BREAKING CHANGES:__ +- NEW: Added a OAuth 2.0 method to authentication. [#7248](https://github.com/parse-community/parse-server/issues/7248). +- NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy), [Manuel Trezza](https://github.com/mtrezza). ___ ## Unreleased (Master Branch) [Full Changelog](https://github.com/parse-community/parse-server/compare/4.5.0...master) diff --git a/package-lock.json b/package-lock.json index 4943c87033..723af7d6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4036,8 +4036,7 @@ "crypto-js": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz", - "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==", - "optional": true + "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==" }, "css-select": { "version": "1.2.0", diff --git a/package.json b/package.json index 06313bf389..7d96d6eefe 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "body-parser": "1.19.0", "commander": "5.1.0", "cors": "2.8.5", + "crypto-js": "4.0.0", "deepcopy": "2.1.0", "express": "4.17.1", "follow-redirects": "1.13.2", diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index 5ed6bfe941..dd6d20ffdd 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -1,7 +1,13 @@ 'use strict'; describe('Auth', () => { - const { Auth, getAuthForSessionToken } = require('../lib/Auth.js'); + const { + Auth, + getAuthForSessionToken, + createJWT, + validJWT, + decodeJWT, + } = require('../lib/Auth.js'); const Config = require('../lib/Config'); describe('getUserRoles', () => { let auth; @@ -123,35 +129,6 @@ describe('Auth', () => { expect(userAuth.user.id).toBe(user.id); }); - it('should load auth without a config', async () => { - const user = new Parse.User(); - await user.signUp({ - username: 'hello', - password: 'password', - }); - expect(user.getSessionToken()).not.toBeUndefined(); - const userAuth = await getAuthForSessionToken({ - sessionToken: user.getSessionToken(), - }); - expect(userAuth.user instanceof Parse.User).toBe(true); - expect(userAuth.user.id).toBe(user.id); - }); - - it('should load auth with a config', async () => { - const user = new Parse.User(); - await user.signUp({ - username: 'hello', - password: 'password', - }); - expect(user.getSessionToken()).not.toBeUndefined(); - const userAuth = await getAuthForSessionToken({ - sessionToken: user.getSessionToken(), - config: Config.get('test'), - }); - expect(userAuth.user instanceof Parse.User).toBe(true); - expect(userAuth.user.id).toBe(user.id); - }); - describe('getRolesForUser', () => { const rolesNumber = 100; @@ -241,4 +218,31 @@ describe('Auth', () => { expect(cloudRoles2.length).toBe(rolesNumber); }); }); + + describe('OAuth2.0 JWT', () => { + it('should handle jwt', async () => { + const oauthKey = 'jwt-secret'; + const oauthTTL = 100; + const user = new Parse.User(); + await user.signUp({ + username: 'jwt-test', + password: 'jwt-password', + }); + const sessionToken = user.getSessionToken(); + + const jwt = createJWT(sessionToken, oauthKey, oauthTTL); + expect(jwt.accessToken).toBeDefined(); + expect(jwt.expires_in).toBeDefined(); + + const isValid = validJWT('invalid', oauthKey); + expect(isValid).toBe(false); + + const result = validJWT(jwt.accessToken, oauthKey); + expect(result.sub).toBe(sessionToken); + expect(result.exp).toBeDefined(); + + const decoded = decodeJWT(jwt.accessToken); + expect(result).toEqual(decoded); + }); + }); }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index be636e53cb..40ffadb567 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -3929,6 +3929,146 @@ describe('Parse.User testing', () => { } }); + it('user signup with JWT', async () => { + const oauthKey = 'jwt-secret'; + const oauthTTL = 100; + await reconfigureServer({ + oauth20: true, + oauthKey, + oauthTTL, + }); + let response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + username: 'jwt-test', + password: 'jwt-password', + }, + }); + const { accessToken, refreshToken, expiresAt, sessionToken } = response.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(expiresAt).toBeDefined(); + expect(sessionToken).toBeUndefined(); + + response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/refresh', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken, + }, + }); + const jwt = response.data; + expect(jwt.accessToken).toBe(accessToken); + expect(jwt.expiresAt).toBeDefined(); + expect(jwt.refreshToken).not.toBe(refreshToken); + + const query = new Parse.Query('_Session'); + query.equalTo('refreshToken', jwt.refreshToken); + let session = await query.first({ useMasterKey: true }); + expect(session).toBeDefined(); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/revoke', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken: jwt.refreshToken, + }, + }); + session = await query.first({ useMasterKey: true }); + expect(session).toBeUndefined(); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/refresh', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken: jwt.refreshToken, + }, + }); + fail(); + } catch (response) { + const { code, error } = response.data; + expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(error).toBe('Invalid refresh token'); + } + + response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/revoke', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken: jwt.refreshToken, + }, + }); + expect(response.data).toEqual({}); + }); + + it('handle JWT errors', async () => { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/refresh', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken: null, + }, + }); + fail(); + } catch (response) { + const { code, error } = response.data; + expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(error).toBe('Invalid refresh token'); + } + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/users/revoke', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + refreshToken: null, + }, + }); + fail(); + } catch (response) { + const { code, error } = response.data; + expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(error).toBe('Invalid refresh token'); + } + }); + describe('issue #4897', () => { it_only_db('mongo')('should be able to login with a legacy user (no ACL)', async () => { // This issue is a side effect of the locked users and legacy users which don't have ACL's diff --git a/src/Auth.js b/src/Auth.js index 2d63e785be..34f3eb9796 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,6 +1,9 @@ +const CryptoJS = require('crypto-js'); const cryptoUtils = require('./cryptoUtils'); +const jwt = require('jsonwebtoken'); const RestQuery = require('./RestQuery'); const Parse = require('parse/node'); +const SHA256 = require('crypto-js/sha256'); // An Auth object tells you who is requesting something and whether // the master key was used. @@ -27,6 +30,59 @@ function Auth({ this.rolePromise = null; } +const base64url = source => { + let encodedSource = CryptoJS.enc.Base64.stringify(source); + encodedSource = encodedSource.replace(/=+$/, ''); + encodedSource = encodedSource.replace(/\+/g, '-'); + encodedSource = encodedSource.replace(/\//g, '_'); + return encodedSource; +}; + +const generateRefreshToken = () => { + return SHA256(CryptoJS.lib.WordArray.random(256)).toString(); +}; + +const createJWT = (sessionToken, oauthKey, oauthTTL) => { + const header = { + alg: 'HS256', + typ: 'JWT', + }; + const stringifiedHeader = CryptoJS.enc.Utf8.parse(JSON.stringify(header)); + const encodedHeader = base64url(stringifiedHeader); + const currentTime = new Date(); + const timestamp = Math.floor(currentTime.getTime() / 1000); + const expiration = timestamp + oauthTTL; + const data = { + sub: sessionToken, + iat: timestamp, + exp: expiration, + }; + const stringifiedData = CryptoJS.enc.Utf8.parse(JSON.stringify(data)); + const encodedData = base64url(stringifiedData); + const token = encodedHeader + '.' + encodedData; + + let signature = CryptoJS.HmacSHA256(token, oauthKey); + signature = base64url(signature); + currentTime.setSeconds(currentTime.getSeconds() + oauthTTL); + + return { + accessToken: token + '.' + signature, + expires_in: { __type: 'Date', iso: currentTime.toISOString() }, + }; +}; + +const validJWT = (token, secret) => { + try { + return jwt.verify(token, secret); + } catch (err) { + return false; + } +}; + +const decodeJWT = token => { + return jwt.decode(token); +}; + // Whether this auth could possibly modify the given user id. // It still could be forbidden via ACLs even if this returns true. Auth.prototype.isUnauthenticated = function () { @@ -63,6 +119,13 @@ const getAuthForSessionToken = async function ({ }) { cacheController = cacheController || (config && config.cacheController); if (cacheController) { + if (config.oauth20 === true) { + if (validJWT(sessionToken, config.oauthKey) === false) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); + } + const decoded = decodeJWT(sessionToken); + sessionToken = decoded.sub; + } const userJSON = await cacheController.user.get(sessionToken); if (userJSON) { const cachedUser = Parse.Object.fromJSON(userJSON); @@ -321,6 +384,10 @@ const createSession = function ( sessionData.installationId = installationId; } + if (config.oauth20 === true) { + sessionData.refreshToken = generateRefreshToken(); + } + Object.assign(sessionData, additionalSessionData); // We need to import RestWrite at this point for the cyclic dependency it has to it const RestWrite = require('./RestWrite'); @@ -339,5 +406,9 @@ module.exports = { readOnly, getAuthForSessionToken, getAuthForLegacySessionToken, + generateRefreshToken, createSession, + createJWT, + validJWT, + decodeJWT, }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index de92038964..f32fcb6102 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -353,6 +353,22 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_REST_API_KEY', help: 'Key for REST calls', }, + oauth20: { + env: 'PARSE_SERVER_OAUTH_20', + help: 'Sets whether to use the OAuth protocol', + action: parsers.booleanParser, + default: false, + }, + oauthKey: { + env: 'PARSE_SERVER_OAUTH_KEY', + help: 'Key for OAuth protocol', + }, + oauthTTL: { + env: 'PARSE_SERVER_OAUTH_TTL', + help: 'The JSON Web Token (JWT) expiration TTL', + action: parsers.numberParser('oauthTTL'), + default: 1800, + }, revokeSessionOnPasswordReset: { env: 'PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET', help: diff --git a/src/Options/index.js b/src/Options/index.js index 6b4a504801..b0db0d2165 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -84,6 +84,17 @@ export interface ParseServerOptions { /* Key for REST calls :ENV: PARSE_SERVER_REST_API_KEY */ restAPIKey: ?string; + /* Enable (or disable) the addition of OAuth 2.0 + :ENV: PARSE_SERVER_OAUTH_20 + :DEFAULT: false */ + oauth20: ?boolean; + /* Key for OAuth 2.0 + :ENV: PARSE_SERVER_OAUTH_KEY */ + oauthKey: ?string; + /* The TTL for Access Token + :ENV: PARSE_SERVER_OAUTH_TTL + :DEFAULT: 1800 - 30 minutes */ + oauthTTL: ?number; /* Read-only key, which has the same capabilities as MasterKey without writes */ readOnlyMasterKey: ?string; /* Key sent with outgoing webhook calls */ diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7843cf4674..ea51a8a495 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -139,11 +139,115 @@ export class UsersRouter extends ClassesRouter { }); } + async handleCreate(req) { + const res = await rest.create( + req.config, + req.auth, + this.className(req), + req.body, + req.info.clientSDK, + req.info.context + ); + if (req.config.oauth20 === true) { + const sessionToken = res.response.sessionToken; + const result = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken }, + { include: 'user' }, + req.info.clientSDK, + req.info.context + ); + const user = result.results[0]; + const token = Auth.createJWT(sessionToken, req.config.oauthKey, req.config.oauthTTL); + res.response.accessToken = token.accessToken; + res.response.refreshToken = user.refreshToken; + res.response.expiresAt = token.expires_in; + delete res.response.sessionToken; + } + return res; + } + + async handleRefresh(req) { + const { refreshToken } = req.body; + if (!refreshToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token'); + } + const res = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { refreshToken }, + { include: 'user' }, + req.info.clientSDK, + req.info.context + ); + if (!res.results || res.results.length == 0 || !res.results[0].user) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token'); + } else { + const data = res.results[0]; + const newCode = Auth.generateRefreshToken(); + const sessionId = data.objectId; + const token = Auth.createJWT(data.sessionToken, req.config.oauthKey, req.config.oauthTTL); + await req.config.database.update( + '_Session', + { objectId: sessionId }, + { refreshToken: newCode } + ); + return { + response: { + accessToken: token.accessToken, + refreshToken: newCode, + expiresAt: token.expires_in, + }, + }; + } + } + + async handleRevoke(req) { + const { refreshToken } = req.body; + if (!refreshToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token'); + } + const res = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { refreshToken: refreshToken }, + undefined, + req.info.clientSDK, + req.info.context + ); + if (res.results && res.results.length) { + await rest.del( + req.config, + Auth.master(req.config), + '_Session', + res.results[0].objectId, + req.info.context + ); + this._runAfterLogoutTrigger(req, res.results[0]); + } + return { response: {} }; + } + handleMe(req) { if (!req.info || !req.info.sessionToken) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); } - const sessionToken = req.info.sessionToken; + let sessionToken = req.info.sessionToken; + const originalToken = req.info.sessionToken; + + // Check if you use OAuth to retrieve the sessionToken from within the JWT + if (req.config.oauth20 === true) { + if (Auth.validJWT(sessionToken, req.config.oauthKey) === false) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); + } + const decoded = Auth.decodeJWT(sessionToken); + sessionToken = decoded.sub; + } + return rest .find( req.config, @@ -159,8 +263,14 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); } else { const user = response.results[0].user; - // Send token back on the login, because SDKs expect that. - user.sessionToken = sessionToken; + if (req.config.oauth20 === true) { + const decoded = Auth.decodeJWT(originalToken); + const expiresDate = new Date(decoded.exp * 1000); + user.accessToken = originalToken; + user.expiresAt = { __type: 'Date', iso: expiresDate.toISOString() }; + } else { + user.sessionToken = sessionToken; + } // Remove hidden properties. UsersRouter.removeHiddenProperties(user); @@ -227,7 +337,22 @@ export class UsersRouter extends ClassesRouter { installationId: req.info.installationId, }); - user.sessionToken = sessionData.sessionToken; + // Check if you use OAuth to generate a JWT to return + if (req.config.oauth20 === true) { + var signedToken = Auth.createJWT( + sessionData.sessionToken, + req.config.oauthKey, + req.config.oauthTTL + ); + + user.accessToken = signedToken.accessToken; + user.refreshToken = sessionData.refreshToken; + user.expiresAt = signedToken.expires_in; + + delete user.sessionToken; + } else { + user.sessionToken = sessionData.sessionToken; + } await createSession(); @@ -259,6 +384,12 @@ export class UsersRouter extends ClassesRouter { handleLogOut(req) { const success = { response: {} }; if (req.info && req.info.sessionToken) { + // Check if you use OAuth to retrieve the sessionToken from within the JWT + if (req.config.oauth20 === true) { + const decoded = Auth.decodeJWT(req.info.sessionToken); + req.info.sessionToken = decoded.sub; + } + return rest .find( req.config, @@ -402,6 +533,12 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/users/me', req => { return this.handleMe(req); }); + this.route('POST', '/users/refresh', req => { + return this.handleRefresh(req); + }); + this.route('POST', '/users/revoke', req => { + return this.handleRevoke(req); + }); this.route('GET', '/users/:objectId', req => { return this.handleGet(req); });