OAuth2 provider implemented

This commit is contained in:
Evert Prants 2017-08-23 23:13:45 +03:00
parent c7d348b27d
commit 1f54626a33
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
27 changed files with 1385 additions and 10 deletions

View File

@ -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')

View File

@ -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'
} }
} }

View 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)
}
})

View 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
}

View 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
}

View File

@ -0,0 +1,4 @@
module.exports = {
Code: require('./code'),
Implicit: require('./implicit')
}

View File

@ -0,0 +1,5 @@
module.exports = function (req, res, client, scope, user) {
res.locals.client = client
res.locals.scope = scope
res.render('authorization')
}

View File

@ -0,0 +1,5 @@
module.exports = {
authorization: require('./authorization'),
introspection: require('./introspection'),
token: require('./token')
}

View 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)
})

View 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)
}
})

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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
}

View 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

View 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
View 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

View 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)
}
}

View File

@ -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
View 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

View File

@ -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))
} }

View File

@ -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
View 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

View File

@ -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

View File

@ -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