travisghansen / external-auth-server

easy auth for reverse proxies
MIT License
330 stars 44 forks source link

ip whitelist + forwardauth #130

Closed solipsist01 closed 3 years ago

solipsist01 commented 3 years ago

Hello,

I have running eas + traefik 2.0 with forwardauth and keycloak. Unfortunately, Traefik doesn't support OR chained middlewares. https://github.com/traefik/traefik/issues/6007

Would it be possible to do this within EAS ?

i'd like to combine ip whitelist and forward auth.

if ip is in ipwhitelist, then no keycloak. you are authenticated and get the website presented else, you are forwarded to keycloak, where you have to authenticate.

Would it be complicated to get this running?

travisghansen commented 3 years ago

Not complicated, and entirely possible. The only ramification would be that the ip whitelist pages would not have the token data passed as headers.

travisghansen commented 3 years ago

Just put a request_js plugin in the list of plugins before the oidc plugin.

Something like this should do the trick. This of course assume x-forwarded-for is being properly set etc.

"snippet": "if (['ip1', 'ip2', ...].includes(req.headers['x-forwarded-for'])) res.statusCode = 200;",

I may have something not quite properly escaped there, but you get the idea.

https://github.com/travisghansen/external-auth-server/blob/master/PLUGINS.md#request_js

solipsist01 commented 3 years ago

I can't get it to work :)

i have set the EAS_ALLOW_EVAIL variable in my docker-compose.yml. Is this the right way ? True right ?

    environment:
      - EAS_ALLOW_EVAL=true

I have checked my headers with this container: https://hub.docker.com/r/brndnmtthws/nginx-echo-headers

(i redacted my ip address, 8.8.8.8 is my ip)

X-Forwarded-For: 8.8.8.8, 141.101.76.16 X-Forwarded-Host: headers.redacted.com X-Forwarded-Port: 443 X-Forwarded-Proto: https X-Forwarded-Server: traefik

my ip is listed in X-Forwarded-For. (and an ip from cloudflare)

This is the config i came up with. I'm kinda fighting with the brackets i think. This one gets accepted by node, and does create the key.

When i visit headers.redacted.com the eas container gives a decryption error.

error: error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt {"stack":"Error: error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt\n at Object.decrypt (/home/eas/app/src/utils.js:83:11)\n at verifyHandler (/home/eas/app/src/server.js:128:46)\n at Layer.handle [as handle_request] (/home/eas/app/node_modules/express/lib/router/layer.js:95:5)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:137:13)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)\n at next (/home/eas/app/node_modules/express/lib/router/route.js:131:14)"}

The redacted config:

const utils = require("../src/utils");

const config_token_sign_secret =
  process.env.EAS_CONFIG_TOKEN_SIGN_SECRET ||
  utils.exit_failure("missing EAS_CONFIG_TOKEN_SIGN_SECRET env variable");
const config_token_encrypt_secret =
  process.env.EAS_CONFIG_TOKEN_ENCRYPT_SECRET ||
  utils.exit_failure("missing EAS_CONFIG_TOKEN_ENCRYPT_SECRET env variable");

