Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added keycloak authentication #3486

Merged
merged 9 commits into from
Apr 21, 2022
Merged
3 changes: 2 additions & 1 deletion docs/Usage/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Here is the full list:
|[kerberos-password](../../xpra/server/auth/kerberos_password_auth.py)|Uses kerberos to authenticate a username + password|[#1691](../https://github.com/Xpra-org/xpra/issues/1691)|
|[kerberos-ticket](../../xpra/server/auth/kerberos_ticket_auth.py)|Uses a kerberos ticket to authenticate a client|[#1691](../https://github.com/Xpra-org/xpra/issues/1691)|
|[gss_auth](../../xpra/trunk/src/xpra/server/auth/gss_auth.py)|Uses a GSS ticket to authenticate a client|[#1691](../https://github.com/Xpra-org/xpra/issues/1691)|
|[keycloak](../../xpra/server/auth/keycloak_auth.py)|Uses a keycloak token to authenticate a client|[#3334](../https://github.com/Xpra-org/xpra/issues/3334)|
|[ldap](../../xpra/server/auth/ldap_auth.py)|Uses ldap via [python-ldap](https://www.python-ldap.org/en/latest/)|[#1791](../https://github.com/Xpra-org/xpra/issues/1791)|
|[ldap3](../../xpra/server/auth/ldap3_auth.py)|Uses ldap via [python-ldap3](https://github.com/cannatag/ldap3)|[#1791](../https://github.com/Xpra-org/xpra/issues/1791)|
|[u2f](../../xpra/trunk/src/xpra/server/auth/u2f_auth.py)|[Universal 2nd Factor](https://en.wikipedia.org/wiki/Universal_2nd_Factor)|[#1789](../https://github.com/Xpra-org/xpra/issues/1789)|
Expand Down Expand Up @@ -134,7 +135,7 @@ The steps below assume that the client and server have been configured to use au
* if the server is not configured for authentication, the client connection should be accepted and a warning will be printed
* if the client is not configured for authentication, a password dialog may show up, and the connection will fail with an authentication error if the correct value is not supplied
* if multiple authentication modules are specified, the client may bring up multiple authentication dialogs
* how the client handles the challenges sent by the server can be configured using the `challenge-handlers` option, by default the client will try the following handlers in the specified order: `uri` (whatever password may have been specified in the connection string), `file` (if the `password-file` option was used), `env` (if the environment variable is present), `kerberos`, `gss`, `u2f` and finally `prompt`
* how the client handles the challenges sent by the server can be configured using the `challenge-handlers` option, by default the client will try the following handlers in the specified order: `uri` (whatever password may have been specified in the connection string), `file` (if the `password-file` option was used), `env` (if the environment variable is present), `kerberos`, `gss`, `keycloak`, `u2f` and finally `prompt`
</details>
<details>
<summary>module and platform specific notes</summary>
Expand Down
23 changes: 23 additions & 0 deletions xpra/client/auth/keycloak_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This file is part of Xpra.
# Copyright (C) 2019 Antoine Martin <antoine@xpra.org>
# Copyright (C) 2022 Nathalie Casati <nat@yuka.ch>
# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
# later version. See the file COPYING for details.


class Handler:

def __init__(self, client, **_kwargs):
self.client = client

def __repr__(self):
return "keycloak"

def get_digest(self) -> str:
return "keycloak"

def handle(self, packet) -> bool:
if not self.client.password:
return False
self.client.send_challenge_reply(packet, self.client.password)
return True
2 changes: 1 addition & 1 deletion xpra/client/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def init_challenge_handlers(self, challenge_handlers):
items = (
"uri", "file", "env",
"kerberos", "gss",
"u2f",
"keycloak", "u2f",
iDmple marked this conversation as resolved.
Show resolved Hide resolved
"prompt", "prompt", "prompt", "prompt",
)
ierror = authlog
Expand Down
2 changes: 1 addition & 1 deletion xpra/net/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def gendigest(digest, password, salt):
salt = salt.ljust(16, b"\x00")[:16]
v = generate_response(password, salt)
return hexstr(v)
if digest in ("xor", "kerberos", "gss"):
if digest in ("xor", "kerberos", "gss", "keycloak"):
#kerberos and gss use xor because we need to use the actual token
iDmple marked this conversation as resolved.
Show resolved Hide resolved
#at the other end
salt = salt.ljust(len(password), b"\x00")[:len(password)]
Expand Down
113 changes: 113 additions & 0 deletions xpra/server/auth/keycloak_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# This file is part of Xpra.
# Copyright (C) 2016-2021 Antoine Martin <antoine@xpra.org>
# Copyright (C) 2022 Nathalie Casati <nat@yuka.ch>
# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
# later version. See the file COPYING for details.

import os
import json

from xpra.util import typedict
from xpra.server.auth.sys_auth_base import SysAuthenticator, log
from keycloak import KeycloakOpenID
from oauthlib.oauth2 import WebApplicationClient
iDmple marked this conversation as resolved.
Show resolved Hide resolved

KEYCLOAK_SERVER_URL = os.environ.get("XPRA_KEYCLOAK_SERVER_URL", "http://localhost:8080/auth/")
KEYCLOAK_REALM_NAME = os.environ.get("XPRA_KEYCLOAK_REALM_NAME", "example_realm")
KEYCLOAK_CLIENT_ID = os.environ.get("XPRA_KEYCLOAK_CLIENT_ID", "example_client")
KEYCLOAK_CLIENT_SECRET_KEY = os.environ.get("XPRA_KEYCLOAK_CLIENT_SECRET_KEY", "secret")
KEYCLOAK_REDIRECT_URI = os.environ.get("XPRA_KEYCLOAK_REDIRECT_URI", "http://localhost/login/")
KEYCLOAK_SCOPE = os.environ.get("XPRA_KEYCLOAK_SCOPE", "openid")
KEYCLOAK_GRANT_TYPE = os.environ.get("XPRA_KEYCLOAK_GRANT_TYPE", "authorization_code")


class Authenticator(SysAuthenticator):

def __init__(self, **kwargs):
self.server_url = kwargs.pop("server_url", KEYCLOAK_SERVER_URL)
self.realm_name = kwargs.pop("realm_name", KEYCLOAK_REALM_NAME)
self.client_id = kwargs.pop("client_id", KEYCLOAK_CLIENT_ID)
self.client_secret_key = kwargs.pop("client_secret_key", KEYCLOAK_CLIENT_SECRET_KEY)
self.redirect_uri = kwargs.pop("redirect_uri", KEYCLOAK_REDIRECT_URI)
self.scope = kwargs.pop("scope", KEYCLOAK_SCOPE)
self.grant_type = kwargs.pop("grant_type", KEYCLOAK_GRANT_TYPE)
kwargs["prompt"] = kwargs.pop("prompt", "keycloak")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wouldn't hurt to add a comment here, like "use keycloak as default prompt".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: keycloak token as default prompt would be clearer IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we add more grant_types we could have

  • keycloak password
  • keycloak authorization code
  • others

But never token. We could also implement them in different files if too different / complex.

We can add what you prefer here but in this case it will never be shown, or I don't see how. And it will never ask for a token.


if KEYCLOAK_GRANT_TYPE == "authorization_code":
super().__init__(**kwargs)
log("keycloak auth: server_url=%s, client_id=%s, realm_name=%s, redirect_uri=%s, scope=%s, grant_type=%s",
self.server_url, self.client_id, self.realm_name, self.redirect_uri, self.scope, self.grant_type)

# Get authorization code
client = WebApplicationClient(KEYCLOAK_CLIENT_ID)
authorization_url = KEYCLOAK_SERVER_URL + 'realms/' + KEYCLOAK_REALM_NAME + '/protocol/openid-connect/auth'
self.salt = client.prepare_request_uri(
authorization_url,
redirect_uri = KEYCLOAK_REDIRECT_URI,
scope = [KEYCLOAK_SCOPE],
)
else:
raise(NotImplementedError("Warning: only grant type \"authorization_code\" is currently supported."))

def __repr__(self):
return "keycloak"

def get_challenge(self, digests):
assert not self.challenge_sent
if "keycloak" not in digests:
log.error("Error: client does not support keycloak authentication")
return None
self.challenge_sent = True
return self.salt, "keycloak"
totaam marked this conversation as resolved.
Show resolved Hide resolved

def check(self, response_json) -> bool:
assert self.challenge_sent

if response_json is None or response_json == "":
iDmple marked this conversation as resolved.
Show resolved Hide resolved
log.error("keycloak authentication failed: invalid response received from authorization endpoint.")
return False

#log("response_json: %s", repr(response_json))
response = json.loads(response_json)

iDmple marked this conversation as resolved.
Show resolved Hide resolved
if type(response) != dict or ("code" not in response and "error" not in response):
iDmple marked this conversation as resolved.
Show resolved Hide resolved
log.error("keycloak authentication failed: invalid response received from authorization endpoint.")
iDmple marked this conversation as resolved.
Show resolved Hide resolved
return False

log("check(%s)", repr(response))

if "error" in response:
log.error("keycloak authentication failed with error %s: %s", response["error"], response["error_description"])
iDmple marked this conversation as resolved.
Show resolved Hide resolved
return False
iDmple marked this conversation as resolved.
Show resolved Hide resolved

if "code" in response:
# Configure client
keycloak_openid = KeycloakOpenID(server_url=KEYCLOAK_SERVER_URL,
client_id=KEYCLOAK_CLIENT_ID,
realm_name=KEYCLOAK_REALM_NAME,
client_secret_key=KEYCLOAK_CLIENT_SECRET_KEY)

try:
# Get well_known
#config_well_know = keycloak_openid.well_know()
#log("well_known: %s", repr(config_well_know))
iDmple marked this conversation as resolved.
Show resolved Hide resolved

# Get token
token = keycloak_openid.token(code=response["code"], grant_type=[KEYCLOAK_GRANT_TYPE], redirect_uri=KEYCLOAK_REDIRECT_URI)

# Verify token
token_info = keycloak_openid.introspect(token['access_token'])
iDmple marked this conversation as resolved.
Show resolved Hide resolved
#log("token_info: %s", repr(token_info))
iDmple marked this conversation as resolved.
Show resolved Hide resolved

if token_info["active"]:
iDmple marked this conversation as resolved.
Show resolved Hide resolved
# Get userinfo
#user_info = keycloak_openid.userinfo(token['access_token'])
#log("userinfo_info: %s", repr(user_info))
iDmple marked this conversation as resolved.
Show resolved Hide resolved

log("keycloak authentication succeeded: token is active")
else:
iDmple marked this conversation as resolved.
Show resolved Hide resolved
log.error("keycloak authentication failed: token is not active")
iDmple marked this conversation as resolved.
Show resolved Hide resolved
return token_info["active"]
except Exception as e:
log.error("keycloak authentication failed with error code %s: %s", e.response_code, e.error_message)
return False