Standardising authorised user details across dashboards and uibuilder

Hi all, there has been some work done by FlowFuse @joepavitt and a couple of other contribotors @fullmetal-fred and @kitori on standardising authorised client information for Dashboard 2.

Having seen this, I thought it would be good to bring the same data standard into UIBUILDER as well.

Whereas for D2, you need a plugin, for UIBUILDER I wanted to build in some basics and then allow a hook to allow other methods to be used if needed.

In doing so however, I did note a few potential issues with the processes being used. Notably:

  1. Client IP addresses are not very easy to correctly obtain.
  2. Identifying the authentication provider and the authenticated user details is HARD because there are no standard HTTP headers to use.

To deal with (1), I've replaced my own processing with a copy based on: request-ip/src/index.js at master · pbojinov/request-ip · GitHub - see the code below for the 2 functions. It would be advisable for D2 and the plugins to use the same approach.

To deal with (2), I have the following code for which I would welcome feedback:

// socket is called `conn` in D2 I think.
const headers = socket.request.headers
const handshake = socket.handshake

const realClientIP = getClientRealIpAddress(socket)

let authProvider
if (headers['cf-access-authenticated-user-email']) authProvider = 'CloudFlare Access'
else if (handshake.auth?.user?.userId) authProvider = 'FlowFuse'
else if (headers['x-user-id']) authProvider = 'Keycloak'
else if (headers['remote-user'] || headers['x-remote-user']) authProvider = 'Custom'
else if (headers['x-forwarded-user']) authProvider = 'Proxied Custom'

const userID = headers['cf-access-user'] || headers['cf-access-authenticated-user-email'] || handshake.auth?.user?.userId || headers['remote-user'] || headers['x-remote-user'] || headers['x-forwarded-user'] || headers['x-user-id'] || undefined
const email = headers['cf-access-authenticated-user-email'] || headers['remote-email'] || headers['x-user-email'] || undefined
const name = headers['remote-name'] || headers['x-remote-name'] || handshake.auth?.user?.name

if (authProvider !== undefined && userID !== undefined) {
    out._client = {
        userId: userID,
        socketId: socket.id,
        email: email,
        provider: authProvider,
        agent: headers['user-agent'] || null,
        ip: realClientIP,
        host: headers['host'],
        name: name,
    }
    if (headers['x-forwarded-groups']) out._client.groups = headers['x-forwarded-groups']
    if (headers['x-user-role']) out._client.role = headers['x-user-role']
    if (handshake.auth?.user?.image) out._client.image = handshake.auth.user.image
}

Please do let me know if you think anything is missing or wrong.

For UIBUILDER v7, the msg._client produced from above will be added to any msg received from the front-end clients assuming both authProvider and userID can be worked out.


Here is the code for working out the real client IP address:

/** Parse x-forwarded-for headers.
 * Borrowed from https://github.com/pbojinov/request-ip/blob/master/src/index.js
 * @param {string|string[]} value - The value to be parsed.
 * @returns {string|null} First known IP address, if any.
 */
function getClientIpFromXForwardedFor(value) {
    if (!value) return null

    if (Array.isArray(value)) value = value[0]

    // x-forwarded-for may return multiple IP addresses in the format:
    // "client IP, proxy 1 IP, proxy 2 IP"
    // Therefore, the right-most IP address is the IP address of the most recent proxy
    // and the left-most IP address is the IP address of the originating client.
    // source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
    // Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
    const forwardedIps = value.split(',').map((e) => {
        const ip = e.trim()
        if (ip.includes(':')) {
            const splitted = ip.split(':')
            // make sure we only use this if it's ipv4 (ip:port)
            if (splitted.length === 2) {
                return splitted[0]
            }
        }
        return ip
    })

    // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
    // Therefore taking the right-most IP address that is not unknown
    // A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
    for (let i = 0; i < forwardedIps.length; i++) {
        if (forwardedIps[i] !== 'unknown') {
            return forwardedIps[i]
        }
    }

    // If no value in the split list is an ip, return null
    return null
}

/** Get client real ip address
 * Borrowed from https://github.com/pbojinov/request-ip/blob/master/src/index.js
 * @param {socketio.Socket} socket Socket.IO socket object
 * @returns {string | string[] | undefined} Best estimate of the client's real IP address
 */
function getClientRealIpAddress(socket) {
    const headers = socket.request.headers

    // Standard headers used by Amazon EC2, Heroku, and others.
    if (headers['x-client-ip']) return headers['x-client-ip']

    // Load-balancers (AWS ELB) or proxies.
    const xForwardedFor = getClientIpFromXForwardedFor(headers['x-forwarded-for'])
    if (xForwardedFor) return xForwardedFor

    // Cloudflare. @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
    // CF-Connecting-IP - applied to every request to the origin.
    if (headers['cf-connecting-ip']) return headers['cf-connecting-ip']

    // DigitalOcean. @see https://www.digitalocean.com/community/questions/app-platform-client-ip
    // DO-Connecting-IP - applied to app platform servers behind a proxy.
    if (headers['do-connecting-ip']) return headers['do-connecting-ip']

    // Fastly and Firebase hosting header (When forwared to cloud function)
    if (headers['fastly-client-ip']) return headers['fastly-client-ip']

    // Akamai and Cloudflare: True-Client-IP.
    if (headers['true-client-ip']) return headers['true-client-ip']

    // Default nginx proxy/fcgi alternative to x-forwarded-for, used by some proxies.
    if (headers['x-real-ip']) return headers['x-real-ip']

    // (Rackspace LB and Riverbed's Stingray)
    // http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
    // https://splash.riverbed.com/docs/DOC-1926
    if (headers['x-cluster-client-ip']) return headers['x-cluster-client-ip']

    if (headers['x-forwarded']) return headers['x-forwarded']

    if (headers['forwarded-for']) return headers['forwarded-for']

    if (headers.forwarded) return headers.forwarded

    // Google Cloud App Engine
    // https://cloud.google.com/appengine/docs/standard/go/reference/request-response-headers

    if (headers['x-appengine-user-ip']) return headers['x-appengine-user-ip']

    // else get ip from socket.request that returns the reference to the request that originated the underlying engine.io Client
    if ( socket.request?.connection?.remoteAddress ) return socket.request.connection.remoteAddress

    // else get ip from socket.handshake that is a object that contains handshake details
    return socket.handshake.address
} // --- End of getClientRealIpAddress --- //

Obviously, all this code is Apache 2 licensed and can be freely reused. :grinning:

and this is why we're using plugins :smiley:

Indeed. And I could do that as well - but it isn't in my nature! :rofl:

It still only took an hour to do though so not a great hardship and I like a challenge.