Skip to content
This repository has been archived by the owner on Oct 5, 2023. It is now read-only.

Commit

Permalink
Merge pull request #24 from jkroepke/webauth
Browse files Browse the repository at this point in the history
  • Loading branch information
jkroepke committed Feb 21, 2023
2 parents d79d22f + 3876df8 commit 7d0a928
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 63 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.3]
## [2.1.0]

### Added
- Support for WebAuth SSO (not supported by Tunnelblick)

### Changed

Expand Down Expand Up @@ -96,7 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- First release

[Unreleased]: https://github.com/jkroepke/openvpn-auth-azure-ad/compare/v2.0.3...HEAD
[Unreleased]: https://github.com/jkroepke/openvpn-auth-azure-ad/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/jkroepke/openvpn-auth-azure-ad/releases/tag/v2.1.0
[2.0.3]: https://github.com/jkroepke/openvpn-auth-azure-ad/releases/tag/v2.0.3
[2.0.1]: https://github.com/jkroepke/openvpn-auth-azure-ad/releases/tag/v2.0.1
[2.0.0]: https://github.com/jkroepke/openvpn-auth-azure-ad/releases/tag/v2.0.0
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.10
FROM python:3.11

WORKDIR /opt/

Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ specified via -c). Config file syntax allows: key=value, flag=true, stuff=[a,b,c
specified in more than one place, then commandline values override environment variables which override config file values which override defaults.

```
usage: openvpn-auth-azure-ad.py [-h] [-c CONFIG] [-V] [-t THREADS] [-a AUTHENTICATORS] [--auth-token] [--auth-token-lifetime AUTH_TOKEN_LIFETIME] [--remember-user] [--verify-openvpn-client]
[--verify-openvpn-client-id-token-claim] [-H OPENVPN_HOST] [-P OPENVPN_PORT] [-s OPENVPN_SOCKET] [-p OPENVPN_PASSWORD] [--openvpn-release-hold] --client-id CLIENT_ID
[--token-authority TOKEN_AUTHORITY] [--graph-endpoint GRAPH_ENDPOINT] [--prometheus] [--prometheus-listen-addr PROMETHEUS_LISTEN_ADDR]
[--prometheus-listen-port PROMETHEUS_LISTEN_PORT] [--log-level LOG_LEVEL]
usage: openvpn-auth-azure-ad.py [-h] [-c CONFIG] [-V] [-t THREADS] [-a AUTHENTICATORS] [--auth-token] [--auth-token-lifetime AUTH_TOKEN_LIFETIME] [--remember-user] [--webauth] [--webauth-url WEBAUTH_URL]
[--openvpn-identity-key {common_name,username}] [--verify-openvpn-client] [--verify-openvpn-client-id-token-claim] [-H OPENVPN_HOST] [-P OPENVPN_PORT] [-s OPENVPN_SOCKET]
[-p OPENVPN_PASSWORD] [--openvpn-release-hold] --client-id CLIENT_ID [--token-authority TOKEN_AUTHORITY] [--graph-endpoint GRAPH_ENDPOINT] [--prometheus]
[--prometheus-listen-addr PROMETHEUS_LISTEN_ADDR] [--prometheus-listen-port PROMETHEUS_LISTEN_PORT] [--log-level LOG_LEVEL]
optional arguments:
options:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path of config file [env var: AAD_CONFIG_PATH]
Expand All @@ -98,6 +98,11 @@ OpenVPN User Authentication:
--auth-token-lifetime AUTH_TOKEN_LIFETIME
Lifetime of auth tokens in seconds [env var: AAD_AUTH_TOKEN_LIFETIME]
--remember-user If user authenticated once, the users refresh token is used to reauthenticate silently if possible. [env var: AAD_REMEMBER_USER]
--webauth Support OpenVPN WebAuth capabilities, if client supports. [env var: AAD_REMEMBER_USER]
--webauth-url WEBAUTH_URL
Wrapper Page for WebAuth capabilities. Copy docs/ folder to host a dedicated one. [env var: AAD_REMEMBER_USER]
--openvpn-identity-key {common_name,username}
Define which value from OpenVPN should be used for identity the AAD user. Supported values: 'common_name', 'username' [env var: AAD_OPENVPN_IDENTITY_KEY]
--verify-openvpn-client
Check if openvpn client common_name matches Azure AD token claim [env var: AAD_VERIFY_OPENVPN_CLIENT]
--verify-openvpn-client-id-token-claim
Expand All @@ -119,8 +124,8 @@ Azure AD settings:
--client-id CLIENT_ID
Client ID of application. [env var: AAD_CLIENT_ID]
--token-authority TOKEN_AUTHORITY
A URL that identifies a token authority. It should be of the format https://login.microsoftonline.com/your_tenant. By default, we will use https://login.microsoftonline.com/organizations
[env var: AAD_TOKEN_AUTHORITY]
A URL that identifies a token authority. It should be of the format https://login.microsoftonline.com/your_tenant. By default, we will use
https://login.microsoftonline.com/organizations [env var: AAD_TOKEN_AUTHORITY]
--graph-endpoint GRAPH_ENDPOINT
Endpoint of the graph API. See: https://developer.microsoft.com/en-us/graph/graph-explorer [env var: AAD_GRAPH_ENDPOINT]
Expand All @@ -132,6 +137,10 @@ Prometheus settings:
prometheus statistics [env var: AAD_PROMETHEUS_PORT]
--log-level LOG_LEVEL
Configure the logging level. [env var: AAD_LOG_LEVEL]
Args that start with '--' (eg. -V) can also be set in a config file (/etc/openvpn-auth-azure-ad/config.conf or ~/.openvpn-auth-azure-ad or specified via -c). Config file syntax allows: key=value, flag=true,
stuff=[a,b,c] (for details, see syntax at https://goo.gl/R74nmi). If an arg is specified in more than one place, then commandline values override environment variables which override config file values which
override defaults.
```

## Register an app with AAD
Expand Down
16 changes: 15 additions & 1 deletion openvpn_auth_azure_ad/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ def main():
help="If user authenticated once, the users refresh token is used to reauthenticate silently if possible.",
env_var="AAD_REMEMBER_USER",
)
parser_authentication.add_argument(
"--webauth",
action="store_true",
help="Support OpenVPN WebAuth capabilities, if client supports.",
env_var="AAD_REMEMBER_USER",
)
parser_authentication.add_argument(
"--webauth-url",
default="https://jkroepke.github.io/openvpn-auth-azure-ad/",
help="Wrapper Page for WebAuth capabilities. Copy docs/ folder to host a dedicated one.",
env_var="AAD_REMEMBER_USER",
)
parser_authentication.add_argument(
"--openvpn-identity-key",
default="common_name",
Expand Down Expand Up @@ -207,11 +219,13 @@ def main():
options.auth_token_lifetime,
options.remember_user,
options.threads,
options.openvpn_release_hold,
options.webauth,
options.webauth_url,
options.openvpn_host,
options.openvpn_port,
options.openvpn_socket,
options.openvpn_password,
options.openvpn_release_hold,
)

aad_authenticator.run()
136 changes: 85 additions & 51 deletions openvpn_auth_azure_ad/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ def __init__(
auth_token_lifetime: int,
remember_user: bool,
threads: int,
release_hold: bool,
webauth: bool,
webauth_url: str,
host: str = None,
port: int = None,
socket: str = None,
password: str = None,
release_hold: bool = None,
):
self._app = app
self._graph_endpoint = graph_endpoint
Expand All @@ -80,6 +82,8 @@ def __init__(
self._auth_token_enabled = auth_token
self._auth_token_lifetime = auth_token_lifetime
self._remember_user_enabled = remember_user
self._webauth_enabled = webauth
self._webauth_url = webauth_url
self._thread_pool = ThreadPoolExecutorStackTraced(max_workers=threads)

def run(self) -> None:
Expand Down Expand Up @@ -125,7 +129,7 @@ def send_authentication_success(self, client: dict) -> None:
"client-auth-nt %s %s" % (client["cid"], client["kid"])
)

def send_authentication_challenge(self, client: Dict, client_message: str) -> None:
def send_authentication_challenge(self, client: ClientDataType, client_message: str) -> None:
self.log_debug(client, "authentication challenge: %s" % client_message)

client_challenge = util.format_client_challenge(client, client_message)
Expand All @@ -134,8 +138,25 @@ def send_authentication_challenge(self, client: Dict, client_message: str) -> No
% (client["cid"], client["kid"], "client_challenge", client_challenge)
)

def send_webauth(self, client: ClientDataType, url: str, timeout: int) -> None:
self.log_debug(client, "authentication challenge: %s" % url)

extra_prefix = "OPEN_URL:" if "openurl" in client["env"]["IV_SSO"] else "WEB_AUTH::"
self._openvpn.send_command(
'client-pending-auth %s %s%s %s'
% (client["cid"], extra_prefix, url, timeout)
)

def send_crtext(self, client: ClientDataType, message: str, timeout: int) -> None:
self.log_debug(client, "authentication challenge: %s" % message)

self._openvpn.send_command(
'client-pending-auth %s CR_TEXT:R,E:%s %s'
% (client["cid"], message, timeout)
)

