OAuth2 provider implemented
This commit is contained in:
parent
c7d348b27d
commit
1f54626a33
@ -50,7 +50,7 @@ exports.up = function(knex, Promise) {
|
|||||||
table.text('redirect_url')
|
table.text('redirect_url')
|
||||||
table.text('icon')
|
table.text('icon')
|
||||||
table.text('secret')
|
table.text('secret')
|
||||||
table.text('scopes')
|
table.text('scope')
|
||||||
table.text('grants')
|
table.text('grants')
|
||||||
|
|
||||||
table.integer('user_id').unsigned().notNullable()
|
table.integer('user_id').unsigned().notNullable()
|
||||||
@ -66,6 +66,7 @@ exports.up = function(knex, Promise) {
|
|||||||
|
|
||||||
table.integer('user_id').unsigned().notNullable()
|
table.integer('user_id').unsigned().notNullable()
|
||||||
table.integer('client_id').unsigned().notNullable()
|
table.integer('client_id').unsigned().notNullable()
|
||||||
|
table.text('scope')
|
||||||
table.dateTime('expires_at')
|
table.dateTime('expires_at')
|
||||||
|
|
||||||
table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE')
|
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('user_id').unsigned().notNullable()
|
||||||
table.integer('client_id').unsigned().notNullable()
|
table.integer('client_id').unsigned().notNullable()
|
||||||
table.text('code')
|
table.text('code')
|
||||||
table.text('scopes')
|
table.text('scope')
|
||||||
|
|
||||||
table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE')
|
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('user_id').unsigned().notNullable()
|
||||||
table.integer('client_id').unsigned().notNullable()
|
table.integer('client_id').unsigned().notNullable()
|
||||||
table.text('token')
|
table.text('token')
|
||||||
table.text('scopes')
|
table.text('scope')
|
||||||
|
|
||||||
table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE')
|
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('user_id').unsigned().notNullable()
|
||||||
table.integer('client_id').unsigned().notNullable()
|
table.integer('client_id').unsigned().notNullable()
|
||||||
table.text('token')
|
table.text('token')
|
||||||
table.text('scopes')
|
table.text('scope')
|
||||||
|
|
||||||
table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE')
|
table.foreign('user_id').references('users.id').onDelete('CASCADE').onUpdate('CASCADE')
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ class OAuth2AuthorizedClient extends Model {
|
|||||||
|
|
||||||
class OAuth2Code extends Model {
|
class OAuth2Code extends Model {
|
||||||
static get tableName () {
|
static get tableName () {
|
||||||
return 'oauth2_client_authorization'
|
return 'oauth2_code'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
152
server/api/oauth2/controller/authorization.js
Normal file
152
server/api/oauth2/controller/authorization.js
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
28
server/api/oauth2/controller/code/code.js
Normal file
28
server/api/oauth2/controller/code/code.js
Normal file
@ -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
|
||||||
|
}
|
27
server/api/oauth2/controller/code/implicit.js
Normal file
27
server/api/oauth2/controller/code/implicit.js
Normal file
@ -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
|
||||||
|
}
|
4
server/api/oauth2/controller/code/index.js
Normal file
4
server/api/oauth2/controller/code/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
Code: require('./code'),
|
||||||
|
Implicit: require('./implicit')
|
||||||
|
}
|
5
server/api/oauth2/controller/decision.js
Normal file
5
server/api/oauth2/controller/decision.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = function (req, res, client, scope, user) {
|
||||||
|
res.locals.client = client
|
||||||
|
res.locals.scope = scope
|
||||||
|
res.render('authorization')
|
||||||
|
}
|
5
server/api/oauth2/controller/index.js
Normal file
5
server/api/oauth2/controller/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
authorization: require('./authorization'),
|
||||||
|
introspection: require('./introspection'),
|
||||||
|
token: require('./token')
|
||||||
|
}
|
54
server/api/oauth2/controller/introspection.js
Normal file
54
server/api/oauth2/controller/introspection.js
Normal file
@ -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)
|
||||||
|
})
|
88
server/api/oauth2/controller/token.js
Normal file
88
server/api/oauth2/controller/token.js
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
68
server/api/oauth2/controller/tokens/authorizationCode.js
Normal file
68
server/api/oauth2/controller/tokens/authorizationCode.js
Normal file
@ -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
|
||||||
|
}
|
28
server/api/oauth2/controller/tokens/clientCredentials.js
Normal file
28
server/api/oauth2/controller/tokens/clientCredentials.js
Normal file
@ -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
|
||||||
|
}
|
11
server/api/oauth2/controller/tokens/index.js
Normal file
11
server/api/oauth2/controller/tokens/index.js
Normal file
@ -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
|
||||||
|
}
|
68
server/api/oauth2/controller/tokens/password.js
Normal file
68
server/api/oauth2/controller/tokens/password.js
Normal file
@ -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
|
||||||
|
}
|
72
server/api/oauth2/controller/tokens/refreshToken.js
Normal file
72
server/api/oauth2/controller/tokens/refreshToken.js
Normal file
@ -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
|
||||||
|
}
|
107
server/api/oauth2/error.js
Normal file
107
server/api/oauth2/error.js
Normal file
@ -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
|
||||||
|
}
|
27
server/api/oauth2/index.js
Normal file
27
server/api/oauth2/index.js
Normal file
@ -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
|
48
server/api/oauth2/middleware.js
Normal file
48
server/api/oauth2/middleware.js
Normal file
@ -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
|
258
server/api/oauth2/model.js
Normal file
258
server/api/oauth2/model.js
Normal file
@ -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
|
60
server/api/oauth2/response.js
Normal file
60
server/api/oauth2/response.js
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,7 @@ import wrap from '../../scripts/asyncRoute'
|
|||||||
import API from '../api'
|
import API from '../api'
|
||||||
|
|
||||||
import apiRouter from './api'
|
import apiRouter from './api'
|
||||||
|
import oauthRouter from './oauth2'
|
||||||
|
|
||||||
let router = express.Router()
|
let router = express.Router()
|
||||||
|
|
||||||
@ -22,6 +23,8 @@ router.use(wrap(async (req, res, next) => {
|
|||||||
next()
|
next()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
router.use('/oauth2', oauthRouter)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
================
|
================
|
||||||
RENDER VIEWS
|
RENDER VIEWS
|
||||||
@ -114,6 +117,14 @@ router.get('/login/verify', wrap(async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function formError (req, res, error, redirect) {
|
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('formkeep', req.body || {})
|
||||||
req.flash('message', {error: true, text: error})
|
req.flash('message', {error: true, text: error})
|
||||||
res.redirect(redirect || parseurl(req).path)
|
res.redirect(redirect || parseurl(req).path)
|
||||||
|
59
server/routes/oauth2.js
Normal file
59
server/routes/oauth2.js
Normal file
@ -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
|
@ -22,6 +22,11 @@ process.once('message', (args) => {
|
|||||||
process.send(util.format.apply(this, arguments))
|
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 () {
|
console.warn = function () {
|
||||||
process.send('warn ' + util.format.apply(this, arguments))
|
process.send('warn ' + util.format.apply(this, arguments))
|
||||||
}
|
}
|
||||||
|
@ -83,15 +83,49 @@ a
|
|||||||
section
|
section
|
||||||
font-family: "Open Sans"
|
font-family: "Open Sans"
|
||||||
position: relative
|
position: relative
|
||||||
height: 100vh
|
min-height: 100vh
|
||||||
.content
|
.content
|
||||||
position: absolute
|
position: absolute
|
||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
top: 0
|
top: 0
|
||||||
bottom: 40%
|
bottom: 40%
|
||||||
|
overflow: auto
|
||||||
background-color: #ffffff
|
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
|
footer
|
||||||
padding: 20px
|
padding: 20px
|
||||||
@ -157,6 +191,8 @@ input[type="submit"]
|
|||||||
.left, .right
|
.left, .right
|
||||||
display: inline-block
|
display: inline-block
|
||||||
width: 50%
|
width: 50%
|
||||||
|
.left
|
||||||
|
border-right: 1px solid #ddd
|
||||||
.right
|
.right
|
||||||
float: right
|
float: right
|
||||||
width: 46%
|
width: 46%
|
||||||
@ -224,6 +260,63 @@ span.divider
|
|||||||
margin: 0 5px
|
margin: 0 5px
|
||||||
cursor: default
|
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)
|
@media all and (max-width: 800px)
|
||||||
.navigator
|
.navigator
|
||||||
padding: 0 10px
|
padding: 0 10px
|
||||||
@ -245,7 +338,29 @@ span.divider
|
|||||||
display: block
|
display: block
|
||||||
.copyright
|
.copyright
|
||||||
margin-left: 0
|
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)
|
@media all and (max-width: 500px)
|
||||||
|
section
|
||||||
|
padding: 10px
|
||||||
.logo.small
|
.logo.small
|
||||||
font-size: 5vw
|
font-size: 5vw
|
||||||
|
.divbox
|
||||||
|
margin: 10px 0px
|
||||||
|
61
views/authorization.pug
Normal file
61
views/authorization.pug
Normal file
@ -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
|
||||||
|
|
@ -6,6 +6,19 @@ block body
|
|||||||
section#home
|
section#home
|
||||||
.content
|
.content
|
||||||
h1 Welcome to Icy Network!
|
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
|
section#news
|
||||||
.content
|
.content
|
||||||
h1 Icy Network News
|
h1 Icy Network News
|
||||||
|
@ -17,11 +17,11 @@ block body
|
|||||||
form#loginForm(method="POST", action="")
|
form#loginForm(method="POST", action="")
|
||||||
input(type="hidden", name="csrf", value=csrf)
|
input(type="hidden", name="csrf", value=csrf)
|
||||||
label(for="username") Username
|
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
|
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
|
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
|
label(for="password") Password
|
||||||
input(type="password", name="password", id="password")
|
input(type="password", name="password", id="password")
|
||||||
label(for="password_repeat") Repeat Password
|
label(for="password_repeat") Repeat Password
|
||||||
|
Reference in New Issue
Block a user