Repository: Mailu/Mailu

Issue:
Mailu should redirect to proxy authentication endpoints for users of proxy header authentication
<!--

Thank you for opening an issue with Mailu. Please understand that issues are meant for bugs and enhancement-requests.
For **user-support questions**, reach out to us  on [matrix](https://matrix.to/#/#mailu:tedomum.net).

To be able to help you best, we need some more information.

Before you open your issue
- Check if no issue or pull-request for this already exists.
- Check [documentation](https://mailu.io/master/) and [FAQ](https://mailu.io/master/faq.html). (Tip, use the search function on the documentation page)
- You understand `Mailu` is made by volunteers in their **free time** — be concise, civil and accept that delays can occur.
- The title of the issue should be short and simple. It should contain specific terms related to the actual issue. Be specific while writing the title.

Please put your text outside of the comment blocks to be visible. You can use the button "Preview" above to check.

-->

## Environment & Version

### Environment

- [x] docker-compose
- [ ] kubernetes
- [ ] docker swarm

### Version

- Version: `master`

<!--
To find your version, get the image name of a mailu container and read  the version from the tag (example for version 1.7).

$> docker ps -a | grep mailu
140b09d4b09c    mailu/roundcube:1.7    "docker-php-entrypoi…"    2 weeks ago    Up 2 days (healthy)    80/tcp
$> grep MAILU_VERSION docker-compose.yml mailu.env
-->

## Description
<!--
Further explain the bug in a few words. It should be clear what the unexpected behaviour is.  Share it in an easy-to-understand language.
-->
This is related to #1972.
I've been testing proxy authentication with a new deployment of mailu. It works as expected, however there is one issue. The default login redirect for `WEB_WEBMAIL` path `/webmail`, and `WEB_ADMIN` path `/admin` will use `/sso/login`, where the user is prompted for credentials after completing SSO workflow. When using a proxy for auth, mailu should redirect logins to `WEB_WEBMAIL` to `/sso/proxy` and `WEB_ADMIN` logins to `/sso/proxy/admin`. We should have a setting in the configuration (disabled by default) to enable redirect to the proxy authentication endpoints. This would keep mailu from prompting users for their credentials where we expect all users to authenticate through the proxy. If proxy authentication breaks or there are users that do not authenticate through the proxy, users can still login by navigating to `/sso/login`.

As a workaround, I have configured requests to the base URL `/` to redirect to `/sso/proxy` using my proxy, but it's not an ideal solution. There is no way to redirect requests to `/webmail` and `/admin` to `/sso/proxy` because the proxy cannot tell if the request has been authenticated against mailu. Configuring redirects on those endpoints will only cause a redirect loop.

## Replication Steps
<!--
Steps for replicating your issue
-->
Using docker-compose and mailu master tag. Tested with Google Chrome version 110 on Windows.
1. Deploy mailu with a reverse proxy to perform authentication. I am using traefik2 for proxy and authentik for IdP.
2. Configure a user in IdP that will authenticate to mailu. Optionally enable PROXY_AUTH_CREATE in mailu config.
3. Confirm authentication through proxy works by navigating to `/sso/proxy`. A successful login will load Webmail.
4. Logout of any sessions via mailu, or clear cookies set by mailu.
5. Navigate to base URL `/`, webmail endpoint `/webmail`, and admin endpoint `/admin`.

## Observed behaviour
<!--
Explain or paste the result you received.
-->
The base URL `/` redirects to `/webmail`. This is expected. However, proxy authenticated requests to `/webmail` and `/admin` are redirected to `/sso/login`. Note the requests are correctly authenticated through the proxy, but not yet authenticated with mailu as it hasn't set the roundcube/rainloop/admin session cookies yet. When the browser is redirected here, mailu prompts the user for credentials which we don't want for users already authenticated through the proxy.

## Expected behaviour
<!--
Explain what results you expected - be as specific as possible.
Just saying "it doesn’t work as expected" is not useful. It's also helpful to describe what you actually experienced.
-->
In environments where a proxy is configured for header authentication to mailu, requests to `WEB_WEBMAIL` (`/webmail`) and `WEB_ADMIN` (`/admin`) should redirect to `/sso/proxy` and `/sso/proxy/admin` respectively when the session cookies are unset/invalid/expired. 

## Logs
<!--
Often it is very useful to include log fragments of the involved component.
You can get the logs via `docker logs <container name> --tail 1000`.
For example for the admin container: `docker logs mailu_admin_1 --tail 1000`
or using docker-compose `docker-compose -f /mailu/docker-compose.yml logs --tail 1000 admin`

If you can find the relevant section, please share only the parts that seem relevant. If you have any logs, please enclose them in code tags, like so:

```
Your logs here!
```
-->
I don't think logs are necessary for this, but let me know if I can help by providing any.


Discussion:
my workaround is to redirect all incoming request from `/sso/login` to `/sso/proxy` this works very good on my end. Also I redirected `/sso/logout` to the logout route of my proxy (e.g. for authentik: `/outpost.goauthentik.io/sign_out`). But I admit, it would be better if Mailu automatically redirects the `/sso/login` route to `/sso/proxy` in this setting
Yeah I know redirecting `/sso/login` is an option, but you don't want to do that in production environments where you would want to keep mailu's login page as a backup if proxy authentication breaks and users need access to their webmail, or if there are a few users who do not authenticate through the proxy, such as the admin user created during mailu setup.

Thank you for bringing up `/sso/logout` though, I didn't consider this and think that similarly it should be a setting in the mailu configuration. The way I see it, users would still get sent to `/sso/logout` to invalidate the mailu session cookies, then get redirected to the URL specified in configuration (if set) to complete the logout with the proxy auth.
This is not specific to proxy-auth: we currently don't "guess" nor auto-detect where we should redirect... we could attempt to parse the referer to get back to the right place.

Resolving patch:
diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py
--- a/core/admin/mailu/configuration.py
+++ b/core/admin/mailu/configuration.py
@@ -86,6 +86,7 @@
     'PROXY_AUTH_WHITELIST': '',
     'PROXY_AUTH_HEADER': 'X-Auth-Email',
     'PROXY_AUTH_CREATE': False,
+    'PROXY_AUTH_LOGOUT_URL': None,
     'SUBNET': '192.168.203.0/24',
     'SUBNET6': None,
 }
diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py
--- a/core/admin/mailu/sso/views/base.py
+++ b/core/admin/mailu/sso/views/base.py
@@ -8,26 +8,38 @@
 import flask_login
 import secrets
 import ipaddress
+from urllib.parse import urlparse, urljoin
+from werkzeug.urls import url_unquote
 
 @sso.route('/login', methods=['GET', 'POST'])
 def login():
+    if flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) and not 'noproxyauth' in flask.request.url:
+        return _proxy()
+
     client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
     form = forms.LoginForm()