let config_token = {
  /**
   * future feature: allow blocking certain token IDs
   */
  //jti: <some known value>

  /**
   * using the same aud for multiple tokens allows sso for all services sharing the aud
   */
  //aud: "some application id", //should be unique to prevent cookie/session hijacking, defaults to a hash unique to the whole config
  eas: {
    plugins: [
{
    type: "request_js",
    "snippet": "if (['8.8.8.8', '8.8.4.4'].includes(req.headers['x-forwarded-for'])) res.statusCode = 200;",
},
    {
    type: "oidc",
    issuer: {
        /**
        * via discovery (takes preference)
        */
        discover_url: "https://keycloak.redacted.com/auth/realms/redacted/.well-known/openid-configuration",

        /**
        * via manual definition
        */
        //issuer: 'https://accounts.google.com',
        //authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
        //token_endpoint: 'https://www.googleapis.com/oauth2/v4/token',
        //userinfo_endpoint: 'https://www.googleapis.com/oauth2/v3/userinfo',
        //jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs',
    },
    client: {
        /**
        * manually defined (preferred)
        */
        client_id: "redacted",
        client_secret: "redacted"

        /**
        * via client registration
        */
        //registration_client_uri: "",
        //registration_access_token: "",
    },
    scopes: ["openid", "email", "profile"], // must include openid
    /**
    * static redirect URI
    * if your oauth provider does not support wildcards place the URL configured in the provider (that will return to this proper service) here
    */
    redirect_uri: "https://forwardauth.redacted.com/oauth/callback",
    features: {
        /**
        * how to expire the cookie
        * true = cookies expire will expire with tokens
        * false = cookies will be 'session' cookies
        * num seconds = expire after given number of seconds
        */
        cookie_expiry: false,

        /**
        * how frequently to refresh userinfo data
        * true = refresh with tokens (assuming they expire)
        * false = never refresh
        * num seconds = expire after given number of seconds
        */
        userinfo_expiry: true,

        /**
        * how long to keep a session (server side) around
        * true = expire with tokenSet (if applicable)
        * false = never expire
        * num seconds = expire after given number of seconds (enables sliding window)
        *
        * sessions become a floating window *if*
        * - tokens are being refreshed
        * or
        * - userinfo being refreshed
        * or
        * - session_expiry_refresh_window is a positive number
        */
        session_expiry: 1800,

        /**
        * window to update the session window based on activity if
        * nothing else has updated it (ie: refreshing tokens or userinfo)
        *
        * should be a positive number less than session_expiry
        *
        * For example, if session_expiry is set to 60 seconds and session_expiry_refresh_window value is set to 20
        * then activity in the last 20 seconds (40-60) of the window will 'slide' the window
        * out session_expiry time from whenever the activity occurred
        */
        session_expiry_refresh_window: 900,

        /**
        * will re-use the same id (ie: same cookie) for a particular client if a session has expired
        */
        session_retain_id: true,

        /**
        * if the access token is expired and a refresh token is available, refresh
        */
        refresh_access_token: true,

        /**
        * fetch userinfo and include as X-Userinfo header to backing service
        */
        fetch_userinfo: true,

        /**
        * check token validity with provider during assertion process
        */
        introspect_access_token: false,

        /**
        * which token (if any) to send back to the proxy as the Authorization Bearer value
        * note the proxy must allow the token to be passed to the backend if desired
        *
        * possible values are id_token, access_token, or refresh_token
        */
        authorization_token: "access_token"
    },
    assertions: {
        /**
        * assert the token(s) has not expired
        */
        exp: true,

        /**
        * assert the 'not before' attribute of the token(s)
        */
        nbf: true,

        /**
        * assert the correct issuer of the token(s)
        */
        iss: true,

        /**
        * custom userinfo assertions
        */

        /**
        * custom id_token assertions
        */
    },
    cookie: {
        //name: "_my_company_session",//default is _oeas_oauth_session
        domain: "redacted.com", //defaults to request domain, could do sso with more generic domain
        //path: "/",
    },
    // see HEADERS.md for details
    headers: {},
}], // list of plugin definitions, refer to PLUGINS.md for details
  }
};

config_token = jwt.sign(config_token, config_token_sign_secret);
const conifg_token_encrypted = utils.encrypt(
  config_token_encrypt_secret,
  config_token
);

//console.log("token: %s", config_token);
//console.log("");

console.log("encrypted token (for server-side usage): %s", conifg_token_encrypted);
console.log("");

console.log(
  "URL safe config_token: %s",
  encodeURIComponent(conifg_token_encrypted)
);
console.log("");
solipsist01 commented 3 years ago

With this code i don't have a java error, but still i get forwarded to keycloak. It does however seem to process request_js

  eas: {
    plugins: [

{
    type: "request_js",
    "snippet": "if (['8.8.8.8', '8.8.4.4'].includes(req.headers['X-Forwarded-For'])) res.statusCode = 200;",
},
{
    type: "oidc",
    issuer: {

info: starting verify pipeline
info: starting verify for plugin: request_js
info: starting verify for plugin: oidc
info: end verify pipeline with status: 200

debug on:

info: starting verify for plugin: request_js
(node:18) [DEP0106] DeprecationWarning: crypto.createDecipher is deprecated.
debug: plugin response {"statusCode":500,"statusMessage":"","body":"","cookies":[],"clearCookies":[],"headers":{},"authenticationData":{},"plugin":{"server":{},"config":{"type":"request_js","snippet":"if (['8.8.8.8'].includes(req.headers['X-Forwarded-For'])) res.statusCode = 200;","pcb":{}}}}
travisghansen commented 3 years ago

I forgot x-forwarded-for could have multiple entries. I’ll send another example shortly which should parse out just the first address for the comparison.

travisghansen commented 3 years ago

Try this..

"snippet": "if (['8.8.8.8', '8.8.4.4'].includes(req.headers['x-forwarded-for'].split(',')[0].trim())) res.statusCode = 200;"

Note that header names are all lower-cased in that array so always reference it with all lower-case letters.

solipsist01 commented 3 years ago

Unfortunately, that doesn't work as well :)


debug: config token: {"eas":{"plugins":[{"type":"request_js","snippet":"if (['8.8.8.8', '8.8.4.4'].includes(req.headers['x-forwarded-for'].split(',')[0].trim())) res.statusCode = 200;"},{"type":"oidc","issuer":{"discover_url":"https://keycloak.redacted.com/auth/realms/redacted/.well-known/openid-configuration"},"client":{"client_id":"redacted","client_secret":"redacted"},"scopes":["openid","email","profile"],"redirect_uri":"https://forwardauth.redacted.com/oauth/callback","features":{"cookie_expiry":false,"userinfo_expiry":true,"session_expiry":1800,"session_expiry_refresh_window":900,"session_retain_id":true,"refresh_access_token":true,"fetch_userinfo":true,"introspect_access_token":false,"authorization_token":"access_token"},"assertions":{"exp":true,"nbf":true,"iss":true},"cookie":{"domain":"redacted.com"},"headers":{}}]},"iat":1633224249421432145,"audMD5":"asdfasdf3a700d1"}
info: starting verify for plugin: request_js
debug: plugin response {"statusCode":500,"statusMessage":"","body":"","cookies":[],"clearCookies":[],"headers":{},"authenticationData":{},"plugin":{"server":{},"config":{"type":"request_js","snippet":"if (['8.8.8.8', '8.4.4.4'].includes(req.headers['x-forwarded-for'].split(',')[0].trim())) res.statusCode = 200;","pcb":{}}}}
travisghansen commented 3 years ago

I'll do a test here locally and send over a working snippet..

travisghansen commented 3 years ago

OK, busy week so sorry for the delay. This snippet works for me locally:

"snippet": "console.log(parentReqInfo); console.log(req.headers); if (['::1', '8.8.4.4'].includes(req.headers['x-forwarded-for'].split(',')[0].trim())) res.statusCode = 200; console.log(res);",

Replace the list of IPs as appropriate for your use-case obviously (::1 was the operative value for me locally).

solipsist01 commented 3 years ago

Im testing this first thing tomorrow!. Thanks for this :)

solipsist01 commented 3 years ago

Hi Travis,

I have changed to cf-connecting-ip and then it works, so i'm all happy :)

Could it be that EAS doesn't handle x-forwarded-for as an array internally ? The logging shows the headers, and it only has the cloudflare IP.

I have a headers container running which shows my headers, and that one does infact show multiple ip's

This is my console output of EAS.

info: starting verify for plugin: request_js
{
  uri: 'https://headers.redacted.nl/',
  parsedUri: {
    scheme: 'https',
    userinfo: undefined,
    host: 'headers.redacted.nl',
    port: undefined,
    path: '/',
    query: undefined,
    fragment: undefined,
    reference: 'absolute'
  },
  parsedQuery: {},
  method: 'GET'
}
{
  host: 'forwardauth:8080',
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/201001                                                                                                                                                             01 Firefox/93.0',
  accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,imag                                                                                                                                                             e/webp,*/*;q=0.8',
  'accept-encoding': 'gzip',
  'accept-language': 'en-US,en;q=0.5',
  'cdn-loop': 'cloudflare',
  'cf-connecting-ip': '8.8.4.4',
  'cf-ipcountry': 'NL',
  'cf-ray': '6a7b447d9b694248-AMS',
  'cf-visitor': '{"scheme":"https"}',
  dnt: '1',
  'sec-fetch-dest': 'document',
  'sec-fetch-mode': 'navigate',
  'sec-fetch-site': 'none',
  'sec-fetch-user': '?1',
  'upgrade-insecure-requests': '1',
  'x-forwarded-for': '141.101.105.34',
  'x-forwarded-host': 'headers.redacted.nl',
  'x-forwarded-method': 'GET',
  'x-forwarded-port': '443',
  'x-forwarded-proto': 'https',
  'x-forwarded-server': 'traefik',
  'x-forwarded-uri': '/',
  'x-real-ip': '141.101.105.34'
}
PluginVerifyResponse {
  statusCode: 500,
  statusMessage: '',
  body: '',
  cookies: [],
  clearCookies: [],
  headers: {},
  authenticationData: {},
  plugin: RequestJsPlugin {
    server: ExternalAuthServer { WebServer: [EventEmitter] },
    config: {
      type: 'request_js',
      snippet: "console.log(parentReqInfo); console.log(req.headers); if (['8.8.4.4', '8.8.4.4'].includes(req.headers['x-forwarded-for'].split(',')[0]                                                                                                                                                             .trim())) res.statusCode = 200; console.log(res);",
      pcb: {}
    }
  }
}
travisghansen commented 3 years ago

The satus code being 500 isn’t necessarily a problem of that’s what you’re asking about.

The split turns the value into an array, but yes if this is ‘behind’ cf then you’ll want to adjust as appropriate. In the example output above which ip is the client ip and which ip is the cf ip?

solipsist01 commented 3 years ago

8.8.4.4 is my real wan ip 141.101.105.34 is the Cloudflare IP

My headers page shows the following:

X-Forwarded-For: 8.8.4.4, 141.101.77.227
X-Forwarded-Host: headers.redacted.nl
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: traefik

While EAS logging shows:

  'x-forwarded-for': '141.101.77.227',
  'x-forwarded-host': 'headers.redacted.nl',
  'x-forwarded-method': 'GET',
  'x-forwarded-port': '443',

Wouldn't this mean it only splits one ip ? because there is only one ip in the variables?

travisghansen commented 3 years ago

That seems odd that the header doesn’t have the wan ip as eas sees it. Can you send the middleware definition for me to review? And also the entry point if available?

solipsist01 commented 3 years ago
labels:
      - "traefik.http.middlewares.keycloak-and-whitelist-auth.forwardauth.address=http://forwardauth:8080/verify?config_token=randomjibberishhashoutputfromjsfile"
      - "traefik.http.middlewares.keycloak-and-whitelist-auth.forwardauth.authResponseHeaders=X-Userinfo, X-Id-Token, X-Access-Token"

    command:
      - --entrypoints.web.address=:80
      - --entryPoints.web.forwardedHeaders.insecure
      - --entrypoints.websecure.address=:443
      - --entryPoints.websecure.forwardedHeaders.insecure
travisghansen commented 3 years ago

What behavior do you see when you enable this: https://doc.traefik.io/traefik/middlewares/http/forwardauth/#trustforwardheader

solipsist01 commented 3 years ago

You sir, are brilliant. That was the solution.

I never considered that for an forwardauth flow, headers would be handled diffrent.

travisghansen commented 3 years ago

It’s especially important for forward auth to ensure headers can be trusted :) In your case I would try to set those manually using curl or something to ensure they’re being cleansed properly end-to-end so someone can’t effectively spoof their ip (and subsequently bypass auth).

solipsist01 commented 3 years ago

After some research, i cannot trust this header indeed. Im gonna use cf-connecting-ip and make sure traffic only comes from cloudflare ips, as cloudflare always overwrites that header. Thank you!