From 1f54626a3380870c77055bc8608c4aed40dad361 Mon Sep 17 00:00:00 2001 From: Evert Date: Wed, 23 Aug 2017 23:13:45 +0300 Subject: [PATCH] OAuth2 provider implemented --- migrations/20170801231334_initial.js | 9 +- server/api/models.js | 2 +- server/api/oauth2/controller/authorization.js | 152 +++++++++++ server/api/oauth2/controller/code/code.js | 28 ++ server/api/oauth2/controller/code/implicit.js | 27 ++ server/api/oauth2/controller/code/index.js | 4 + server/api/oauth2/controller/decision.js | 5 + server/api/oauth2/controller/index.js | 5 + server/api/oauth2/controller/introspection.js | 54 ++++ server/api/oauth2/controller/token.js | 88 ++++++ .../controller/tokens/authorizationCode.js | 68 +++++ .../controller/tokens/clientCredentials.js | 28 ++ server/api/oauth2/controller/tokens/index.js | 11 + .../api/oauth2/controller/tokens/password.js | 68 +++++ .../oauth2/controller/tokens/refreshToken.js | 72 +++++ server/api/oauth2/error.js | 107 ++++++++ server/api/oauth2/index.js | 27 ++ server/api/oauth2/middleware.js | 48 ++++ server/api/oauth2/model.js | 258 ++++++++++++++++++ server/api/oauth2/response.js | 60 ++++ server/routes/index.js | 11 + server/routes/oauth2.js | 59 ++++ server/worker.js | 5 + src/style/main.styl | 119 +++++++- views/authorization.pug | 61 +++++ views/index.pug | 13 + views/register.pug | 6 +- 27 files changed, 1385 insertions(+), 10 deletions(-) create mode 100644 server/api/oauth2/controller/authorization.js create mode 100644 server/api/oauth2/controller/code/code.js create mode 100644 server/api/oauth2/controller/code/implicit.js create mode 100644 server/api/oauth2/controller/code/index.js create mode 100644 server/api/oauth2/controller/decision.js create mode 100644 server/api/oauth2/controller/index.js create mode 100644 server/api/oauth2/controller/introspection.js create mode 100644 server/api/oauth2/controller/token.js create mode 100644 server/api/oauth2/controller/tokens/authorizationCode.js create mode 100644 server/api/oauth2/controller/tokens/clientCredentials.js create mode 100644 server/api/oauth2/controller/tokens/index.js create mode 100644 server/api/oauth2/controller/tokens/password.js create mode 100644 server/api/oauth2/controller/tokens/refreshToken.js create mode 100644 server/api/oauth2/error.js create mode 100644 server/api/oauth2/index.js create mode 100644 server/api/oauth2/middleware.js create mode 100644 server/api/oauth2/model.js create mode 100644 server/api/oauth2/response.js create mode 100644 server/routes/oauth2.js create mode 100644 views/authorization.pug diff --git a/migrations/20170801231334_initial.js b/migrations/20170801231334_initial.js index 9006f65..5f82847 100644 --- a/migrations/20170801231334_initial.js +++ b/migrations/20170801231334_initial.js @@ -50,7 +50,7 @@ exports.up = function(knex, Promise) { table.text('redirect_url') table.text('icon') table.text('secret') - table.text('scopes') + table.text('scope') table.text('grants') table.integer('user_id').unsigned().notNullable() @@ -66,6 +66,7 @@ exports.up = function(knex, Promise) { table.integer('user_id').unsigned().notNullable() table.integer('client_id').unsigned().notNullable() + table.text('scope') table.dateTime('expires_at') table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE') @@ -77,7 +78,7 @@ exports.up = function(knex, Promise) { table.integer('user_id').unsigned().notNullable() table.integer('client_id').unsigned().notNullable() table.text('code') - table.text('scopes') + table.text('scope') table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE') @@ -90,7 +91,7 @@ exports.up = function(knex, Promise) { table.integer('user_id').unsigned().notNullable() table.integer('client_id').unsigned().notNullable() table.text('token') - table.text('scopes') + table.text('scope') table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE') @@ -103,7 +104,7 @@ exports.up = function(knex, Promise) { table.integer('user_id').unsigned().notNullable() table.integer('client_id').unsigned().notNullable() table.text('token') - table.text('scopes') + table.text('scope') table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE') diff --git a/server/api/models.js b/server/api/models.js index 0aa6aa4..ae74915 100644 --- a/server/api/models.js +++ b/server/api/models.js @@ -32,7 +32,7 @@ class OAuth2AuthorizedClient extends Model { class OAuth2Code extends Model { static get tableName () { - return 'oauth2_client_authorization' + return 'oauth2_code' } } diff --git a/server/api/oauth2/controller/authorization.js b/server/api/oauth2/controller/authorization.js new file mode 100644 index 0000000..d1fa7f1 --- /dev/null +++ b/server/api/oauth2/controller/authorization.js @@ -0,0 +1,152 @@ +import error from '../error' +import response from '../response' +import model from '../model' +import authorization from './code' +import wrap from '../../../../scripts/asyncRoute' + +const usermodel = model.user + +module.exports = wrap(async (req, res, next) => { + let clientId = null + let redirectUri = null + let responseType = null + let grantType = null + let scope = null + let user = null + + if (!req.query.redirect_uri) { + return response.error(req, res, new error.InvalidRequest('redirect_uri field is mandatory for authorization endpoint'), redirectUri) + } + + redirectUri = req.query.redirect_uri + console.debug('Parameter redirect uri is', redirectUri) + + if (!req.query.client_id) { + return response.error(req, res, new error.InvalidRequest('client_id field is mandatory for authorization endpoint'), redirectUri) + } + + // Check for client_secret (prevent passing it) + if (req.query.client_secret) { + return response.error(req, res, new error.InvalidRequest('client_secret field should not be passed to the authorization endpoint'), redirectUri) + } + + clientId = req.query.client_id + console.debug('Parameter client_id is', clientId) + + if (!req.query.response_type) { + return response.error(req, res, new error.InvalidRequest('response_type field is mandatory for authorization endpoint'), redirectUri) + } + + responseType = req.query.response_type + console.debug('Parameter response_type is', responseType) + + switch (responseType) { + case 'code': + grantType = 'authorization_code' + break + case 'token': + grantType = 'implicit' + break + default: + return response.error(req, res, new error.UnsupportedResponseType('Unknown response_type parameter passed'), redirectUri) + } + console.debug('Parameter response_type is', responseType) + + let client = await req.oauth2.model.client.fetchById(clientId) + if (!client) { + return response.error(req, res, new error.InvalidClient('Client not found'), redirectUri) + } + + if (!req.oauth2.model.client.getRedirectUri(client)) { + return response.error(req, res, new error.UnsupportedResponseType('The client has not set a redirect uri'), redirectUri) + } else if (!req.oauth2.model.client.checkRedirectUri(client, redirectUri)) { + return response.error(req, res, new error.InvalidRequest('Wrong RedirectUri provided'), redirectUri) + } else { + console.debug('redirect_uri check passed') + } + + if (!req.oauth2.model.client.checkGrantType(client, grantType)) { + return response.error(req, res, new error.UnauthorizedClient('This client does not support this grant type'), redirectUri) + } else { + console.debug('Grant type check passed') + } + + scope = req.oauth2.model.client.transformScope(req.query.scope) + scope = req.oauth2.model.client.checkScope(client, scope) + if (!scope) { + return response.error(req, res, new error.InvalidScope('Client does not allow access to this scope'), redirectUri) + } else { + console.debug('Scope check passed') + } + + user = await req.oauth2.model.user.fetchFromRequest(req) + if (!user) { + return response.error(req, res, new error.InvalidRequest('There is no currently logged in user'), redirectUri) + } else { + if (!user.username) { + return response.error(req, res, new error.Forbidden(user), redirectUri) + } + console.debug('User fetched from request') + } + + let data = null + + if (req.method === 'GET') { + let hasAuthorizedAlready = await usermodel.clientAllowed(user.id, client.id, scope) + if (client.verified === 1) { + hasAuthorizedAlready = true + } + + if (hasAuthorizedAlready) { + if (grantType === 'authorization_code') { + try { + data = await authorization.Code(req, res, client, scope, user, redirectUri, false) + } catch (err) { + return response.error(req, res, err, redirectUri) + } + + return response.data(req, res, { code: data }, redirectUri) + } else if (grantType === 'implicit') { + try { + data = await authorization.Implicit(req, res, client, scope, user, redirectUri, false) + } catch (err) { + return response.error(req, res, err, redirectUri) + } + + return response.data(req, res, { + token_type: 'bearer', + access_token: data, + expires_in: req.oauth2.model.accessToken.ttl + }, redirectUri) + } + } else { + return req.oauth2.decision(req, res, client, scope, user, redirectUri) + } + + return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri) + } + + if (grantType === 'authorization_code') { + try { + data = await authorization.Code(req, res, client, scope, user, redirectUri, true) + } catch (err) { + return response.error(req, res, err, redirectUri) + } + + return response.data(req, res, { code: data }, redirectUri) + } else if (grantType === 'implicit') { + try { + data = await authorization.Implicit(req, res, client, scope, user, redirectUri, true) + } catch (err) { + return response.error(req, res, err, redirectUri) + } + + return response.data(req, res, { + token_type: 'bearer', + access_token: data, + expires_in: req.oauth2.model.accessToken.ttl + }, redirectUri) + } else { + return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri) + } +}) diff --git a/server/api/oauth2/controller/code/code.js b/server/api/oauth2/controller/code/code.js new file mode 100644 index 0000000..feeee78 --- /dev/null +++ b/server/api/oauth2/controller/code/code.js @@ -0,0 +1,28 @@ +import error from '../../error' +import model from '../../model' + +module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => { + let codeValue = null + + if (createAllowFuture) { + if (!req.body || (typeof req.body['decision']) === 'undefined') { + throw new error.InvalidRequest('No decision parameter passed') + } else if (req.body['decision'] === '0') { + throw new error.AccessDenied('User denied access to the resource') + } else { + console.debug('Decision check passed') + } + + await model.user.allowClient(user.id, client.id, scope) + } + + try { + codeValue = await req.oauth2.model.code.create(req.oauth2.model.user.getId(user), + req.oauth2.model.client.getId(client), scope, req.oauth2.model.code.ttl) + } catch (err) { + console.error(err) + throw new error.ServerError('Failed to call code.create function') + } + + return codeValue +} diff --git a/server/api/oauth2/controller/code/implicit.js b/server/api/oauth2/controller/code/implicit.js new file mode 100644 index 0000000..1c807ad --- /dev/null +++ b/server/api/oauth2/controller/code/implicit.js @@ -0,0 +1,27 @@ +import error from '../../error' +import model from '../../model' + +module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => { + let accessTokenValue = null + + if (createAllowFuture) { + if (!req.body || (typeof req.body['decision']) === 'undefined') { + throw new error.InvalidRequest('No decision parameter passed') + } else if (req.body['decision'] === 0) { + throw new error.AccessDenied('User denied access to the resource') + } else { + console.debug('Decision check passed') + } + + await model.user.allowClient(user.id, client.id, scope) + } + + try { + accessTokenValue = await req.oauth2.model.accessToken.create(req.oauth2.model.user.getId(user), + req.oauth2.model.client.getId(client), scope, req.oauth2.model.accessToken.ttl) + } catch (err) { + throw new error.ServerError('Failed to call accessToken.create function') + } + + return accessTokenValue +} diff --git a/server/api/oauth2/controller/code/index.js b/server/api/oauth2/controller/code/index.js new file mode 100644 index 0000000..df2b072 --- /dev/null +++ b/server/api/oauth2/controller/code/index.js @@ -0,0 +1,4 @@ +module.exports = { + Code: require('./code'), + Implicit: require('./implicit') +} diff --git a/server/api/oauth2/controller/decision.js b/server/api/oauth2/controller/decision.js new file mode 100644 index 0000000..c239dd3 --- /dev/null +++ b/server/api/oauth2/controller/decision.js @@ -0,0 +1,5 @@ +module.exports = function (req, res, client, scope, user) { + res.locals.client = client + res.locals.scope = scope + res.render('authorization') +} diff --git a/server/api/oauth2/controller/index.js b/server/api/oauth2/controller/index.js new file mode 100644 index 0000000..f8a028b --- /dev/null +++ b/server/api/oauth2/controller/index.js @@ -0,0 +1,5 @@ +module.exports = { + authorization: require('./authorization'), + introspection: require('./introspection'), + token: require('./token') +} diff --git a/server/api/oauth2/controller/introspection.js b/server/api/oauth2/controller/introspection.js new file mode 100644 index 0000000..2b71463 --- /dev/null +++ b/server/api/oauth2/controller/introspection.js @@ -0,0 +1,54 @@ +import error from '../error' +import response from '../response' +import wrap from '../../../../scripts/asyncRoute' + +module.exports = wrap(async function (req, res) { + let clientId = null + let clientSecret = null + + if (req.body.client_id && req.body.client_secret) { + clientId = req.body.client_id + clientSecret = req.body.client_secret + console.debug('Client credentials parsed from body parameters ', clientId, clientSecret) + } else { + if (!req.headers || !req.headers.authorization) { + return response.error(req, res, new error.InvalidRequest('No authorization header passed')) + } + + let pieces = req.headers.authorization.split(' ', 2) + if (!pieces || pieces.length !== 2) { + return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted')) + } + + if (pieces[0] !== 'Basic') { + return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0])) + } + + pieces = new Buffer(pieces[1], 'base64').toString('ascii').split(':', 2) + if (!pieces || pieces.length !== 2) { + return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data')) + } + + clientId = pieces[0] + clientSecret = pieces[1] + console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret) + } + + if (!req.body.token) { + return response.error(req, res, new error.InvalidRequest('Token not provided in request body')) + } + + let token = await req.oauth2.model.accessToken.fetchByToken(req.body.token) + if (!token) { + return response.error(req, res, new error.InvalidRequest('Token does not exist')) + } + + let ttl = req.oauth2.model.accessToken.getTTL(token) + let resObj = { + token_type: 'bearer', + token: token.token, + expires_in: Math.floor(ttl / 1000) + } + + response.data(req, res, resObj) +}) diff --git a/server/api/oauth2/controller/token.js b/server/api/oauth2/controller/token.js new file mode 100644 index 0000000..d42108f --- /dev/null +++ b/server/api/oauth2/controller/token.js @@ -0,0 +1,88 @@ +import token from './tokens' +import error from '../error' +import response from '../response' +import wrap from '../../../../scripts/asyncRoute' + +module.exports = wrap(async (req, res) => { + let clientId = null + let clientSecret = null + let grantType = null + + if (req.body.client_id && req.body.client_secret) { + clientId = req.body.client_id + clientSecret = req.body.client_secret + console.debug('Client credentials parsed from body parameters', clientId, clientSecret) + } else { + if (!req.headers || !req.headers.authorization) { + return response.error(req, res, new error.InvalidRequest('No authorization header passed')) + } + + let pieces = req.headers.authorization.split(' ', 2) + if (!pieces || pieces.length !== 2) { + return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted')) + } + + if (pieces[0] !== 'Basic') { + return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0])) + } + + pieces = new Buffer(pieces[1], 'base64').toString('ascii').split(':', 2) + if (!pieces || pieces.length !== 2) { + return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data')) + } + + clientId = pieces[0] + clientSecret = pieces[1] + console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret) + } + + if (!req.body.grant_type) { + return response.error(req, res, new error.InvalidRequest('Request body does not contain grant_type parameter')) + } + + grantType = req.body.grant_type + console.debug('Parameter grant_type is', grantType) + + let client = await req.oauth2.model.client.fetchById(clientId) + + if (!client) { + return response.error(req, res, new error.InvalidClient('Client not found')) + } + + let valid = req.oauth2.model.client.checkSecret(client, clientSecret) + if (!valid) { + return response.error(req, res, new error.UnauthorizedClient('Invalid client secret')) + } + + if (!req.oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'refresh_token') { + return response.error(req, res, new error.UnauthorizedClient('Invalid grant type for the client')) + } else { + console.debug('Grant type check passed') + } + + let evt + try { + switch (grantType) { + case 'authorization_code': + evt = await token.authorizationCode(req.oauth2, client, req.body.code, req.body.redirect_uri) + break + case 'password': + evt = await token.password(req.oauth2, client, req.body.username, req.body.password, req.body.scope) + break + case 'client_credentials': + evt = await token.clientCredentials(req.oauth2, client, req.body.scope) + break + case 'refresh_token': + evt = await token.refreshToken(req.oauth2, client, req.body.refresh_token, req.body.scope) + break + default: + throw new error.UnsupportedGrantType('Grant type does not match any supported type') + } + + if (evt) { + response.data(req, res, evt) + } + } catch (e) { + response.error(req, res, e) + } +}) diff --git a/server/api/oauth2/controller/tokens/authorizationCode.js b/server/api/oauth2/controller/tokens/authorizationCode.js new file mode 100644 index 0000000..53582c6 --- /dev/null +++ b/server/api/oauth2/controller/tokens/authorizationCode.js @@ -0,0 +1,68 @@ +import error from '../../error' + +module.exports = async (oauth2, client, providedCode, redirectUri) => { + let respObj = { + token_type: 'bearer' + } + + let code = null + + try { + code = await oauth2.model.code.fetchByCode(providedCode) + } catch (err) { + throw new error.ServerError('Failed to call code.fetchByCode function') + } + + if (code) { + if (oauth2.model.code.getClientId(code) !== oauth2.model.client.getId(client)) { + throw new error.InvalidGrant('Code was issued by another client') + } + + if (!oauth2.model.code.checkTTL(code)) { + throw new error.InvalidGrant('Code has already expired') + } + } else { + throw new error.InvalidGrant('Code not found') + } + + console.debug('Code fetched ', code) + + try { + await oauth2.model.refreshToken.removeByUserIdClientId(oauth2.model.code.getUserId(code), oauth2.model.code.getClientId(code)) + } catch (err) { + console.error(err) + throw new error.ServerError('Failed to call refreshToken.removeByUserIdClientId function') + } + + console.debug('Refresh token removed') + + if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) { + console.debug('Client does not allow grant type refresh_token, skip creation') + } else { + try { + respObj.refresh_token = await oauth2.model.refreshToken.create(oauth2.model.code.getUserId(code), oauth2.model.code.getClientId(code), oauth2.model.code.getScope(code)) + } catch (err) { + console.error(err) + throw new error.ServerError('Failed to call refreshToken.create function') + } + } + + try { + respObj.access_token = await oauth2.model.accessToken.create(oauth2.model.code.getUserId(code), oauth2.model.code.getClientId(code), oauth2.model.code.getScope(code), oauth2.model.accessToken.ttl) + } catch (err) { + console.error(err) + throw new error.ServerError('Failed to call accessToken.create function') + } + + respObj.expires_in = oauth2.model.accessToken.ttl + console.debug('Access token saved: ', respObj.access_token) + + try { + await oauth2.model.code.removeByCode(providedCode) + } catch (err) { + console.error(err) + throw new error.ServerError('Failed to call code.removeByCode function') + } + + return respObj +} diff --git a/server/api/oauth2/controller/tokens/clientCredentials.js b/server/api/oauth2/controller/tokens/clientCredentials.js new file mode 100644 index 0000000..bddabc4 --- /dev/null +++ b/server/api/oauth2/controller/tokens/clientCredentials.js @@ -0,0 +1,28 @@ +import error from '../../error' + +module.exports = async (oauth2, client, wantScope) => { + let scope = null + + let resObj = { + token_type: 'bearer' + } + + scope = oauth2.model.client.transformScope(wantScope) + scope = oauth2.model.client.checkScope(client, scope) + + if (!scope) { + throw new error.InvalidScope('Client does not allow access to this scope') + } else { + console.debug('Scope check passed ', scope) + } + + try { + resObj.access_token = await oauth2.model.accessToken.create(null, oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl) + } catch (err) { + throw new error.ServerError('Failed to call accessToken.create function') + } + + resObj.expires_in = oauth2.model.accessToken.ttl + + return resObj +} diff --git a/server/api/oauth2/controller/tokens/index.js b/server/api/oauth2/controller/tokens/index.js new file mode 100644 index 0000000..40a0fd0 --- /dev/null +++ b/server/api/oauth2/controller/tokens/index.js @@ -0,0 +1,11 @@ +import authorizationCode from './authorizationCode' +import clientCredentials from './clientCredentials' +import password from './password' +import refreshToken from './refreshToken' + +module.exports = { + authorizationCode: authorizationCode, + clientCredentials: clientCredentials, + password: password, + refreshToken: refreshToken +} diff --git a/server/api/oauth2/controller/tokens/password.js b/server/api/oauth2/controller/tokens/password.js new file mode 100644 index 0000000..3511e73 --- /dev/null +++ b/server/api/oauth2/controller/tokens/password.js @@ -0,0 +1,68 @@ +import error from '../../error' + +module.exports = async (oauth2, client, username, password, scope) => { + let user = null + let resObj = { + token_type: 'bearer' + } + + if (!username) { + throw new error.InvalidRequest('Username is mandatory for password grant type') + } + + if (!password) { + throw new error.InvalidRequest('Password is mandatory for password grant type') + } + + scope = oauth2.model.client.transformScope(scope) + scope = oauth2.model.client.checkScope(client, scope) + if (!scope) { + throw new error.InvalidScope('Client does not allow access to this scope') + } else { + console.debug('Scope check passed: ', scope) + } + + try { + user = await oauth2.model.user.fetchByUsername(username) + } catch (err) { + throw new error.ServerError('Failed to call user.fetchByUsername function') + } + + if (!user) { + throw new error.InvalidClient('User not found') + } + + let valid = await oauth2.model.user.checkPassword(user, password) + if (!valid) { + throw new error.InvalidClient('Wrong password') + } + + try { + await oauth2.model.refreshToken.removeByUserIdClientId(oauth2.model.user.getId(user), oauth2.model.client.getId(client)) + } catch (err) { + throw new error.ServerError('Failed to call refreshToken.removeByUserIdClientId function') + } + + console.debug('Refresh token removed') + + if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) { + console.debug('Client does not allow grant type refresh_token, skip creation') + } else { + try { + resObj.refresh_token = await oauth2.model.refreshToken.create(oauth2.model.user.getId(user), oauth2.model.client.getId(client), scope) + } catch (err) { + throw new error.ServerError('Failed to call refreshToken.create function') + } + } + + try { + resObj.access_token = await oauth2.model.accessToken.create(oauth2.model.user.getId(user), oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl) + } catch (err) { + throw new error.ServerError('Failed to call accessToken.create function') + } + + resObj.expires_in = oauth2.model.accessToken.ttl + console.debug('Access token saved ', resObj.access_token) + + return resObj +} diff --git a/server/api/oauth2/controller/tokens/refreshToken.js b/server/api/oauth2/controller/tokens/refreshToken.js new file mode 100644 index 0000000..6b8ed9e --- /dev/null +++ b/server/api/oauth2/controller/tokens/refreshToken.js @@ -0,0 +1,72 @@ +import error from '../../error' + +module.exports = async (oauth2, client, pRefreshToken, scope) => { + let user = null + let ttl = null + let refreshToken = null + let accessToken = null + + let resObj = { + token_type: 'bearer' + } + + if (!pRefreshToken) { + throw new error.InvalidRequest('refresh_token is mandatory for refresh_token grant type') + } + + try { + refreshToken = await oauth2.model.refreshToken.fetchByToken(pRefreshToken) + } catch (err) { + throw new error.ServerError('Failed to call refreshToken.fetchByToken function') + } + + if (!refreshToken) { + throw new error.InvalidGrant('Refresh token not found') + } + + if (oauth2.model.refreshToken.getClientId(refreshToken) !== oauth2.model.client.getId(client)) { + console.warn('Client "' + oauth2.model.client.getId(client) + '" tried to fetch a refresh token which belongs to client"' + + oauth2.model.refreshToken.getClientId(refreshToken) + '"') + throw new error.InvalidGrant('Refresh token not found') + } + + try { + user = await oauth2.model.user.fetchById(oauth2.model.refreshToken.getUserId(refreshToken)) + } catch (err) { + throw new error.ServerError('Failed to call user.fetchById function') + } + + if (!user) { + throw new error.InvalidClient('User not found') + } + + try { + accessToken = await oauth2.model.accessToken.fetchByUserIdClientId(oauth2.model.user.getId(user), oauth2.model.client.getId(client)) + } catch (err) { + throw new error.ServerError('Failed to call accessToken.fetchByUserIdClientId function') + } + + if (accessToken) { + ttl = oauth2.model.accessToken.getTTL(accessToken) + + if (!ttl) { + accessToken = null + } else { + resObj.access_token = oauth2.model.accessToken.getToken(accessToken) + resObj.expires_in = ttl + } + } + + if (!accessToken) { + try { + resObj.access_token = await oauth2.model.accessToken.create(oauth2.model.user.getId(user), + oauth2.model.client.getId(client), oauth2.model.refreshToken.getScope(refreshToken), oauth2.model.accessToken.ttl) + } catch (err) { + throw new error.ServerError('Failed to call accessToken.create function') + } + + resObj.expires_in = oauth2.model.accessToken.ttl + } + + return resObj +} diff --git a/server/api/oauth2/error.js b/server/api/oauth2/error.js new file mode 100644 index 0000000..18226aa --- /dev/null +++ b/server/api/oauth2/error.js @@ -0,0 +1,107 @@ +class OAuth2Error extends Error { + constructor (code, msg, status) { + super() + Error.captureStackTrace(this, this.constructor) + + this.code = code + this.message = msg + this.status = status + + this.name = 'OAuth2AbstractError' + this.logLevel = 'error' + } +} + +class AccessDenied extends OAuth2Error { + constructor (msg) { + super('access_denied', msg, 403) + + this.name = 'OAuth2AccessDenied' + this.logLevel = 'info' + } +} + +class InvalidClient extends OAuth2Error { + constructor (msg) { + super('invalid_client', msg, 401) + + this.name = 'OAuth2InvalidClient' + this.logLevel = 'info' + } +} + +class InvalidGrant extends OAuth2Error { + constructor (msg) { + super('invalid_grant', msg, 400) + + this.name = 'OAuth2InvalidGrant' + this.logLevel = 'info' + } +} + +class InvalidRequest extends OAuth2Error { + constructor (msg) { + super('invalid_request', msg, 400) + + this.name = 'OAuth2InvalidRequest' + this.logLevel = 'info' + } +} + +class InvalidScope extends OAuth2Error { + constructor (msg) { + super('invalid_scope', msg, 400) + + this.name = 'OAuth2InvalidScope' + this.logLevel = 'info' + } +} + +class ServerError extends OAuth2Error { + constructor (msg) { + super('server_error', msg, 500) + + this.name = 'OAuth2ServerError' + this.logLevel = 'error' + } +} + +class UnauthorizedClient extends OAuth2Error { + constructor (msg) { + super('unauthorized_client', msg, 400) + + this.name = 'OAuth2UnauthorizedClient' + this.logLevel = 'info' + } +} + +class UnsupportedGrantType extends OAuth2Error { + constructor (msg) { + super('unsupported_grant_type', msg, 400) + + this.name = 'OAuth2UnsupportedGrantType' + this.logLevel = 'info' + } +} + +class UnsupportedResponseType extends OAuth2Error { + constructor (msg) { + super('unsupported_response_type', msg, 400) + + this.name = 'OAuth2UnsupportedResponseType' + this.logLevel = 'info' + } +} + +module.exports = { + OAuth2Error: OAuth2Error, + AccessDenied: AccessDenied, + InvalidClient: InvalidClient, + InvalidGrant: InvalidGrant, + InvalidRequest: InvalidRequest, + InvalidScope: InvalidScope, + ServerError: ServerError, + UnauthorizedClient: UnauthorizedClient, + UnsupportedGrantType: UnsupportedGrantType, + UnsupportedResponseType: UnsupportedResponseType +} diff --git a/server/api/oauth2/index.js b/server/api/oauth2/index.js new file mode 100644 index 0000000..e5be603 --- /dev/null +++ b/server/api/oauth2/index.js @@ -0,0 +1,27 @@ +import middleware from './middleware' +import controller from './controller' +import decision from './controller/decision' +import response from './response' +import error from './error' +import model from './model' + +class OAuth2Provider { + constructor () { + this.bearer = middleware + this.controller = controller + this.decision = decision + this.response = response + this.error = error.OAuth2Error + this.model = model + } + + express () { + return (req, res, next) => { + console.debug('attached') + req.oauth2 = this + next() + } + } +} + +module.exports = OAuth2Provider diff --git a/server/api/oauth2/middleware.js b/server/api/oauth2/middleware.js new file mode 100644 index 0000000..16b21eb --- /dev/null +++ b/server/api/oauth2/middleware.js @@ -0,0 +1,48 @@ +import response from './response' +import error from './error' +import wrap from '../../../scripts/asyncRoute' + +const middleware = wrap(async function (req, res, next) { + console.debug('Parsing bearer token') + let token = null + + // Look for token in header + if (req.headers.authorization) { + const pieces = req.headers.authorization.split(' ', 2) + + // Check authorization header + if (!pieces || pieces.length !== 2) { + return response.error(req, res, new error.AccessDenied('Wrong authorization header')) + } + + // Only bearer auth is supported + if (pieces[0].toLowerCase() !== 'bearer') { + return response.error(req, res, new error.AccessDenied('Unsupported authorization method in header')) + } + + token = pieces[1] + console.debug('Bearer token parsed from authorization header:', token) + } else if (req.query && req.query['access_token']) { + token = req.query['access_token'] + console.debug('Bearer token parsed from query params:', token) + } else if (req.body && req.body['access_token']) { + token = req.body['access_token'] + console.debug('Bearer token parsed from body params:', token) + } else { + return response.error(req, res, new error.AccessDenied('Bearer token not found')) + } + + // Try to fetch access token + let object = await req.oauth2.model.accessToken.fetchByToken(token) + if (!object) { + response.error(req, res, new error.Forbidden('Token not found or has expired')) + } else if (!req.oauth2.model.accessToken.checkTTL(object)) { + response.error(req, res, new error.Forbidden('Token is expired')) + } else { + req.oauth2.accessToken = object + console.debug('AccessToken fetched', object) + next() + } +}) + +module.exports = middleware diff --git a/server/api/oauth2/model.js b/server/api/oauth2/model.js new file mode 100644 index 0000000..c996e5e --- /dev/null +++ b/server/api/oauth2/model.js @@ -0,0 +1,258 @@ +import config from '../../../scripts/load-config' +import database from '../../../scripts/load-database' +import Models from '../models' +import Users from '../index' + +import crypto from 'crypto' + +const OAuthDB = { + accessToken: { + ttl: config.oauth2.access_token_life, + getToken: (object) => { + if (object) return object.token + return null + }, + create: async (userId, clientId, scope, ttl) => { + const token = crypto.randomBytes(config.oauth2.token_length).toString('hex') + const expr = new Date(Date.now() + ttl * 1000) + + if (typeof scope === 'object') { + scope = scope.join(' ') + } + + // Delete already existing tokens with this exact user id, client id and scope, because it will + // eventually pile up and flood the database. + await Models.OAuth2AccessToken.query().delete().where('user_id', userId) + .andWhere('client_id', clientId) + + const obj = { token: token, user_id: userId, client_id: clientId, scope: scope, expires_at: expr, created_at: new Date() } + + let res = await Models.OAuth2AccessToken.query().insert(obj) + if (!res) return null + + return res.token + }, + fetchByToken: async (token) => { + if (typeof token === 'object') { + return token + } + + token = await Models.OAuth2AccessToken.query().where('token', token) + if (!token.length) return null + + return token[0] + }, + checkTTL: (object) => { + return (object.expires_at > Date.now()) + }, + getTTL: (object) => { + return (object.expires_at - Date.now()) + }, + fetchByUserIdClientId: async (userId, clientId) => { + let tkn = await Models.OAuth2AccessToken.query().where('user_id', userId).andWhere('client_id', clientId) + + if (!tkn.length) return null + + return tkn[0] + } + }, + client: { + getId: (client) => { + return client.id + }, + fetchById: async (id) => { + let client = await Models.OAuth2Client.query().where('id', id) + + if (!client.length) return null + + return client[0] + }, + checkSecret: (client, secret) => { + return client.secret === secret + }, + checkGrantType: (client, grant) => { + if (client.grants.indexOf(grant) !== -1) { + return true + } + + return false + }, + getRedirectUri: (client) => { + return client.redirect_url + }, + checkRedirectUri: (client, redirectUri) => { + return (redirectUri.indexOf(OAuthDB.client.getRedirectUri(client)) === 0 && + redirectUri.replace(OAuthDB.client.getRedirectUri(client), '').indexOf('#') === -1) + }, + transformScope: (scope) => { + if (!scope) return [] + if (typeof scope === 'object') { + return scope + } + + scope = scope.trim() + if (scope.indexOf(',') != -1) { + scope = scope.split(',') + } else { + scope = scope.split(' ') + } + + return scope + }, + checkScope: (client, scope) => { + if (!scope) return [] + if (typeof scope === 'string') { + scope = OAuthDB.client.transformScope(scope) + } + + let clientScopes = client.scope.split(' ') + + for (let i in scope) { + if (clientScopes.indexOf(scope[i]) === -1) { + return false + } + } + + return scope + } + }, + code: { + ttl: config.oauth2.code_life, + create: async (userId, clientId, scope, ttl) => { + const code = crypto.randomBytes(config.oauth2.token_length).toString('hex') + const expr = new Date(Date.now() + ttl * 1000) + + if (typeof scope === 'object') { + scope = scope.join(' ') + } + + // Delete already existing codes with this exact user id, client id and scope, because it will + // eventually pile up and flood the database, especially when they were never used. + await Models.OAuth2Code.query().delete().where('user_id', userId).andWhere('client_id', clientId) + + const obj = { code: code, user_id: userId, client_id: clientId, scope: scope, expires_at: expr, created_at: new Date() } + console.log(obj) + + await Models.OAuth2Code.query().insert(obj) + + return obj.code + }, + fetchByCode: async (code) => { + code = await Models.OAuth2Code.query().where('code', code) + + if (!code.length) return null + + return code[0] + }, + removeByCode: async (code) => { + if (typeof code === 'object') { + code = code.code + } + + return await Models.OAuth2Code.query().delete().where('code', code) + }, + getUserId: (code) => { + return code.user_id + }, + getClientId: (code) => { + return code.client_id + }, + getScope: (code) => { + return code.scope + }, + checkTTL: (code) => { + return (code.expires_at > Date.now()) + } + }, + refreshToken: { + create: async (userId, clientId, scope) => { + const token = crypto.randomBytes(config.oauth2.token_length).toString('hex') + + if (typeof scope === 'object') { + scope = scope.join(' ') + } + + const obj = { token: token, user_id: userId, client_id: clientId, scope: scope, created_at: new Date() } + + await Models.OAuth2RefreshToken.query().insert(obj) + + return obj.token + }, + fetchByToken: async (token) => { + token = await Models.OAuth2RefreshToken.query().where('token', token) + + if (!token.length) return null + + return token[0] + }, + removeByUserIdClientId: async (userId, clientId) => { + return await Models.OAuth2RefreshToken.query().delete().where('user_id', userId) + .andWhere('client_id', clientId) + }, + removeByRefreshToken: async (token) => { + return await Models.OAuth2RefreshToken.query().delete().where('token', token) + }, + getUserId: (refreshToken) => { + return refreshToken.user_id + }, + getClientId: (refreshToken) => { + return refreshToken.client_id + }, + getScope: (refreshToken) => { + return refreshToken.scope + } + }, + user: { + getId: (user) => { + return user.id + }, + fetchById: Users.User.get, + fetchByUsername: Users.User.get, + checkPassword: Users.User.Login.password, + fetchFromRequest: async (req) => { + if (!req.session.user) return null + return req.session.user + }, + clientAllowed: async (userId, clientId, scope) => { + if (typeof scope === 'object') { + scope = scope.join(' ') + } + + let authorized = await Models.OAuth2AuthorizedClient.query().where('user_id', userId) + if (!authorized.length) return false + + let correct = false + for (let i in authorized) { + if (authorized[i].client_id === clientId) { + correct = authorized[i] + } + } + + if (correct) { + if (correct.scope !== scope) { + await Models.OAuth2AuthorizedClient.query().delete().where('user_id', userId) + .andWhere('client_id', correct.client_id) + + return false + } + + correct = true + } + + return correct + }, + allowClient: async (userId, clientId, scope) => { + if (typeof scope === 'object') { + scope = scope.join(' ') + } + + let obj = { user_id: userId, client_id: clientId, scope: scope, created_at: new Date() } + + await Models.OAuth2AuthorizedClient.query().insert(obj) + + return true + } + } +} + +module.exports = OAuthDB diff --git a/server/api/oauth2/response.js b/server/api/oauth2/response.js new file mode 100644 index 0000000..61965bd --- /dev/null +++ b/server/api/oauth2/response.js @@ -0,0 +1,60 @@ +import query from 'querystring' +import error from './error' + +function data (req, res, code, data) { + res.header('Cache-Control', 'no-store') + res.header('Pragma', 'no-cache') + res.status(code).send(data) + console.debug('Response: ', data) +} + +function redirect (req, res, redirectUri) { + res.header('Location', redirectUri) + res.status(302).end() + console.debug('Redirecting to ', redirectUri) +} + +module.exports.error = function (req, res, err, redirectUri) { + // Transform unknown error + if (!(err instanceof error.OAuth2Error)) { + console.error(err.stack) + err = new error.ServerError('Uncaught exception') + } else { + console.error('Exception caught', err.stack) + } + + if (redirectUri) { + let obj = { + error: err.code, + error_description: err.message + } + + if (req.query.state) { + obj.state = req.query.state + } + + redirectUri += '?' + query.stringify(obj) + redirect(req, res, redirectUri) + } else { + data(req, res, err.status, {error: err.code, error_description: err.message}) + } +} + +module.exports.data = function (req, res, obj, redirectUri, anchor) { + if (redirectUri) { + if (anchor) { + redirectUri += '#' + } else { + redirectUri += (redirectUri.indexOf('?') === -1 ? '?' : '&') + } + + if (req.query.state) { + obj.state = req.query.state + } + + redirectUri += query.stringify(obj) + redirect(req, res, redirectUri) + } else { + data(req, res, 200, obj) + } +} diff --git a/server/routes/index.js b/server/routes/index.js index 34fb300..064eeb3 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -7,6 +7,7 @@ import wrap from '../../scripts/asyncRoute' import API from '../api' import apiRouter from './api' +import oauthRouter from './oauth2' let router = express.Router() @@ -22,6 +23,8 @@ router.use(wrap(async (req, res, next) => { next() })) +router.use('/oauth2', oauthRouter) + /* ================ RENDER VIEWS @@ -114,6 +117,14 @@ router.get('/login/verify', wrap(async (req, res) => { */ function formError (req, res, error, redirect) { + // Security measures + if (req.body.password) { + delete req.body.password + if (req.body.password_repeat) { + delete req.body.password_repeat + } + } + req.flash('formkeep', req.body || {}) req.flash('message', {error: true, text: error}) res.redirect(redirect || parseurl(req).path) diff --git a/server/routes/oauth2.js b/server/routes/oauth2.js new file mode 100644 index 0000000..d1fdada --- /dev/null +++ b/server/routes/oauth2.js @@ -0,0 +1,59 @@ +import express from 'express' +import uapi from '../api' +import OAuth2 from '../api/oauth2' +import config from '../../scripts/load-config' +import wrap from '../../scripts/asyncRoute' + +let router = express.Router() +let oauth = new OAuth2() + +router.use(oauth.express()) + +function ensureLoggedIn (req, res, next) { + if (req.session.user) { + next() + } else { + req.session.redirectUri = req.originalUrl + res.redirect('/login') + } +} + +router.use('/authorize', ensureLoggedIn, oauth.controller.authorization) +router.post('/token', oauth.controller.token) +router.post('/introspect', oauth.controller.introspection) + +router.get('/user', oauth.bearer, wrap(async (req, res) => { + let accessToken = req.oauth2.accessToken + let user = await uapi.User.get(accessToken.user_id) + if (!user) { + return res.status(404).jsonp({ + error: 'No such user' + }) + } + + let udata = { + id: user.id, + name: user.display_name, + avatar_file: user.avatar_file + } + + if (accessToken.scope.indexOf('email') != -1) { + udata.email = user.email + } + + if (accessToken.scope.indexOf('privilege') != -1) { + udata.privilege = user.nw_privilege + } + + res.jsonp(udata) +})) + +router.use((err, req, res, next) => { + if (err && err instanceof oauth.error) { + return oauth.response.error(req, res, err, req.body.redirectUri) + } + + next() +}) + +module.exports = router diff --git a/server/worker.js b/server/worker.js index d54351a..29fc7ef 100644 --- a/server/worker.js +++ b/server/worker.js @@ -22,6 +22,11 @@ process.once('message', (args) => { process.send(util.format.apply(this, arguments)) } + console.debug = function () { + if (!args.dev) return + process.send('[DEBUG] ' + util.format.apply(this, arguments)) + } + console.warn = function () { process.send('warn ' + util.format.apply(this, arguments)) } diff --git a/src/style/main.styl b/src/style/main.styl index f7086b8..44ef3dc 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -83,15 +83,49 @@ a section font-family: "Open Sans" position: relative - height: 100vh + min-height: 100vh .content position: absolute left: 0 right: 0 top: 0 bottom: 40% + overflow: auto background-color: #ffffff - padding: 40px + padding: 20px + &#home + h1 + font-size: 300% + text-align: center + color: green + margin-bottom: 0 + p + text-align: center; + font-size: 200% + color: #0074d0; + font-weight: bold; + +.divboxes + text-align: center +.divbox + width: 300px + display: inline-block + margin: 20px + vertical-align: text-top + .head + display: block + font-size: 30px + .fa + color: white + height: 32px + width: 32px + padding: 10px + border-radius: 100px + margin-right: 15px + .fa-lock + background-color: orange + .fa-github + background-color: green footer padding: 20px @@ -157,6 +191,8 @@ input[type="submit"] .left, .right display: inline-block width: 50% + .left + border-right: 1px solid #ddd .right float: right width: 46% @@ -224,6 +260,63 @@ span.divider margin: 0 5px cursor: default +.nobulletin + list-style-type: none + padding-left: 2em + i.fa + margin-right: 10px + +.haveaccess + color: #0c9a0c + span + font-weight: bold +.noaccess + color: red + span + font-weight: bold + +.unaffiliated + font-size: 120% + text-align: center + color: #FF5722 + font-weight: bold + +.application + height: 140px + .picture + width: 120px + height: 120px + position: relative + display: inline-block + float: left + .noicon + width: 100% + height: 100% + text-align: center + background-color: #40aaff + color: #fff + i + font-size: 500% + margin-top: 20px + .info + margin-left: 130px + .name + font-size: 150% + font-weight: bold + .description + font-size: 80% + font-style: italic + .url + display: block + text-overflow: ellipsis + overflow: hidden + +input.authorize + background-color: #00b0ff + font-size: 200% + color: #fff + border: 1px solid #2196F3 + @media all and (max-width: 800px) .navigator padding: 0 10px @@ -245,7 +338,29 @@ span.divider display: block .copyright margin-left: 0 + .document + padding: 5px + .tos + width: auto + font-size: 4vw + .divbox + font-size: 80% + .head + font-size: 120% + .fa + width: 15px + height: 15px + section#home + h1 + font-size: 150% + margin-top: 0 + p + font-size: 100% @media all and (max-width: 500px) + section + padding: 10px .logo.small font-size: 5vw + .divbox + margin: 10px 0px diff --git a/views/authorization.pug b/views/authorization.pug new file mode 100644 index 0000000..631e59c --- /dev/null +++ b/views/authorization.pug @@ -0,0 +1,61 @@ +extends layout +block title + |Icy Network - Authorize Client + +block body + .wrapper + .boxcont + .box#login + h1 Authorize OAuth2 Application + .left + .application + .picture + if client.icon + img(src=client.icon) + else + .noicon + i.fa.fa-fw.fa-gears + .info + .name= client.title + .description= client.description + a.url(href=client.url)= client.url + form#loginForm(method="POST", action="") + input(type="hidden", name="csrf", value=csrf) + input(type="hidden", name="decision", value='1') + input.authorize(type="submit", value="Authorize") + form#loginForm(method="POST", action="") + input(type="hidden", name="csrf", value=csrf) + input(type="hidden", name="decision", value='0') + input(type="submit", value="Deny") + .right + .haveaccess + span This application can + ul.nobulletin + if scope.indexOf('email') !== -1 + li + i.fa.fa-fw.fa-envelope + |See your Email address + li + i.fa.fa-fw.fa-user + |See your Display Name + .noaccess + span This application cannot + ul.nobulletin + if scope.indexOf('email') === -1 + li + i.fa.fa-fw.fa-envelope + |See your Email address + li + i.fa.fa-fw.fa-lock + |See your Password + li + i.fa.fa-fw.fa-gears + |Change your Account Settings + if client.verified != 1 + .unaffiliated + br + span + i.fa.fa-fw.fa-warning + |This application is not affiliated with Icy Network + i.fa.fa-fw.fa-warning + diff --git a/views/index.pug b/views/index.pug index fe5188b..6fddc2a 100644 --- a/views/index.pug +++ b/views/index.pug @@ -6,6 +6,19 @@ block body section#home .content h1 Welcome to Icy Network! + p Icy Network is a Global Network of Communities and Websites, United by a Single Login + .divboxes + .divbox#secure + span.head + i.fa.fa-lock.fa-fw + span.text Secure Login + span.text A secure login system with Two-Factor Authentication possibility + .divbox#secure + span.head + i.fa.fa-github.fa-fw + span.text Open Source + span.text All of our platforms are Open Source Software hosted on our + a(href="https://github.com/IcyNet", target="_blank") GitHub Organization section#news .content h1 Icy Network News diff --git a/views/register.pug b/views/register.pug index 175a216..b8f4a94 100644 --- a/views/register.pug +++ b/views/register.pug @@ -17,11 +17,11 @@ block body form#loginForm(method="POST", action="") input(type="hidden", name="csrf", value=csrf) label(for="username") Username - input(type="text", name="username", id="username") + input(type="text", name="username", id="username", value=formkeep.username) label(for="display_name") Display Name - input(type="text", name="display_name", id="display_name") + input(type="text", name="display_name", id="display_name", value=formkeep.display_name) label(for="email") Email Address - input(type="email", name="email", id="email") + input(type="email", name="email", id="email", value=formkeep.email) label(for="password") Password input(type="password", name="password", id="password") label(for="password_repeat") Repeat Password