def send_authentication_error(
self, client: dict, message: str, client_message: Optional[str]
self, client: ClientDataType, message: str, client_message: Optional[str]
) -> None:
if client_message is None:
self._openvpn.send_command(
Expand Down Expand Up @@ -274,9 +295,7 @@ def handle_response_challenge(self, client: ClientDataType) -> Optional[dict]:
return None

self.log_info(client, "Continue to authenticate using device token flow")
result = self.device_token_finish(state)

return result
return self.device_token_finish(state["flow"])

def client_connect(self, data: str) -> None:
client = AADAuthenticator.parse_client_data(data)
Expand Down Expand Up @@ -319,42 +338,7 @@ def authenticate_client(self, client: ClientDataType) -> None:
result = self.handle_response_challenge(client)
if result is not None:
client["state_id"] = util.get_state_id(client)
if util.is_authenticated(result):
if not self.verify_openvpn_client(client, result):
self.send_authentication_error(
client, "client_certificate_not_matched", None
)
return None

self.setup_auth_token(client)
self._states["authenticated"].set(
client["state_id"], {"client": client, "result": result}
)
openvpn_auth_azure_ad_auth_succeeded.labels(
AADAuthenticatorFlows.DEVICE_TOKEN
).inc()
self.log_info(client, "device token flow succeeded")
self.send_authentication_success(client)
return
else:
openvpn_auth_azure_ad_auth_failures.labels(
AADAuthenticatorFlows.DEVICE_TOKEN
).inc()

if 70016 in result.get("error_codes", []):
error = "device token flow errored: no user action"
self.log_info(client, error)
self.send_authentication_error(client, error, None)
else:
self.log_info(
client,
"device token flow errored: %s "
% util.format_error(result),
)
self.send_authentication_error(
client, util.format_error(result), None
)
return
return self.handle_device_token_result(client, result)

client["state_id"] = util.generated_id()
if self._remember_user_enabled:
Expand Down Expand Up @@ -422,14 +406,26 @@ def authenticate_client(self, client: ClientDataType) -> None:
if AADAuthenticatorFlows.DEVICE_TOKEN in self._authenticators:
self.log_info(client, "Start to authenticate using device token flow")
flow = self.device_auth_start()
message = flow["message"] + " Then press OK here."

self._states["challenge"].set(client["state_id"], {"flow": flow})

openvpn_auth_azure_ad_auth_total.labels(
AADAuthenticatorFlows.DEVICE_TOKEN
).inc()
self.send_authentication_challenge(client, message)

if self._webauth_enabled and "IV_SSO" in client["env"] and ("openurl" in client["env"]["IV_SSO"] or "webauth" in client["env"]["IV_SSO"]):
self.send_webauth(client, "%s?code=%s" % (self._webauth_url, flow["user_code"]), 600)
self.handle_device_token_result(client, self.device_token_finish(flow))
return

self._states["challenge"].set(client["state_id"], {"flow": flow})
message = flow["message"] + " Then press OK here."

if "IV_SSO" in client["env"] and "crtext" in client["env"]["IV_SSO"]:
self.log_info(client, "crtext")
self.send_crtext(client, message, 600)
else:
self.log_info(client, "old")
self.log_info(client, client["env"]["IV_SSO"])
self.send_authentication_challenge(client, message)
return

self.setup_auth_token(client)
Expand All @@ -439,6 +435,44 @@ def authenticate_client(self, client: ClientDataType) -> None:
self.send_authentication_success(client)
return

def handle_device_token_result(self, client: ClientDataType, result: dict):
if util.is_authenticated(result):
if not self.verify_openvpn_client(client, result):
self.send_authentication_error(
client, "client_certificate_not_matched", None
)
return None

self.setup_auth_token(client)
self._states["authenticated"].set(
client["state_id"], {"client": client, "result": result}
)
openvpn_auth_azure_ad_auth_succeeded.labels(
AADAuthenticatorFlows.DEVICE_TOKEN
).inc()
self.log_info(client, "device token flow succeeded")
self.send_authentication_success(client)
return
else:
openvpn_auth_azure_ad_auth_failures.labels(
AADAuthenticatorFlows.DEVICE_TOKEN
).inc()

if 70016 in result.get("error_codes", []):
error = "device token flow errored: no user action"
self.log_info(client, error)
self.send_authentication_error(client, error, None)
else:
self.log_info(
client,
"device token flow errored: %s "
% util.format_error(result),
)
self.send_authentication_error(
client, util.format_error(result), None
)
return

def device_auth_start(self) -> dict:
flow = self._app.initiate_device_flow(scopes=self.token_scopes)

Expand All @@ -449,12 +483,12 @@ def device_auth_start(self) -> dict:

return flow

def device_token_finish(self, state: dict) -> dict:
def device_token_finish(self, flow: dict) -> dict:
# Block authentication at least for 120 seconds
state["flow"]["expires_in"] = 60
state["flow"]["expires_at"] = time.time() + state["flow"]["expires_in"]
flow["expires_in"] = 60
flow["expires_at"] = time.time() + flow["expires_in"]

return self._app.acquire_token_by_device_flow(state["flow"])
return self._app.acquire_token_by_device_flow(flow)

@staticmethod
def parse_client_data(data: str) -> ClientDataType:
Expand Down Expand Up @@ -482,7 +516,7 @@ def parse_client_data(data: str) -> ClientDataType:
client["reason"] = client_info[0].replace(">CLIENT:", "").lower()
client["cid"] = int(client_info[1])
elif line.startswith(">CLIENT:ENV,"):
client_env = line.split(",")[1].split("=")
client_env = line.split(",", 1)[1].split("=")
client["env"][client_env[0]] = (
client_env[1] if len(client_env) == 2 else ""
)
Expand Down
2 changes: 1 addition & 1 deletion tests/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
cap_add:
- NET_ADMIN
ports:
- "1194:1194/udp"
- "0.0.0.0:1194:1194/udp"
- "8080:8080/tcp"
- "8081:8081/tcp"
volumes:
Expand Down

0 comments on commit 7d0a928

Please sign in to comment.