Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ Jump directly to a version:
| | [2.0.8](#208) |
</details>

__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)
Expand Down
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions src/Auth.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -27,6 +30,65 @@ function Auth({
this.rolePromise = null;
}

function base64url(source) {
var encodedSource = CryptoJS.enc.Base64.stringify(source);

encodedSource = encodedSource.replace(/=+$/, '');

encodedSource = encodedSource.replace(/\+/g, '-');
encodedSource = encodedSource.replace(/\//g, '_');

return encodedSource;
}

const generateRefreshToken = function () {
return SHA256(CryptoJS.lib.WordArray.random(256)).toString();
};

const createJWT = function (sessionToken, oauthKey, oauthTTL) {
const header = {
alg: 'HS256',
typ: 'JWT',
};

const stringifiedHeader = CryptoJS.enc.Utf8.parse(JSON.stringify(header));
const encodedHeader = base64url(stringifiedHeader);

const timestamp = Math.floor(new Date().getTime() / 1000);
const expiration = timestamp + oauthTTL;
Comment thread
dplewis marked this conversation as resolved.

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);

return {
accessToken: token + '.' + signature,
expires_in: expiration,
};
};

const validJWT = function (token, secret) {
try {
return jwt.verify(token, secret);
} catch (err) {
return false;
}
};

const decodeJWT = function (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 () {
Expand Down Expand Up @@ -63,6 +125,15 @@ const getAuthForSessionToken = async function ({
}) {
cacheController = cacheController || (config && config.cacheController);
if (cacheController) {
// Check if you use OAuth to retrieve the sessionToken from within the JWT
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);
Expand Down Expand Up @@ -321,6 +392,12 @@ const createSession = function (
sessionData.installationId = installationId;
}

// Check if you use OAuth to retrieve the sessionToken from within the JWT
// Generate a random hash
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');
Expand All @@ -339,5 +416,9 @@ module.exports = {
readOnly,
getAuthForSessionToken,
getAuthForLegacySessionToken,
generateRefreshToken,
createSession,
createJWT,
validJWT,
decodeJWT,
};
16 changes: 16 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
176 changes: 173 additions & 3 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,148 @@ export class UsersRouter extends ClassesRouter {
});
}

handleCreate(req) {
return rest
.create(
req.config,
req.auth,
this.className(req),
req.body,
req.info.clientSDK,
req.info.context
)
.then(res => {
if (req.config.oauth20 === true) {
const sessionToken = res.response.sessionToken;
return rest
.find(
req.config,
Auth.master(req.config),
'_Session',
{ sessionToken },
{ include: 'user' },
req.info.clientSDK,
req.info.context
)
.then(result => {
const user = result.results[0];
const token = Auth.createJWT(
res.response.sessionToken,
req.config.oauthKey,
req.config.oauthTTL
);
res.response.accessToken = token.accessToken;
res.response.refreshToken = user.refreshToken;
res.response.expiresIn = token.expires_in;
delete res.response.sessionToken;
return res;
});
}
return res;
});
}

handleRefresh(req) {
const payload = req.body;
const { refreshToken } = payload;

if (!refreshToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid update token');
}

return rest
.find(
req.config,
Auth.master(req.config),
'_Session',
{ refreshToken },
{ include: 'user' },
req.info.clientSDK,
req.info.context
)
.then(response => {
if (!response.results || response.results.length == 0 || !response.results[0].user) {
throw new Parse.Error(
Parse.Error.INVALID_SESSION_TOKEN,
'Invalid update token or ClientID'
);
} else {
const data = response.results[0];
const newCode = Auth.generateRefreshToken();
const sessionId = data.objectId;
const token = Auth.createJWT(data.sessionToken, req.config.oauthKey, req.config.oauthTTL);

req.config.database.update(
'_Session',
{ objectId: sessionId },
{ refreshToken: newCode }
);

return {
response: {
accesstoken: token.accessToken,
refreshToken: newCode,
expiresIn: token.expires_in,
},
};
}
});
}

handleRevoke(req) {
const payload = req.body;
const { refreshToken } = payload;
const success = { response: {} };

if (!refreshToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
}

return rest
.find(
req.config,
Auth.master(req.config),
'_Session',
{ refreshToken: refreshToken },
undefined,
req.info.clientSDK,
req.info.context
)
.then(records => {
if (records.results && records.results.length) {
return rest
.del(
req.config,
Auth.master(req.config),
'_Session',
records.results[0].objectId,
req.info.context
)
.then(() => {
this._runAfterLogoutTrigger(req, records.results[0]);
return Promise.resolve(success);
});
}
return Promise.resolve(success);
});
}

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,
Expand All @@ -160,7 +297,13 @@ export class UsersRouter extends ClassesRouter {
} 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);
user.accessToken = originalToken;
user.expiresIn = decoded.exp;
} else {
user.sessionToken = sessionToken;
}

// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
Expand Down Expand Up @@ -227,7 +370,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.expiresIn = signedToken.expires_in;

delete user.sessionToken;
} else {
user.sessionToken = sessionData.sessionToken;
}

await createSession();

Expand Down Expand Up @@ -259,6 +417,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,
Expand Down Expand Up @@ -402,6 +566,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', '/revoke', req => {
return this.handleRevoke(req);
});
this.route('GET', '/users/:objectId', req => {
return this.handleGet(req);
});
Expand Down