[BREAKING] Create a suite for Traefik proxy.

* Removal of the Redirect header sent by Authelia /api/verify endpoint.
* Authelia does not consume Host header anymore but X-Forwarded-Proto and X-Forwarded-Host
  to compute the link sent in identity verification emails.
* Authelia used Host header as the application name for U2F authentication but it's now using
  X-Forwarded-* headers.
pull/355/head
Clement Michaud 2019-04-10 21:27:18 +02:00 committed by Clément Michaud
parent 617e929e1a
commit 4016ff1bba
39 changed files with 480 additions and 270 deletions

View File

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import SecurityKeyRegistrationView from '../../../views/SecurityKeyRegistrationView/SecurityKeyRegistrationView';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import * as U2fApi from "u2f-api";
import U2fApi from "u2f-api";
import { Props } from '../../../views/SecurityKeyRegistrationView/SecurityKeyRegistrationView';
import { registerSecurityKey, registerSecurityKeyFailure, registerSecurityKeySuccess } from '../../../reducers/Portal/SecurityKeyRegistration/actions';
import AutheliaService from '../../../services/AutheliaService';
@ -12,13 +12,8 @@ const mapStateToProps = (state: RootState) => ({
error: state.securityKeyRegistration.error,
});
async function checkIdentity(token: string) {
return fetch(`/api/secondfactor/u2f/identity/finish?token=${token}`, {
method: 'POST',
});
}
function fail(dispatch: Dispatch, err: Error) {
console.error(err);
dispatch(registerSecurityKeyFailure(err.message));
}
@ -27,9 +22,9 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
onInit: async (token: string) => {
try {
dispatch(registerSecurityKey());
await checkIdentity(token);
const requestRegister = await AutheliaService.requestSecurityKeyRegistration();
const registerResponse = await U2fApi.register(requestRegister, [], 60);
await AutheliaService.completeSecurityKeyRegistrationIdentityValidation(token);
const registerRequest = await AutheliaService.requestSecurityKeyRegistration();
const registerResponse = await U2fApi.register([registerRequest], [], 60);
await AutheliaService.completeSecurityKeyRegistration(registerResponse);
dispatch(registerSecurityKeySuccess());
setTimeout(() => {

View File

@ -1,5 +1,5 @@
import RemoteState from "../views/AuthenticationView/RemoteState";
import u2fApi, { SignRequest } from "u2f-api";
import U2fApi, { SignRequest } from "u2f-api";
import Method2FA from "../types/Method2FA";
import RedirectResponse from "./RedirectResponse";
import PreferedMethodResponse from "./PreferedMethodResponse";
@ -74,15 +74,11 @@ class AutheliaService {
}
static async requestSigning() {
return this.fetchSafe('/api/u2f/sign_request')
.then(async (res) => {
const body = await res.json();
return body as SignRequest;
});
return this.fetchSafeJson<SignRequest>('/api/u2f/sign_request');
}
static async completeSecurityKeySigning(
response: u2fApi.SignResponse, redirectionUrl: string | null) {
response: U2fApi.SignResponse, redirectionUrl: string | null) {
const headers: Record<string, string> = {
'Accept': 'application/json',
@ -199,7 +195,7 @@ class AutheliaService {
return await this.fetchSafeJson('/api/secondfactor/available');
}
static async completeSecurityKeyRegistration(response: u2fApi.RegisterResponse): Promise<Response> {
static async completeSecurityKeyRegistration(response: U2fApi.RegisterResponse): Promise<Response> {
return await this.fetchSafe('/api/u2f/register', {
method: 'POST',
headers: {
@ -211,7 +207,18 @@ class AutheliaService {
}
static async requestSecurityKeyRegistration() {
return this.fetchSafeJson<u2fApi.RegisterRequest>('/api/u2f/register_request')
return this.fetchSafeJson<U2fApi.RegisterRequest>('/api/u2f/register_request')
}
static async completeSecurityKeyRegistrationIdentityValidation(token: string) {
const res = await this.fetchSafeJson(`/api/secondfactor/u2f/identity/finish?token=${token}`, {
method: 'POST',
});
if ('error' in res) {
throw new Error(res['error']);
}
return res;
}
}

View File

@ -1,5 +1,4 @@
version: '2'
# services: {}
networks:
authelianet:
driver: bridge

View File

@ -1,4 +0,0 @@
version: '2'
networks:
authelianet:
external: true

View File

@ -2,5 +2,9 @@ version: '2'
services:
nginx-backend:
image: authelia-example-backend
labels:
- traefik.frontend.rule=Host:home.example.com,public.example.com,secure.example.com,admin.example.com,singlefactor.example.com
- traefik.frontend.auth.forward.address=http://192.168.240.1:9091/api/verify?rd=https://login.example.com:8080/%23/
- traefik.frontend.auth.forward.tls.insecureSkipVerify=true
networks:
- authelianet

View File

@ -35,10 +35,15 @@ http {
# Serve the backend API for the portal.
location /api {
proxy_set_header Host $http_host;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
# Required by Authelia because "trust proxy" option is used.
# See https://expressjs.com/en/guide/behind-proxies.html
proxy_set_header X-Forwarded-Proto $scheme;
# Required by Authelia to build correct links for identity validation.
proxy_set_header X-Forwarded-Host $http_host;
# Needed for network ACLs to work. It appends the IP of the client to the list of IPs
# and allows Authelia to use it to match the network-based ACLs.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -65,11 +70,15 @@ http {
# Serve the backend API for the portal.
location /api {
proxy_set_header Host $http_host;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
# Required by Authelia because "trust proxy" option is used.
# See https://expressjs.com/en/guide/behind-proxies.html
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Required by Authelia to build correct links for identity validation.
proxy_set_header X-Forwarded-Host $http_host;
# Needed for network ACLs to work. It appends the IP of the client to the list of IPs
# and allows Authelia to use it to match the network-based ACLs.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -136,16 +145,15 @@ http {
# to the virtual endpoint introduced by nginx and declared in the next block.
location / {
auth_request /auth_verify;
auth_request_set $redirect $upstream_http_redirect;
auth_request_set $user $upstream_http_remote_user;
proxy_set_header X-Forwarded-User $user;
auth_request_set $user $upstream_http_remote_user;
proxy_set_header X-Forwarded-User $user;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Remote-Groups $groups;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Remote-Groups $groups;
proxy_set_header Host $http_host;
# Route the request to the correct virtual host in the backend.
proxy_set_header Host $http_host;
# Authelia relies on Proxy-Authorization header to authenticate in basic auth.
# but for the sake of simplicity (because Authorization in supported in most
@ -153,20 +161,35 @@ http {
# Proxy-Authorization before sending it to Authelia.
proxy_set_header Proxy-Authorization $http_authorization;
error_page 401 =302 https://login.example.com:8080/#/?rd=$redirect;
# mitigate HTTPoxy Vulnerability
# https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
proxy_set_header Proxy "";
proxy_pass $upstream_endpoint;
# Set the `target_url` variable based on the request. It will be used to build the portal
# URL with the correct redirection parameter.
set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://login.example.com:8080/#/?rd=$target_url;
proxy_pass $upstream_endpoint;
}
# Virtual endpoint forwarding requests to Authelia server.
location /auth_verify {
internal;
proxy_set_header Host $http_host;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Real-IP $remote_addr;
# Provide either X-Original-URL and X-Forwarded-Proto or
# X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Uri or both.
# Those headers will be used by Authelia to deduce the target url of the user.
#
# X-Forwarded-Proto is mandatory since Authelia uses the "trust proxy" option.
# See https://expressjs.com/en/guide/behind-proxies.html
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Authelia can receive Proxy-Authorization to authenticate however most of the clients
@ -176,24 +199,23 @@ http {
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass $upstream_verify;
proxy_pass $upstream_verify;
}
# Used by suites to test the forwarded users and groups headers produced by Authelia.
location /headers {
auth_request /auth_verify;
auth_request_set $redirect $upstream_http_redirect;
auth_request_set $user $upstream_http_remote_user;
proxy_set_header Custom-Forwarded-User $user;
auth_request_set $user $upstream_http_remote_user;
proxy_set_header Custom-Forwarded-User $user;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Custom-Forwarded-Groups $groups;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Custom-Forwarded-Groups $groups;
error_page 401 =302 https://login.example.com:8080/#/?rd=$redirect;
set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://login.example.com:8080/#/?rd=$target_url;
proxy_pass $upstream_headers;
proxy_pass $upstream_headers;
}
}

View File

@ -4,5 +4,8 @@ services:
image: schickling/mailcatcher
ports:
- "1025:1025"
labels:
- traefik.frontend.rule=Host:mail.example.com
- traefik.port=1080
networks:
- authelianet

View File

@ -0,0 +1 @@
traefik.toml

View File

@ -0,0 +1,14 @@
version: '2'
services:
traefik:
image: traefik:v1.7.9-alpine
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./example/compose/traefik/traefik.toml:/etc/traefik/traefik.toml
labels:
- traefik.frontend.rule=Host:traefik.example.com
- traefik.port=8081
networks:
authelianet:
# Set the IP to be able to query on port 443
ipv4_address: 192.168.240.100

View File

@ -0,0 +1,40 @@
#!/usr/bin/env node
const ejs = require('ejs');
const fs = require('fs');
const program = require('commander');
let backend;
program
.version('0.1.0')
.option('-p, --production', 'Render template for production.')
.arguments('[backend]')
.action((backendArg) => backend = backendArg)
.parse(process.argv)
const options = {
production: false,
}
if (!backend) {
backend = 'http://192.168.240.1:9091'
}
if (program.production) {
options['production'] = true;
}
options['authelia_backend'] = backend;
const templatePath = __dirname + '/traefik.toml.ejs';
const outputPath = __dirname + '/traefik.toml';
html = ejs.renderFile(templatePath, options, (err, conf) => {
try {
var fd = fs.openSync(outputPath, 'w');
fs.writeFileSync(fd, conf);
} catch (e) {
fs.writeFileSync(outputPath, conf);
}
});

View File

@ -0,0 +1,75 @@
defaultEntryPoints = ["http", "https"]
logLevel = "DEBUG"
[traefikLog]
filePath = "/var/log/traefik.log"
[entryPoints]
[entryPoints.http]
address = ":80"
[entryPoints.http.redirect]
entryPoint = "https"
[entryPoints.https]
address = ":8080"
[entryPoints.https.tls]
[entryPoints.api]
address = ":8081"
[file]
# TODO(c.michaud): remove this template by providing a proxy doing
# the routing depending on the mode (production or dev)
<% if (!production) { %>
[frontends]
[frontends.authelia_api]
backend = "authelia_api_backend"
[frontends.authelia_api.routes.route0]
rule = "Host:login.example.com; PathPrefix:/api;"
[frontends.authelia_front]
backend = "authelia_front_backend"
[frontends.authelia_front.routes.route0]
rule = "Host:login.example.com"
[backends]
[backends.authelia_api_backend]
[backends.authelia_api_backend.servers.server]
url = "http://192.168.240.1:9091"
[backends.authelia_front_backend]
[backends.authelia_front_backend.servers.server]
url = "http://192.168.240.1:3000"
<% } else { %>
[frontends]
[frontends.authelia]
backend = "authelia_backend"
[frontends.authelia.routes.route0]
rule = "Host:login.example.com"
[backends]
[backends.authelia_backend]
[backends.authelia_backend.servers.server]
url = "http://192.168.240.1:9091"
<% } %>
[api]
# This is exposed via a subdomain and a proxy
entryPoint = "api"
dashboard = true
[docker]
# Docker server endpoint. Can be a tcp or a unix socket endpoint.
endpoint = "unix:///var/run/docker.sock"
# network = "traefik_default"
# Default domain used.
# Can be overridden by setting the "traefik.domain" label on a container.
domain = "localhost"
# Enable watch docker changes
watch = true

View File

@ -1,61 +0,0 @@
###############################################################
# Authelia minimal configuration #
###############################################################
logs_level: debug
authentication_backend:
file:
path: /etc/authelia/users_database.yml
session:
secret: unsecure_session_secret
domain: example.com
# Configuration of the storage backend used to store data and secrets. i.e. totp data
storage:
local:
path: /etc/authelia/storage
# TOTP Issuer Name
#
# This will be the issuer name displayed in Google Authenticator
# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names
totp:
issuer: example.com
# Access Control
#
# Access control is a set of rules you can use to restrict user access to certain
# resources.
access_control:
# Default policy can either be `bypass`, `one_factor`, `two_factor` or `deny`.
default_policy: deny
rules:
- domain: traefik.example.com
policy: two_factor
- domain: who.example.com
policy: two_factor
# Configuration of the authentication regulation mechanism.
regulation:
# Set it to 0 to disable max_retries.
max_retries: 3
# The user is banned if the authenticaction failed `max_retries` times in a `find_time` seconds window.
find_time: 120
# The length of time before a banned user can login again.
ban_time: 300
# Default redirection URL
#
# Note: this parameter is optional. If not provided, user won't
# be redirected upon successful authentication.
#default_redirection_url: https://authelia.example.domain
notifier:
# For testing purpose, notifications can be sent in a file
filesystem:
filename: /tmp/authelia/notification.txt

View File

@ -1,38 +0,0 @@
version: '2'
services:
traefik:
image: traefik
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./traefik.toml:/etc/traefik/traefik.toml
- /etc/traefik
labels:
- traefik.frontend.rule=Host:traefik.example.com
- traefik.port=8080
- traefik.frontend.auth.forward.address=https://auth.example.com/api/verify?rd=https://auth.example.com
- traefik.frontend.auth.forward.tls.insecureSkipVerify=true
authelia:
# image: clems4ever/authelia:latest
build:
context: ../..
dockerfile: Dockerfile.dev
restart: always
volumes:
- ./config.minimal.yml:/etc/authelia/config.yml:ro
- ./users_database.yml:/etc/authelia/users_database.yml:rw
- /tmp/authelia:/tmp/authelia
environment:
- NODE_TLS_REJECT_UNAUTHORIZED=1
labels:
- traefik.frontend.rule=Host:auth.example.com
whoami:
image: emilevauge/whoami
labels:
- traefik.frontend.rule=Host:who.example.com
- traefik.frontend.auth.forward.address=https://auth.example.com/api/verify?rd=https://auth.example.com
- traefik.frontend.auth.forward.tls.insecureSkipVerify=true

View File

@ -1,30 +0,0 @@
defaultEntryPoints = ["http", "https"]
# logLevel = "DEBUG"
[entryPoints]
[entryPoints.http]
address = ":80"
[entryPoints.http.redirect]
entryPoint = "https"
[entryPoints.https]
address = ":443"
[entryPoints.https.tls]
[entryPoints.api]
address = ":8080"
[api]
# This is exposed via a subdomain and a proxy
entryPoint = "api"
dashboard = true
[docker]
# Docker server endpoint. Can be a tcp or a unix socket endpoint.
endpoint = "unix:///var/run/docker.sock"
# network = "traefik_default"
# Default domain used.
# Can be overridden by setting the "traefik.domain" label on a container.
domain = "localhost"
# Enable watch docker changes
watch = true

View File

@ -51,6 +51,9 @@ async function checkHostsFile() {
await checkAndFixEntry(actualEntries, 'mail.example.com', '192.168.240.100');
await checkAndFixEntry(actualEntries, 'duo.example.com', '192.168.240.100');
// For Traefik suite.
await checkAndFixEntry(actualEntries, 'traefik.example.com', '192.168.240.100');
// For testing network ACLs.
await checkAndFixEntry(actualEntries, 'proxy-client1.example.com', '192.168.240.201');
await checkAndFixEntry(actualEntries, 'proxy-client2.example.com', '192.168.240.202');

View File

@ -10,11 +10,13 @@ import { AuthenticationSessionHandler } from "./AuthenticationSessionHandler";
import { AuthenticationSession } from "../../types/AuthenticationSession";
import { ServerVariables } from "./ServerVariables";
import { IdentityValidable } from "./IdentityValidable";
import * as Constants from "../../../shared/constants";
import Identity = require("../../types/Identity");
import { IdentityValidationDocument }
from "./storage/IdentityValidationDocument";
import { OPERATION_FAILED } from "../../../shared/UserMessages";
import GetHeader from "./utils/GetHeader";
function createAndSaveToken(userid: string, challenge: string,
userDataStore: IUserDataStore): BluebirdPromise<string> {
@ -111,8 +113,9 @@ export function post_start_validation(handler: IdentityValidable,
vars.userDataStore);
})
.then((token: string) => {
const host = req.get("Host");
const link_url = util.format("https://%s/#%s?token=%s", host,
const scheme = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const link_url = util.format("%s://%s/#%s?token=%s", scheme, host,
handler.destinationPath(), token);
vars.logger.info(req, "Notification sent to user \"%s\"",
identity.userid);

View File

@ -63,8 +63,7 @@ export default class RegistrationHandler implements IdentityValidable {
}
preValidationResponse(req: Express.Request, res: Express.Response) {
res.status(204);
res.send();
res.json({message: "OK"});
}
postValidationInit(req: Express.Request) {

View File

@ -1,11 +0,0 @@
import util = require("util");
import express = require("express");
function extract_app_id(req: express.Request): string {
return util.format("https://%s", req.headers.host);
}
export = {
extract_app_id: extract_app_id
};

View File

@ -51,8 +51,7 @@ export default class RegistrationHandler implements IdentityValidable {
}
preValidationResponse(req: express.Request, res: express.Response) {
res.status(204);
res.send();
res.json({message: "OK"});
}
postValidationInit(req: express.Request) {
@ -60,8 +59,7 @@ export default class RegistrationHandler implements IdentityValidable {
}
postValidationResponse(req: express.Request, res: express.Response) {
res.status(204);
res.send();
res.json({message: "OK"});
}
mailSubject(): string {

View File

@ -1,5 +1,4 @@
import objectPath = require("object-path");
import u2f_common = require("../U2FCommon");
import BluebirdPromise = require("bluebird");
import express = require("express");
import U2f = require("u2f");
@ -10,12 +9,16 @@ import { ServerVariables } from "../../../../ServerVariables";
import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler";
import UserMessages = require("../../../../../../../shared/UserMessages");
import { AuthenticationSession } from "../../../../../../types/AuthenticationSession";
import GetHeader from "../../../../utils/GetHeader";
import * as Constants from "../../../../../../../shared/constants";
export default function (vars: ServerVariables) {
function handler(req: express.Request, res: express.Response): BluebirdPromise<void> {
let authSession: AuthenticationSession;
const appid = u2f_common.extract_app_id(req);
const scheme = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const appid = scheme + "://" + host;
const registrationResponse: U2f.RegistrationData = req.body;
return new BluebirdPromise(function (resolve, reject) {

View File

@ -1,5 +1,4 @@
import u2f_common = require("../U2FCommon");
import BluebirdPromise = require("bluebird");
import express = require("express");
import U2f = require("u2f");
@ -8,11 +7,15 @@ import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionH
import { AuthenticationSession } from "../../../../../../types/AuthenticationSession";
import UserMessages = require("../../../../../../../shared/UserMessages");
import { ServerVariables } from "../../../../ServerVariables";
import GetHeader from "../../../../utils/GetHeader";
import * as Constants from "../../../../../../../shared/constants";
export default function (vars: ServerVariables) {
function handler(req: express.Request, res: express.Response): BluebirdPromise<void> {
let authSession: AuthenticationSession;
const appid: string = u2f_common.extract_app_id(req);
const scheme = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const appid = scheme + "://" + host;
return new BluebirdPromise(function (resolve, reject) {
authSession = AuthenticationSessionHandler.get(req, vars.logger);

View File

@ -1,5 +1,4 @@
import objectPath = require("object-path");
import u2f_common = require("../U2FCommon");
import BluebirdPromise = require("bluebird");
import express = require("express");
import { U2FRegistrationDocument } from "../../../../storage/U2FRegistrationDocument";
@ -11,11 +10,15 @@ import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionH
import UserMessages = require("../../../../../../../shared/UserMessages");
import { AuthenticationSession } from "../../../../../../types/AuthenticationSession";
import { Level } from "../../../../authentication/Level";
import GetHeader from "../../../../utils/GetHeader";
import * as Constants from "../../../../../../../shared/constants";
export default function (vars: ServerVariables) {
function handler(req: express.Request, res: express.Response): BluebirdPromise<void> {
let authSession: AuthenticationSession;
const appId = u2f_common.extract_app_id(req);
const scheme = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const appid = scheme + "://" + host;
return new BluebirdPromise(function (resolve, reject) {
authSession = AuthenticationSessionHandler.get(req, vars.logger);
@ -28,7 +31,7 @@ export default function (vars: ServerVariables) {
})
.then(function () {
const userid = authSession.userid;
return vars.userDataStore.retrieveU2FRegistration(userid, appId);
return vars.userDataStore.retrieveU2FRegistration(userid, appid);
})
.then(function (doc: U2FRegistrationDocument): BluebirdPromise<U2f.SignatureResult | U2f.Error> {
const signRequest = authSession.sign_request;

View File

@ -1,5 +1,3 @@
import u2f_common = require("../../../secondfactor/u2f/U2FCommon");
import BluebirdPromise = require("bluebird");
import express = require("express");
import { U2FRegistrationDocument } from "../../../../storage/U2FRegistrationDocument";
@ -9,28 +7,31 @@ import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionH
import UserMessages = require("../../../../../../../shared/UserMessages");
import { ServerVariables } from "../../../../ServerVariables";
import { AuthenticationSession } from "../../../../../../types/AuthenticationSession";
import GetHeader from "../../../../utils/GetHeader";
import * as Constants from "../../../../../../../shared/constants";
export default function (vars: ServerVariables) {
function handler(req: express.Request, res: express.Response): BluebirdPromise<void> {
let authSession: AuthenticationSession;
const appId = u2f_common.extract_app_id(req);
const scheme = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const appid = scheme + "://" + host;
return new BluebirdPromise(function (resolve, reject) {
authSession = AuthenticationSessionHandler.get(req, vars.logger);
resolve();
})
.then(function () {
return vars.userDataStore.retrieveU2FRegistration(authSession.userid, appId);
return vars.userDataStore.retrieveU2FRegistration(authSession.userid, appid);
})
.then(function (doc: U2FRegistrationDocument): BluebirdPromise<void> {
if (!doc)
return BluebirdPromise.reject(new exceptions.AccessDeniedError("No U2F registration document found."));
const appId: string = u2f_common.extract_app_id(req);
vars.logger.info(req, "Start authentication of app '%s'", appId);
vars.logger.debug(req, "AppId = %s, keyHandle = %s", appId, JSON.stringify(doc.registration.keyHandle));
vars.logger.info(req, "Start authentication of app '%s'", appid);
vars.logger.debug(req, "AppId = %s, keyHandle = %s", appid, JSON.stringify(doc.registration.keyHandle));
const request = vars.u2f.request(appId, doc.registration.keyHandle);
const request = vars.u2f.request(appid, doc.registration.keyHandle);
authSession.sign_request = request;
res.json(request);
return BluebirdPromise.resolve();

View File

@ -88,7 +88,7 @@ describe("routes/verify/get", function () {
req.query.rd = 'https://login.example.com/';
const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.reject(new NotAuthenticatedError('No!')));
await Get(vars)(req, res as any);
Assert(res.redirect.calledWith('https://login.example.com/'));
Assert(res.redirect.calledWith('https://login.example.com/?rd=https://secret.example.com/'));
mock.restore();
});
});

View File

@ -10,7 +10,7 @@ import { AuthenticationSessionHandler }
import { AuthenticationSession }
from "../../../../types/AuthenticationSession";
import HasHeader from "../..//utils/HasHeader";
import { RequestUrlGetter } from "../../utils/RequestUrlGetter";
import RequestUrlGetter from "../../utils/RequestUrlGetter";
async function verifyWithSelectedMethod(req: Express.Request, res: Express.Response,
@ -24,42 +24,41 @@ async function verifyWithSelectedMethod(req: Express.Request, res: Express.Respo
}
}
/**
* The Redirect header is used to set the target URL in the login portal.
*
* @param req The request to extract X-Original-Url from.
* @param res The response to write Redirect header to.
*/
function setRedirectHeader(req: Express.Request, res: Express.Response) {
const originalUrl = RequestUrlGetter.getOriginalUrl(req);
res.set(Constants.HEADER_REDIRECT, originalUrl);
}
function getRedirectParam(req: Express.Request) {
return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined"
? req.query[Constants.REDIRECT_QUERY_PARAM]
: undefined;
}
async function unsafeGet(vars: ServerVariables, req: Express.Request, res: Express.Response) {
const authSession = AuthenticationSessionHandler.get(req, vars.logger);
try {
await verifyWithSelectedMethod(req, res, vars, authSession);
res.status(204);
res.send();
} catch (err) {
// Kubernetes ingress controller and Traefik use the rd parameter of the verify
// endpoint to provide the URL of the login portal. The target URL of the user
// is computed from X-Fowarded-* headers or X-Original-Url.
let redirectUrl = getRedirectParam(req);
const originalUrl = RequestUrlGetter.getOriginalUrl(req);
if (redirectUrl && originalUrl) {
redirectUrl = redirectUrl + `?${Constants.REDIRECT_QUERY_PARAM}=` + originalUrl;
ErrorReplies.redirectTo(redirectUrl, req, res, vars.logger)(err);
return;
}
// Reply with an error.
throw err;
}
}
export default function (vars: ServerVariables) {
return async function (req: Express.Request, res: Express.Response)
: Promise<void> {
const authSession = AuthenticationSessionHandler.get(req, vars.logger);
setRedirectHeader(req, res);
try {
await verifyWithSelectedMethod(req, res, vars, authSession);
res.status(204);
res.send();
await unsafeGet(vars, req, res);
} catch (err) {
// This redirect parameter is used in Kubernetes to annotate the ingress with
// the url to the authentication portal.
const redirectUrl = getRedirectParam(req);
if (redirectUrl) {
ErrorReplies.redirectTo(redirectUrl, req, res, vars.logger)(err);
return;
}
if (err instanceof Exceptions.NotAuthorizedError) {
ErrorReplies.replyWithError403(req, res, vars.logger)(err);
} else {

View File

@ -6,8 +6,7 @@ import GetHeader from "../../utils/GetHeader";
import { HEADER_PROXY_AUTHORIZATION } from "../../../../../shared/constants";
import setUserAndGroupsHeaders from "./SetUserAndGroupsHeaders";
import CheckAuthorizations from "./CheckAuthorizations";
import { Level as AuthorizationLevel } from "../../authorization/Level";
import { RequestUrlGetter } from "../../utils/RequestUrlGetter";
import RequestUrlGetter from "../../utils/RequestUrlGetter";
export default async function(req: Express.Request, res: Express.Response,
vars: ServerVariables)

View File

@ -2,11 +2,11 @@ import Express = require("express");
import { ServerVariables } from "../../ServerVariables";
import { AuthenticationSession }
from "../../../../types/AuthenticationSession";
import { URLDecomposer } from "../../utils/URLDecomposer";
import setUserAndGroupsHeaders from "./SetUserAndGroupsHeaders";
import CheckAuthorizations from "./CheckAuthorizations";
import CheckInactivity from "./CheckInactivity";
import { RequestUrlGetter } from "../../utils/RequestUrlGetter";
import RequestUrlGetter from "../../utils/RequestUrlGetter";
import * as URLParse from "url-parse";
export default async function (req: Express.Request, res: Express.Response,
@ -22,15 +22,16 @@ export default async function (req: Express.Request, res: Express.Response,
throw new Error("Cannot detect the original URL from headers.");
}
const d = URLDecomposer.fromUrl(originalUrl);
const url = new URLParse(originalUrl);
const username = authSession.userid;
const groups = authSession.groups;
vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s, ip=%s", d.domain,
d.path, (username) ? username : "unknown", (groups instanceof Array && groups.length > 0) ? groups.join(",") : "unknown", req.ip);
vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s, ip=%s", url.hostname,
url.pathname, (username) ? username : "unknown",
(groups instanceof Array && groups.length > 0) ? groups.join(",") : "unknown", req.ip);
CheckAuthorizations(vars.authorizer, d.domain, d.path, username, groups, req.ip, authSession.authentication_level);
CheckAuthorizations(vars.authorizer, url.hostname, url.pathname, username, groups,
req.ip, authSession.authentication_level);
CheckInactivity(req, authSession, vars.config, vars.logger);
setUserAndGroupsHeaders(res, username, groups);
}

View File

@ -11,7 +11,7 @@ import { GET_VARIABLE_KEY } from "../../../../shared/constants";
*/
export default function(req: Express.Request, header: string): string | undefined {
const variables: ServerVariables = req.app.get(GET_VARIABLE_KEY);
if (!variables) return undefined;
if (!variables) throw new Error("There are no server variables set.");
const value = ObjectPath.get<Express.Request, string>(req, "headers." + header, undefined);
variables.logger.debug(req, "Header %s is set to %s", header, value);

View File

@ -0,0 +1,51 @@
import RequestUrlGetter from "./RequestUrlGetter";
import * as Assert from "assert";
import * as Sinon from "sinon";
import { RequestLoggerStub } from "../logging/RequestLoggerStub.spec";
describe('RequestUrlGetter', function() {
let req: any;
beforeEach(function() {
req = {
app: {
get: Sinon.stub().returns({
logger: new RequestLoggerStub()
})
},
headers: {}
}
})
it("should return the content of X-Original-Uri header", function() {
req.headers["x-original-url"] = "https://mytarget.example.com";
Assert.equal(RequestUrlGetter.getOriginalUrl(req), "https://mytarget.example.com");
})
describe("Use X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Uri headers", function() {
it("should get URL from Forwarded headers", function() {
req.headers["x-forwarded-proto"] = "https";
req.headers["x-forwarded-host"] = "mytarget.example.com";
req.headers["x-forwarded-uri"] = "/"
Assert.equal(RequestUrlGetter.getOriginalUrl(req), "https://mytarget.example.com/");
})
it("should get URL from Forwarded headers without URI", function() {
req.headers["x-forwarded-proto"] = "https";
req.headers["x-forwarded-host"] = "mytarget.example.com";
Assert.equal(RequestUrlGetter.getOriginalUrl(req), "https://mytarget.example.com");
})
});
it("should throw when no header is provided", function() {
Assert.throws(() => {
RequestUrlGetter.getOriginalUrl(req)
})
})
it("should throw when only some of X-Forwarded-* headers are provided", function() {
req.headers["x-forwarded-proto"] = "https";
Assert.throws(() => {
RequestUrlGetter.getOriginalUrl(req)
})
})
})

View File

@ -3,23 +3,27 @@ import Express = require("express");
import GetHeader from "./GetHeader";
import HasHeader from "./HasHeader";
export class RequestUrlGetter {
export default class RequestUrlGetter {
static getOriginalUrl(req: Express.Request): string {
if (HasHeader(req, Constants.HEADER_X_ORIGINAL_URL)) {
return GetHeader(req, Constants.HEADER_X_ORIGINAL_URL);
}
// X-Forwarded-Port is not mandatory since the port is included in X-Forwarded-Host
// at least in nginx and Traefik.
const proto = GetHeader(req, Constants.HEADER_X_FORWARDED_PROTO);
const host = GetHeader(req, Constants.HEADER_X_FORWARDED_HOST);
const port = GetHeader(req, Constants.HEADER_X_FORWARDED_PORT);
const uri = GetHeader(req, Constants.HEADER_X_FORWARDED_URI);
if (!proto || !host || !port) {
throw new Error("Missing headers holding requested URL. Requires X-Original-Url or X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port")
if (!proto || !host) {
throw new Error("Missing headers holding requested URL. Requires either X-Original-Url or X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Uri.");
}
return "${proto}://${host}:${port}${uri}";
if (!uri) {
return `${proto}://${host}`;
}
return `${proto}://${host}${uri}`;
}
}

View File

@ -7,7 +7,6 @@ export const HEADER_X_TARGET_URL = "x-target-url";
export const HEADER_X_ORIGINAL_URL = "x-original-url";
export const HEADER_X_FORWARDED_PROTO = "x-forwarded-proto";
export const HEADER_X_FORWARDED_HOST = "x-forwarded-host";
export const HEADER_X_FORWARDED_PORT = "x-forwarded-port";
export const HEADER_X_FORWARDED_URI = "x-forwarded-uri";
export const HEADER_PROXY_AUTHORIZATION = "proxy-authorization";
export const HEADER_REDIRECT = "redirect";

View File

@ -5,11 +5,12 @@ import { StatusCodeError } from 'request-promise/errors';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
export async function GET_ExpectError(url: string, statusCode: number) {
export async function GET_ExpectError(url: string, headers: {[key: string]: string}, statusCode: number) {
try {
await Request.get(url, {
json: true,
rejectUnauthorized: false,
headers: headers,
});
throw new Error('No response');
} catch (e) {
@ -22,12 +23,12 @@ export async function GET_ExpectError(url: string, statusCode: number) {
}
// Sent a GET request to the url and expect a 401
export async function GET_Expect401(url: string) {
return await GET_ExpectError(url, 401);
export async function GET_Expect401(url: string, headers: {[key: string]: string} = {}) {
return await GET_ExpectError(url, headers, 401);
}
export async function GET_Expect502(url: string) {
return await GET_ExpectError(url, 502);
export async function GET_Expect502(url: string, headers: {[key: string]: string} = {}) {
return await GET_ExpectError(url, headers, 502);
}
export async function POST_Expect401(url: string, body?: any) {
@ -47,8 +48,8 @@ export async function POST_Expect401(url: string, body?: any) {
return;
}
export async function GET_ExpectRedirect(url: string, redirectionUrl: string) {
const response = await Fetch(url, {redirect: 'manual'});
export async function GET_ExpectRedirect(url: string, redirectionUrl: string, headers: {[key: string]: string} = {}) {
const response = await Fetch(url, {redirect: 'manual', headers: headers});
if (response.status == 302) {
const body = await response.text();

View File

@ -6,6 +6,8 @@ import CustomHeadersForwarded from "./scenarii/CustomHeadersForwarded";
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
AutheliaSuite(__dirname, function() {
this.timeout(10000);

View File

@ -2,14 +2,32 @@ import { GET_Expect401, GET_ExpectRedirect } from "../../../helpers/utils/Reques
export default function() {
describe('Query without authenticated cookie', function() {
it('should get a 401 on GET to https://login.example.com:8080/api/verify', async function() {
await GET_Expect401('https://login.example.com:8080/api/verify');
it('should get a 401 on GET to http://192.168.240.1:9091/api/verify', async function() {
await GET_Expect401('http://192.168.240.1:9091/api/verify', {
'X-Forwarded-Proto': 'https',
});
});
describe('Parameter `rd` required by Kubernetes ingress controller', async function() {
describe('Kubernetes nginx ingress controller', async function() {
it('should redirect to https://login.example.com:8080', async function() {
await GET_ExpectRedirect('https://login.example.com:8080/api/verify?rd=https://login.example.com:8080',
'https://login.example.com:8080');
await GET_ExpectRedirect('http://192.168.240.1:9091/api/verify?rd=https://login.example.com:8080/%23/',
'https://login.example.com:8080/#/?rd=https://secure.example.com:8080',
{
'X-Original-Url': 'https://secure.example.com:8080',
'X-Forwarded-Proto': 'https'
});
});
});
describe('Traefik proxy', async function() {
it('should redirect to https://login.example.com:8080', async function() {
await GET_ExpectRedirect('http://192.168.240.1:9091/api/verify?rd=https://login.example.com:8080/%23/',
'https://login.example.com:8080/#/?rd=https://secure.example.com:8080/',
{
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'secure.example.com:8080',
'X-Forwarded-Uri': '/',
});
});
});
});

View File

@ -0,0 +1,11 @@
# Traefik suite
This suite has been created to test Authelia against Traefik.
## Components
Authelia, Traefik, fake webmail for registering devices.
## Tests
Authentication tests.

View File

@ -0,0 +1,43 @@
###############################################################
# Authelia minimal configuration #
###############################################################
port: 9091
logs_level: debug
authentication_backend:
file:
path: ./test/suites/basic/users_database.test.yml
session:
secret: unsecure_session_secret
domain: example.com
expiration: 3600000 # 1 hour
inactivity: 300000 # 5 minutes
storage:
local:
path: /tmp/authelia/db
access_control:
default_policy: bypass
rules:
- domain: 'public.example.com'
policy: bypass
- domain: 'admin.example.com'
policy: two_factor
- domain: 'secure.example.com'
policy: two_factor
- domain: 'singlefactor.example.com'
policy: one_factor
notifier:
smtp:
username: test
password: password
secure: false
host: 127.0.0.1
port: 1025
sender: admin@example.com

View File

@ -0,0 +1,36 @@
import { exec } from "../../helpers/utils/exec";
import AutheliaServer from "../../helpers/context/AutheliaServer";
import DockerEnvironment from "../../helpers/context/DockerEnvironment";
import * as fs from "fs";
const autheliaServer = new AutheliaServer(__dirname + '/config.yml');
const dockerEnv = new DockerEnvironment([
'docker-compose.yml',
'example/compose/nginx/backend/docker-compose.yml',
'example/compose/traefik/docker-compose.yml',
'example/compose/smtp/docker-compose.yml',
])
async function setup() {
await exec('./example/compose/traefik/render.js ' + (fs.existsSync('.suite') ? '': '--production'));
await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
await exec('mkdir -p /tmp/authelia/db');
await dockerEnv.start();
await autheliaServer.start();
}
async function teardown() {
await autheliaServer.stop();
await dockerEnv.stop();
await exec('rm -rf /tmp/authelia/db');
}
const setup_timeout = 30000;
const teardown_timeout = 30000;
export {
setup,
setup_timeout,
teardown,
teardown_timeout
};

View File

@ -0,0 +1,17 @@
import AutheliaSuite from "../../helpers/context/AutheliaSuite";
import { exec } from '../../helpers/utils/exec';
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
import SingleFactorAuthentication from "../../helpers/scenarii/SingleFactorAuthentication";
import * as fs from "fs";
AutheliaSuite(__dirname, function() {
this.timeout(10000);
beforeEach(async function() {
await exec('./example/compose/traefik/render.js ' + (fs.existsSync('.suite') ? '': '--production'));
await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
});
describe('Single-factor authentication', SingleFactorAuthentication());
describe('Second factor authentication', TwoFactorAuthentication());
});

View File

@ -15,7 +15,7 @@ users:
harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com
email: harry.potter@authelia.com
groups: []
bob: