From ba65a3db826cce0046f0f0a10d33b1c301c37248 Mon Sep 17 00:00:00 2001 From: ThinkChaos Date: Tue, 23 Feb 2021 23:35:04 +0000 Subject: [PATCH] feat(handlers): authorization header switch via query param to /api/verify (#1563) * [FEATURE] Add auth query param to /api/verify (#1353) When `/api/verify` is called with `?auth=basic`, use the standard Authorization header instead of Proxy-Authorization. * [FIX] Better basic auth error reporting * [FIX] Return 401 when using basic auth instead of redirecting * [TESTS] Add tests for auth=basic query param * [DOCS] Mention auth=basic argument and provide nginx example * docs: add/adjust basic auth query arg docs for proxies Co-authored-by: Amir Zarrinkafsh --- docs/deployment/supported-proxies/haproxy.md | 48 ++++- docs/deployment/supported-proxies/nginx.md | 182 +++++++++++++++++- .../supported-proxies/traefik1.x.md | 26 +++ .../supported-proxies/traefik2.x.md | 30 +++ docs/features/single-factor.md | 17 +- internal/handlers/const.go | 7 +- internal/handlers/handler_verify.go | 105 +++++----- internal/handlers/handler_verify_test.go | 109 ++++++++++- 8 files changed, 464 insertions(+), 60 deletions(-) diff --git a/docs/deployment/supported-proxies/haproxy.md b/docs/deployment/supported-proxies/haproxy.md index 51d294bea..735d78e4f 100644 --- a/docs/deployment/supported-proxies/haproxy.md +++ b/docs/deployment/supported-proxies/haproxy.md @@ -28,13 +28,15 @@ Below you will find commented examples of the following configuration: * Authelia portal * Protected endpoint (Nextcloud) +* Protected endpoint with `Authorization` header for basic authentication (Heimdall) * [haproxy-auth-request](https://github.com/TimWolla/haproxy-auth-request/blob/master/auth-request.lua) With this configuration you can protect your virtual hosts with Authelia, by following the steps below: -1. Add host(s) to the `protected-frontends` ACL to support protection with Authelia. +1. Add host(s) to the `protected-frontends` or `protected-frontends-basic` ACLs to support protection with Authelia. You can separate each subdomain with a `|` in the regex, for example: ``` acl protected-frontends hdr(host) -m reg -i ^(?i)(jenkins|nextcloud|phpmyadmin)\.example\.com + acl protected-frontends-basic hdr(host) -m reg -i ^(?i)(heimdall)\.example\.com ``` 2. Add host ACL(s) in the form of `host-service`, this will be utilised to route to the correct backend upon successful authentication, for example: @@ -42,12 +44,14 @@ backend upon successful authentication, for example: acl host-jenkins hdr(host) -i jenkins.example.com acl host-nextcloud hdr(host) -i nextcloud.example.com acl host-phpmyadmin hdr(host) -i phpmyadmin.example.com + acl host-heimdall hdr(host) -i heimdall.example.com ``` 3. Add backend route for your service(s), for example: ``` use_backend be_jenkins if host-jenkins use_backend be_nextcloud if host-nextcloud use_backend be_phpmyadmin if host-phpmyadmin + use_backend be_heimdall if host-heimdall ``` 4. Add backend definitions for your service(s), for example: ``` @@ -57,6 +61,8 @@ backend upon successful authentication, for example: server nextcloud nextcloud:443 ssl verify none backend be_phpmyadmin server phpmyadmin phpmyadmin:80 + backend be_heimdall + server heimdall heimdall:443 ssl verify none ``` ### Secure Authelia with TLS @@ -87,8 +93,10 @@ frontend fe_http # Host ACLs acl protected-frontends hdr(host) -m reg -i ^(?i)(nextcloud)\.example\.com + acl protected-frontends-basic hdr(host) -m reg -i ^(?i)(heimdall)\.example\.com acl host-authelia hdr(host) -i auth.example.com acl host-nextcloud hdr(host) -i nextcloud.example.com + acl host-heimdall hdr(host) -i heimdall.example.com http-request set-var(req.scheme) str(https) if { ssl_fc } http-request set-var(req.scheme) str(http) if !{ ssl_fc } @@ -102,13 +110,16 @@ frontend fe_http # Protect endpoints with haproxy-auth-request and Authelia http-request lua.auth-request be_authelia /api/verify if protected-frontends + # Force `Authorization` header via query arg to /api/verify + http-request lua.auth-request be_authelia /api/verify?auth=basic if protected-frontends-basic # Authelia backend route use_backend be_authelia if host-authelia # Redirect protected-frontends to Authelia if not authenticated - http-request redirect location https://auth.example.com/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query] if protected-frontends !{ var(txn.auth_response_successful) -m bool } + http-request redirect location https://auth.example.com/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query] if (protected-frontends || protected-frontends-basic) !{ var(txn.auth_response_successful) -m bool } # Service backend route(s) use_backend be_nextcloud if host-nextcloud + use_backend be_heimdall if host-heimdall backend be_authelia server authelia authelia:9091 @@ -125,6 +136,19 @@ backend be_nextcloud http-request set-header Remote-Email %[var(req.auth_response_header.remote_email)] if remote_email_exist server nextcloud nextcloud:443 ssl verify none + +backend be_heimdall + # Pass Remote-User, Remote-Name, Remote-Email and Remote-Groups headers + acl remote_user_exist var(req.auth_response_header.remote_user) -m found + acl remote_groups_exist var(req.auth_response_header.remote_groups) -m found + acl remote_name_exist var(req.auth_response_header.remote_name) -m found + acl remote_email_exist var(req.auth_response_header.remote_email) -m found + http-request set-header Remote-User %[var(req.auth_response_header.remote_user)] if remote_user_exist + http-request set-header Remote-Groups %[var(req.auth_response_header.remote_groups)] if remote_groups_exist + http-request set-header Remote-Name %[var(req.auth_response_header.remote_name)] if remote_name_exist + http-request set-header Remote-Email %[var(req.auth_response_header.remote_email)] if remote_email_exist + + server heimdall heimdall:443 ssl verify none ``` ##### haproxy.cfg (TLS enabled Authelia) @@ -147,8 +171,10 @@ frontend fe_http # Host ACLs acl protected-frontends hdr(host) -m reg -i ^(?i)(nextcloud)\.example\.com + acl protected-frontends-basic hdr(host) -m reg -i ^(?i)(heimdall)\.example\.com acl host-authelia hdr(host) -i auth.example.com acl host-nextcloud hdr(host) -i nextcloud.example.com + acl host-heimdall hdr(host) -i heimdall.example.com http-request set-var(req.scheme) str(https) if { ssl_fc } http-request set-var(req.scheme) str(http) if !{ ssl_fc } @@ -162,13 +188,16 @@ frontend fe_http # Protect endpoints with haproxy-auth-request and Authelia http-request lua.auth-request be_authelia_proxy /api/verify if protected-frontends + # Force `Authorization` header via query arg to /api/verify + http-request lua.auth-request be_authelia /api/verify?auth=basic if protected-frontends-basic # Authelia backend route use_backend be_authelia if host-authelia # Redirect protected-frontends to Authelia if not authenticated - http-request redirect location https://auth.example.com/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query] if protected-frontends !{ var(txn.auth_response_successful) -m bool } + http-request redirect location https://auth.example.com/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query] if (protected-frontends || protected-frontends-basic) !{ var(txn.auth_response_successful) -m bool } # Service backend route(s) use_backend be_nextcloud if host-nextcloud + use_backend be_heimdall if host-heimdall backend be_authelia server authelia authelia:9091 @@ -194,6 +223,19 @@ backend be_nextcloud http-request set-header Remote-Email %[var(req.auth_response_header.remote_email)] if remote_email_exist server nextcloud nextcloud:443 ssl verify none + +backend be_heimdall + # Pass Remote-User, Remote-Name, Remote-Email and Remote-Groups headers + acl remote_user_exist var(req.auth_response_header.remote_user) -m found + acl remote_groups_exist var(req.auth_response_header.remote_groups) -m found + acl remote_name_exist var(req.auth_response_header.remote_name) -m found + acl remote_email_exist var(req.auth_response_header.remote_email) -m found + http-request set-header Remote-User %[var(req.auth_response_header.remote_user)] if remote_user_exist + http-request set-header Remote-Groups %[var(req.auth_response_header.remote_groups)] if remote_groups_exist + http-request set-header Remote-Name %[var(req.auth_response_header.remote_name)] if remote_name_exist + http-request set-header Remote-Email %[var(req.auth_response_header.remote_email)] if remote_email_exist + + server heimdall heimdall:443 ssl verify none ``` [HAproxy]: https://www.haproxy.org/ diff --git a/docs/deployment/supported-proxies/nginx.md b/docs/deployment/supported-proxies/nginx.md index 0809fd65b..949bd7ee2 100644 --- a/docs/deployment/supported-proxies/nginx.md +++ b/docs/deployment/supported-proxies/nginx.md @@ -25,13 +25,15 @@ With the below configuration you can add `authelia.conf` to virtual hosts to sup #### Supplementary config ##### authelia.conf + ```nginx +set $upstream_authelia http://authelia:9091/api/verify; + # Virtual endpoint created by nginx to forward auth requests. location /authelia { internal; - set $upstream_authelia http://authelia:9091/api/verify; proxy_pass_request_body off; - proxy_pass $upstream_authelia; + proxy_pass $upstream_authelia; proxy_set_header Content-Length ""; # Timeout if the real server is dead @@ -46,7 +48,7 @@ location /authelia { proxy_set_header Host $host; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Uri $request_uri; @@ -67,6 +69,7 @@ location /authelia { ``` ##### auth.conf + ```nginx # Basic Authelia Config # Send a subsequent request to Authelia to verify if the user is authenticated @@ -94,6 +97,7 @@ error_page 401 =302 https://auth.example.com/?rd=$target_url; ``` ##### proxy.conf + ```nginx client_body_buffer_size 128k; @@ -145,7 +149,7 @@ server { include /config/nginx/ssl.conf; location / { - set $upstream_authelia http://authelia:9091; # This example assumes a Docker deployment + set $upstream_authelia http://authelia:9091; # This example assumes a Docker deployment proxy_pass $upstream_authelia; include /config/nginx/proxy.conf; } @@ -176,4 +180,174 @@ server { } ``` +### Basic Auth Example + +Here's an example for using HTTP basic auth on a specific endpoint. It is based on the full example above. + +##### authelia-basic.conf + +```nginx +# Notice we added the auth=basic query arg here +set $upstream_authelia http://authelia:9091/api/verify?auth=basic; + +location /authelia { + internal; + proxy_pass_request_body off; + proxy_pass $upstream_authelia; + proxy_set_header Content-Length ""; + + # Timeout if the real server is dead + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; + + # [REQUIRED] Needed by Authelia to check authorizations of the resource. + # 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. + # Basic Proxy Config + client_body_buffer_size 128k; + proxy_set_header Host $host; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + 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-Ssl on; + proxy_redirect http:// $scheme://; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_cache_bypass $cookie_session; + proxy_no_cache $cookie_session; + proxy_buffers 4 32k; + + # Advanced Proxy Config + send_timeout 5m; + proxy_read_timeout 240; + proxy_send_timeout 240; + proxy_connect_timeout 240; +} +``` + +##### auth-basic.conf + +Same as `auth.conf` but without the `error_page` directive. We want nginx to proxy the 401 back to the client, not to return a 301. + +```nginx +# Basic Authelia Config +# Send a subsequent request to Authelia to verify if the user is authenticated +# and has the right permissions to access the resource. +auth_request /authelia; +# Set the `target_url` variable based on the request. It will be used to build the portal +# URL with the correct redirection parameter. +auth_request_set $target_url $scheme://$http_host$request_uri; +# Set the X-Forwarded-User and X-Forwarded-Groups with the headers +# returned by Authelia for the backends which can consume them. +# This is not safe, as the backend must make sure that they come from the +# proxy. In the future, it's gonna be safe to just use OAuth. +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Name $name; +proxy_set_header Remote-Email $email; +# If Authelia returns 401, then nginx passes it to the user. +# If it returns 200, then the request pass through to the backend. +``` + +#### Protected Endpoint + +```nginx +server { + server_name nextcloud.example.com; + listen 80; + return 301 https://$server_name$request_uri; +} + +server { + server_name nextcloud.example.com; + listen 443 ssl http2; + include /config/nginx/ssl.conf; + include /config/nginx/authelia-basic.conf; # Use the "basic" endpoint + + location / { + set $upstream_nextcloud https://nextcloud; + proxy_pass $upstream_nextcloud; + include /config/nginx/auth-basic.conf; # Activate authelia with basic auth + include /config/nginx/proxy.conf; # this file is the exact same as above + } +} +``` + + +### Basic auth for specific client + +If you'd like to force basic auth for some requests, you can use the following template: + +##### authelia-detect.conf + +```nginx +set $is_basic_auth ""; # false value +set $upstream_authelia http://authelia:9091/api/verify; + +# Detect the client you want to force basic auth for here +# For the example we just match a path on the original request +if ($request_uri = "/force-basic") { + set $is_basic_auth "true"; + set $upstream_authelia "$upstream_authelia?auth=basic"; +} + +location = /authelia { + # Same as above +} + +# A new virtual endpoint to used if the auth_request failed +location = /authelia-redirect { + internal; + + if ($is_basic_auth) { + # This is a request where we decided to use basic auth, return a 401. + # Nginx will also proxy back the WWW-Authenticate header from Authelia's + # response. This is what informs the client we're expecting basic auth. + return 401; + } + + # The original request didn't target /force-basic, redirect to the pretty login page + # This is what `error_page 401 =302 https://auth.example.com/?rd=$target_url;` did. + return 302 https://auth.example.com/$is_args$args; +} +``` + +##### auth.conf + +Here we replace `error_page` directive to determine if basic auth should be utilised or not. + +```nginx +# Basic Authelia Config +# Send a subsequent request to Authelia to verify if the user is authenticated +# and has the right permissions to access the resource. +auth_request /authelia; +# Set the `target_url` variable based on the request. It will be used to build the portal +# URL with the correct redirection parameter. +auth_request_set $target_url $scheme://$http_host$request_uri; +# Set the X-Forwarded-User and X-Forwarded-Groups with the headers +# returned by Authelia for the backends which can consume them. +# This is not safe, as the backend must make sure that they come from the +# proxy. In the future, it's gonna be safe to just use OAuth. +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Name $name; +proxy_set_header Remote-Email $email; +# If Authelia returns 401, then nginx passes it to the user. +# If it returns 200, then the request pass through to the backend. +error_page 401 /authelia-redirect?rd=$target_url; +``` + +This tells nginx to use the virtual endpoint we defined above in case the auth_request failed. + [nginx]: https://www.nginx.com/ diff --git a/docs/deployment/supported-proxies/traefik1.x.md b/docs/deployment/supported-proxies/traefik1.x.md index a5d27c730..872043e78 100644 --- a/docs/deployment/supported-proxies/traefik1.x.md +++ b/docs/deployment/supported-proxies/traefik1.x.md @@ -17,11 +17,17 @@ Below you will find commented examples of the following configuration: * Traefik 1.x * Authelia portal * Protected endpoint (Nextcloud) +* Protected endpoint with `Authorization` header for basic authentication (Heimdall) The below configuration looks to provide examples of running Traefik 1.x with labels to protect your endpoint (Nextcloud in this case). Please ensure that you also setup the respective [ACME configuration](https://docs.traefik.io/v1.7/configuration/acme/) for your Traefik setup as this is not covered in the example below. +### Basic Authentication + +Authelia provides the means to be able to authenticate your first factor via the `Proxy-Authorization` header. +Given that this is not compatible with Traefik 1.x you can call Authelia's `/api/verify` endpoint with the `auth=basic` query parameter to force a switch to the `Authentication` header. + ##### docker-compose.yml ```yml version: '3' @@ -94,6 +100,26 @@ services: - PUID=1000 - PGID=1000 - TZ=Australia/Melbourne + + heimdall: + image: linuxserver/heimdall + container_name: heimdall + volumes: + - /path/to/heimdall/config:/config + networks: + - net + labels: + - 'traefik.frontend.rule=Host:heimdall.example.com' + - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/verify?auth=basic + - 'traefik.frontend.auth.forward.trustForwardHeader=true' + - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' + expose: + - 443 + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Australia/Melbourne ``` [Traefik 1.x]: https://docs.traefik.io/v1.7/ diff --git a/docs/deployment/supported-proxies/traefik2.x.md b/docs/deployment/supported-proxies/traefik2.x.md index 7d632bea4..dfc2f5824 100644 --- a/docs/deployment/supported-proxies/traefik2.x.md +++ b/docs/deployment/supported-proxies/traefik2.x.md @@ -17,11 +17,17 @@ Below you will find commented examples of the following configuration: * Traefik 2.x * Authelia portal * Protected endpoint (Nextcloud) +* Protected endpoint with `Authorization` header for basic authentication (Heimdall) The below configuration looks to provide examples of running Traefik 2.x with labels to protect your endpoint (Nextcloud in this case). Please ensure that you also setup the respective [ACME configuration](https://docs.traefik.io/https/acme/) for your Traefik setup as this is not covered in the example below. +### Basic Authentication + +Authelia provides the means to be able to authenticate your first factor via the `Proxy-Authorization` header, this is compatible with Traefik >= 2.4.1. +If you are running Traefik < 2.4.1, or you have a use-case which requires the use of the `Authorization` header/basic authentication login prompt you can call Authelia's `/api/verify` endpoint with the `auth=basic` query parameter to force a switch to the `Authentication` header. + ##### docker-compose.yml ```yml version: '3' @@ -77,6 +83,9 @@ services: - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://login.example.com/' - 'traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true' - 'traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User, Remote-Groups, Remote-Name, Remote-Email' + - 'traefik.http.middlewares.authelia-basic.forwardauth.address=http://authelia:9091/api/verify?auth=basic' + - 'traefik.http.middlewares.authelia-basic.forwardauth.trustForwardHeader=true' + - 'traefik.http.middlewares.authelia-basic.forwardauth.authResponseHeaders=Remote-User, Remote-Groups, Remote-Name, Remote-Email' expose: - 9091 restart: unless-stopped @@ -104,6 +113,27 @@ services: - PUID=1000 - PGID=1000 - TZ=Australia/Melbourne + + heimdall: + image: linuxserver/heimdall + container_name: heimdall + volumes: + - /path/to/heimdall/config:/config + networks: + - net + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.heimdall.rule=Host(`heimdall.example.com`)' + - 'traefik.http.routers.heimdall.entrypoints=https' + - 'traefik.http.routers.heimdall.tls=true' + - 'traefik.http.routers.heimdall.middlewares=authelia-basic@docker' + expose: + - 443 + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Australia/Melbourne ``` ## FAQ diff --git a/docs/features/single-factor.md b/docs/features/single-factor.md index 8d32ec3ca..b7b4bd568 100644 --- a/docs/features/single-factor.md +++ b/docs/features/single-factor.md @@ -21,20 +21,33 @@ To know more about the configuration of the feature, please visit the documentation about the [configuration](../configuration/access-control.md). -## Proxy-Authorization header +## HTTP Basic Auth + +Authelia supports two different methods for basic auth. + +### Proxy-Authorization header Authelia reads credentials from the header `Proxy-Authorization` instead of the usual `Authorization` header. This is because in some circumstances both Authelia and the application could require authentication in order to provide specific authorizations at the level of the application. +### API argument + +If instead of the `Proxy-Authorization` header you want, or need, to use the more +conventional `Authorization` header, you should then configure your reverse-proxy +to use `/api/verify?auth=basic`. +When authentication fails and `auth=basic` was set, Authelia's response will include +the `WWW-Authenticate` header. This will cause browsers to prompt for authentication, +and users will not land on the HTML login page. + ## Session-Username header Authelia by default only verifies the cookie and the associated user with that cookie can access a protected resource. The client browser does not know the username and does not send this to Authelia, it's stored by Authelia for security reasons. - + The Session-Username header has been implemented as a means to use Authelia with non-web services such as PAM. Basically how it works is if the Session-Username header is sent in the request to the /api/verify endpoint it will diff --git a/internal/handlers/const.go b/internal/handlers/const.go index faed58997..c3f3701ea 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -11,8 +11,11 @@ const ResetPasswordAction = "ResetPassword" const authPrefix = "Basic " -// AuthorizationHeader is the basic-auth HTTP header Authelia utilises. -const AuthorizationHeader = "Proxy-Authorization" +// ProxyAuthorizationHeader is the basic-auth HTTP header Authelia utilises. +const ProxyAuthorizationHeader = "Proxy-Authorization" + +// AuthorizationHeader is the basic-auth HTTP header Authelia utilises with "auth=basic" query param. +const AuthorizationHeader = "Authorization" // SessionUsernameHeader is used as additional protection to validate a user for things like pam_exec. const SessionUsernameHeader = "Session-Username" diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go index bc0d01010..d99af1c36 100644 --- a/internal/handlers/handler_verify.go +++ b/internal/handlers/handler_verify.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "encoding/base64" "fmt" "net" @@ -75,9 +76,9 @@ func getOriginalURL(ctx *middlewares.AutheliaCtx) (*url.URL, error) { // parseBasicAuth parses an HTTP Basic Authentication string. // "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). -func parseBasicAuth(auth string) (username, password string, err error) { +func parseBasicAuth(header, auth string) (username, password string, err error) { if !strings.HasPrefix(auth, authPrefix) { - return "", "", fmt.Errorf("%s prefix not found in %s header", strings.Trim(authPrefix, " "), AuthorizationHeader) + return "", "", fmt.Errorf("%s prefix not found in %s header", strings.Trim(authPrefix, " "), header) } c, err := base64.StdEncoding.DecodeString(auth[len(authPrefix):]) @@ -89,7 +90,7 @@ func parseBasicAuth(auth string) (username, password string, err error) { s := strings.IndexByte(cs, ':') if s < 0 { - return "", "", fmt.Errorf("Format of %s header must be user:password", AuthorizationHeader) + return "", "", fmt.Errorf("Format of %s header must be user:password", header) } return cs[:s], cs[s+1:], nil @@ -125,17 +126,17 @@ func isTargetURLAuthorized(authorizer *authorization.Authorizer, targetURL url.U // verifyBasicAuth verify that the provided username and password are correct and // that the user is authorized to target the resource. -func verifyBasicAuth(auth []byte, targetURL url.URL, ctx *middlewares.AutheliaCtx) (username, name string, groups, emails []string, authLevel authentication.Level, err error) { //nolint:unparam - username, password, err := parseBasicAuth(string(auth)) +func verifyBasicAuth(header string, auth []byte, targetURL url.URL, ctx *middlewares.AutheliaCtx) (username, name string, groups, emails []string, authLevel authentication.Level, err error) { //nolint:unparam + username, password, err := parseBasicAuth(header, string(auth)) if err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("Unable to parse content of %s header: %s", AuthorizationHeader, err) + return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("Unable to parse content of %s header: %s", header, err) } authenticated, err := ctx.Providers.UserProvider.CheckUserPassword(username, password) if err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("Unable to check credentials extracted from %s header: %s", AuthorizationHeader, err) + return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("Unable to check credentials extracted from %s header: %s", header, err) } // If the user is not correctly authenticated, send a 401. @@ -232,7 +233,15 @@ func verifySessionCookie(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userS return userSession.Username, userSession.DisplayName, userSession.Groups, userSession.Emails, userSession.AuthenticationLevel, nil } -func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, username string) { +func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, isBasicAuth bool, username string) { + if isBasicAuth { + ctx.Logger.Infof("Access to %s is not authorized to user %s, sending 401 response with basic auth header", targetURL.String(), username) + ctx.ReplyUnauthorized() + ctx.Response.Header.Add("WWW-Authenticate", "Basic realm=\"Authentication required\"") + + return + } + // 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-Forwarded-* headers or X-Original-URL. @@ -394,6 +403,47 @@ func getProfileRefreshSettings(cfg schema.AuthenticationBackendConfiguration) (r return refresh, refreshInterval } +func verifyAuth(ctx *middlewares.AutheliaCtx, targetURL *url.URL, refreshProfile bool, refreshProfileInterval time.Duration) (isBasicAuth bool, username, name string, groups, emails []string, authLevel authentication.Level, err error) { + authHeader := ProxyAuthorizationHeader + if bytes.Equal(ctx.QueryArgs().Peek("auth"), []byte("basic")) { + authHeader = AuthorizationHeader + isBasicAuth = true + } + + authValue := ctx.Request.Header.Peek(authHeader) + if authValue != nil { + isBasicAuth = true + } else if isBasicAuth { + err = fmt.Errorf("Basic auth requested via query arg, but no value provided via %s header", authHeader) + return + } + + if isBasicAuth { + username, name, groups, emails, authLevel, err = verifyBasicAuth(authHeader, authValue, *targetURL, ctx) + return + } + + userSession := ctx.GetSession() + username, name, groups, emails, authLevel, err = verifySessionCookie(ctx, targetURL, &userSession, refreshProfile, refreshProfileInterval) + + sessionUsername := ctx.Request.Header.Peek(SessionUsernameHeader) + if sessionUsername != nil && !strings.EqualFold(string(sessionUsername), username) { + ctx.Logger.Warnf("Possible cookie hijack or attempt to bypass security detected destroying the session and sending 401 response") + + err = ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx) + if err != nil { + ctx.Logger.Error( + fmt.Errorf( + "Unable to destroy user session after handler could not match them to their %s header: %s", + SessionUsernameHeader, err)) + } + + err = fmt.Errorf("Could not match user %s to their %s header with a value of %s when visiting %s", username, SessionUsernameHeader, sessionUsername, targetURL.String()) + } + + return +} + // VerifyGet returns the handler verifying if a request is allowed to go through. func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.RequestHandler { refreshProfile, refreshProfileInterval := getProfileRefreshSettings(cfg) @@ -423,40 +473,7 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques return } - var username, name string - - var groups, emails []string - - var authLevel authentication.Level - - proxyAuthorization := ctx.Request.Header.Peek(AuthorizationHeader) - isBasicAuth := proxyAuthorization != nil - userSession := ctx.GetSession() - - if isBasicAuth { - username, name, groups, emails, authLevel, err = verifyBasicAuth(proxyAuthorization, *targetURL, ctx) - } else { - username, name, groups, emails, authLevel, err = verifySessionCookie(ctx, targetURL, &userSession, - refreshProfile, refreshProfileInterval) - - sessionUsername := ctx.Request.Header.Peek(SessionUsernameHeader) - if sessionUsername != nil && !strings.EqualFold(string(sessionUsername), username) { - ctx.Logger.Warnf( - "Could not match user %s to their %s header with a value of %s when visiting %s, possible cookie hijack or attempt to bypass security detected destroying the session and sending 401 response", - username, SessionUsernameHeader, sessionUsername, targetURL.String()) - - err := ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx) - if err != nil { - ctx.Logger.Error( - fmt.Errorf( - "Unable to destroy user session after handler could not match them to their %s header: %s", - SessionUsernameHeader, err)) - } - - ctx.ReplyUnauthorized() - return - } - } + isBasicAuth, username, name, groups, emails, authLevel, err := verifyAuth(ctx, targetURL, refreshProfile, refreshProfileInterval) if err != nil { ctx.Logger.Error(fmt.Sprintf("Error caught when verifying user authorization: %s", err)) @@ -466,7 +483,7 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques return } - handleUnauthorized(ctx, targetURL, username) + handleUnauthorized(ctx, targetURL, isBasicAuth, username) return } @@ -479,7 +496,7 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username) ctx.ReplyForbidden() case NotAuthorized: - handleUnauthorized(ctx, targetURL, username) + handleUnauthorized(ctx, targetURL, isBasicAuth, username) case Authorized: setForwardedHeaders(&ctx.Response.Header, username, name, groups, emails) } diff --git a/internal/handlers/handler_verify_test.go b/internal/handlers/handler_verify_test.go index 872d2d764..eab127dec 100644 --- a/internal/handlers/handler_verify_test.go +++ b/internal/handlers/handler_verify_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "regexp" "testing" "time" @@ -120,27 +121,34 @@ func TestShouldRaiseWhenXForwardedURIIsNotParsable(t *testing.T) { // Test parseBasicAuth. func TestShouldRaiseWhenHeaderDoesNotContainBasicPrefix(t *testing.T) { - _, _, err := parseBasicAuth("alzefzlfzemjfej==") + _, _, err := parseBasicAuth(ProxyAuthorizationHeader, "alzefzlfzemjfej==") assert.Error(t, err) assert.Equal(t, "Basic prefix not found in Proxy-Authorization header", err.Error()) } func TestShouldRaiseWhenCredentialsAreNotInBase64(t *testing.T) { - _, _, err := parseBasicAuth("Basic alzefzlfzemjfej==") + _, _, err := parseBasicAuth(ProxyAuthorizationHeader, "Basic alzefzlfzemjfej==") assert.Error(t, err) assert.Equal(t, "illegal base64 data at input byte 16", err.Error()) } func TestShouldRaiseWhenCredentialsAreNotInCorrectForm(t *testing.T) { // The decoded format should be user:password. - _, _, err := parseBasicAuth("Basic am9obiBwYXNzd29yZA==") + _, _, err := parseBasicAuth(ProxyAuthorizationHeader, "Basic am9obiBwYXNzd29yZA==") assert.Error(t, err) assert.Equal(t, "Format of Proxy-Authorization header must be user:password", err.Error()) } +func TestShouldUseProvidedHeaderName(t *testing.T) { + // The decoded format should be user:password. + _, _, err := parseBasicAuth("HeaderName", "") + assert.Error(t, err) + assert.Equal(t, "Basic prefix not found in HeaderName header", err.Error()) +} + func TestShouldReturnUsernameAndPassword(t *testing.T) { // the decoded format should be user:password. - user, password, err := parseBasicAuth("Basic am9objpwYXNzd29yZA==") + user, password, err := parseBasicAuth(ProxyAuthorizationHeader, "Basic am9objpwYXNzd29yZA==") assert.NoError(t, err) assert.Equal(t, "john", user) assert.Equal(t, "password", password) @@ -204,7 +212,7 @@ func TestShouldVerifyWrongCredentials(t *testing.T) { Return(false, nil) url, _ := url.ParseRequestURI("https://test.example.com") - _, _, _, _, _, err := verifyBasicAuth([]byte("Basic am9objpwYXNzd29yZA=="), *url, mock.Ctx) + _, _, _, _, _, err := verifyBasicAuth(ProxyAuthorizationHeader, []byte("Basic am9objpwYXNzd29yZA=="), *url, mock.Ctx) assert.Error(t, err) } @@ -354,6 +362,97 @@ func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfDenyDomain() { assert.Equal(s.T(), 403, mock.Ctx.Response.StatusCode()) } +func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgOk() { + mock := mocks.NewMockAutheliaCtx(s.T()) + defer mock.Close() + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + VerifyGet(verifyGetCfg)(mock.Ctx) + + assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) +} + +func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingNoHeader() { + mock := mocks.NewMockAutheliaCtx(s.T()) + defer mock.Close() + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + VerifyGet(verifyGetCfg)(mock.Ctx) + + assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) + assert.Equal(s.T(), "Unauthorized", string(mock.Ctx.Response.Body())) + assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) + assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) +} + +func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingEmptyHeader() { + mock := mocks.NewMockAutheliaCtx(s.T()) + defer mock.Close() + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("Authorization", "") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + VerifyGet(verifyGetCfg)(mock.Ctx) + + assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) + assert.Equal(s.T(), "Unauthorized", string(mock.Ctx.Response.Body())) + assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) + assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) +} + +func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingWrongPassword() { + mock := mocks.NewMockAutheliaCtx(s.T()) + defer mock.Close() + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, nil) + + VerifyGet(verifyGetCfg)(mock.Ctx) + + assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) + assert.Equal(s.T(), "Unauthorized", string(mock.Ctx.Response.Body())) + assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) + assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) +} + +func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingWrongHeader() { + mock := mocks.NewMockAutheliaCtx(s.T()) + defer mock.Close() + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + VerifyGet(verifyGetCfg)(mock.Ctx) + + assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) + assert.Equal(s.T(), "Unauthorized", string(mock.Ctx.Response.Body())) + assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) + assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) +} + func TestShouldVerifyAuthorizationsUsingBasicAuth(t *testing.T) { suite.Run(t, NewBasicAuthorizationSuite()) }