-    form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
-    form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail'
 
     fields = []
-    if str(app.config["WEBMAIL"]).upper() != "NONE":
-        fields.append(form.submitWebmail)
-    if str(app.config["ADMIN"]).upper() != "FALSE":
+
+    if 'url' in flask.request.args and not 'homepage' in flask.request.url:
         fields.append(form.submitAdmin)
+    else:
+        form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
+        form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail'
+        if str(app.config["WEBMAIL"]).upper() != "NONE":
+            fields.append(form.submitWebmail)
+        if str(app.config["ADMIN"]).upper() != "FALSE":
+            fields.append(form.submitAdmin)
     fields = [fields]
 
     if form.validate_on_submit():
-        if form.submitAdmin.data:
-            destination = app.config['WEB_ADMIN']
-        elif form.submitWebmail.data:
-            destination = app.config['WEB_WEBMAIL']
+        if destination := _has_usable_redirect():
+            pass
+        else:
+            if form.submitAdmin.data:
+                destination = app.config['WEB_ADMIN']
+            elif form.submitWebmail.data:
+                destination = app.config['WEB_WEBMAIL']
         device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit'))
         username = form.email.data
         if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip):
@@ -57,14 +69,29 @@ def login():
 def logout():
     flask_login.logout_user()
     flask.session.destroy()
-    response = flask.redirect(flask.url_for('.login'))
+    response = flask.redirect(app.config['PROXY_AUTH_LOGOUT_URL'] or flask.url_for('.login'))
     for cookie in ['roundcube_sessauth', 'roundcube_sessid', 'smsession']:
         response.set_cookie(cookie, 'empty', expires=0)
     return response
 
-@sso.route('/proxy', methods=['GET'])
-@sso.route('/proxy/<target>', methods=['GET'])
-def proxy(target='webmail'):
+"""
+Redirect to the url passed in parameter if any; Ensure that this is not an open-redirect too...
+https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
+"""
+def _has_usable_redirect():
+    if 'homepage' in flask.request.url:
+        return None
+    if url := flask.request.args.get('url'):
+        url = url_unquote(url)
+        target = urlparse(urljoin(flask.request.url, url))
+        if target.netloc == urlparse(flask.request.url).netloc:
+            return target.geturl()
+    return None
+
+"""
+https://mailu.io/master/configuration.html#header-authentication-using-an-external-proxy
+"""
+def _proxy():
     ip = ipaddress.ip_address(flask.request.remote_addr)
     if not any(ip in cidr for cidr in app.config['PROXY_AUTH_WHITELIST']):
         return flask.abort(500, '%s is not on PROXY_AUTH_WHITELIST' % flask.request.remote_addr)
@@ -73,11 +100,13 @@ def proxy(target='webmail'):
     if not email:
         return flask.abort(500, 'No %s header' % app.config['PROXY_AUTH_HEADER'])
 
+    url = _has_usable_redirect() or app.config['WEB_ADMIN']
+
     user = models.User.get(email)
     if user:
         flask.session.regenerate()
         flask_login.login_user(user)
-        return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL'])
+        return flask.redirect(url)
 
     if not app.config['PROXY_AUTH_CREATE']:
         return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email)
@@ -100,4 +129,4 @@ def proxy(target='webmail'):
     flask_login.login_user(user)
     user.send_welcome()
     flask.current_app.logger.info(f'Login succeeded by proxy created user: {user} from {client_ip} through {flask.request.remote_addr}.')
-    return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL'])
+    return flask.redirect(url)
diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py
--- a/core/admin/mailu/utils.py
+++ b/core/admin/mailu/utils.py
@@ -42,7 +42,7 @@
 def handle_needs_login():
     """ redirect unauthorized requests to login page """
     return flask.redirect(
-        flask.url_for('sso.login')
+        flask.url_for('sso.login', url=flask.request.url)
     )
 
 # DNS stub configured to do DNSSEC enabled queries


Test patch: