Skip to content
/ kvmd Public

Commit 2fa2a4d

Browse files
Markus Beckschulteintelfx
authored andcommitted
OAuth Manager and OAuth2/OpenID connect Plugin (#156)
1 parent e849885 commit 2fa2a4d

File tree

10 files changed

+626
-4
lines changed

10 files changed

+626
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919
*.pyc
2020
*.swp
2121
/venv/
22+
/.idea

kvmd/apps/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from ..plugins import UnknownPluginError
3737
from ..plugins.auth import get_auth_service_class
38+
from ..plugins.auth import get_oauth_service_class
3839
from ..plugins.hid import get_hid_class
3940
from ..plugins.atx import get_atx_class
4041
from ..plugins.msd import get_msd_class
@@ -268,9 +269,17 @@ def _patch_dynamic( # pylint: disable=too-many-locals
268269
rebuild = False
269270

270271
if load_auth:
271-
scheme["kvmd"]["auth"]["internal"].update(get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options())
272+
scheme["kvmd"]["auth"]["internal"].update(
273+
get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options()
274+
)
272275
if config.kvmd.auth.external.type:
273-
scheme["kvmd"]["auth"]["external"].update(get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options())
276+
scheme["kvmd"]["auth"]["external"].update(
277+
get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options()
278+
)
279+
if config.kvmd.auth.oauth.enabled:
280+
for provider, data in tools.rget(raw_config, "kvmd", "auth", "oauth", "providers").items():
281+
scheme["kvmd"]["auth"]["oauth"]["providers"][provider] = get_oauth_service_class(data["type"]).get_plugin_options()
282+
scheme["kvmd"]["auth"]["oauth"]["providers"][provider]["type"] = Option(data["type"])
274283
rebuild = True
275284

276285
for (load, section, get_class) in [
@@ -385,6 +394,13 @@ def _get_config_scheme() -> dict:
385394
# Dynamic content
386395
},
387396

397+
"oauth": {
398+
"enabled": Option(False, type=valid_bool),
399+
"providers": {
400+
# Dynamic content
401+
}
402+
},
403+
388404
"totp": {
389405
"secret": {
390406
"file": Option("/etc/kvmd/totp.secret", type=valid_abs_path, if_empty=""),

kvmd/apps/kvmd/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ def main(argv: (list[str] | None)=None) -> None:
8989
ext_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
9090

9191
totp_secret_path=config.auth.totp.secret.file,
92+
93+
oauth_enabled=config.auth.oauth.enabled,
94+
oauth_providers=config.auth.oauth.providers._unpack(ignore=["enabled"] if config.auth.oauth.enabled else {}),
9295
),
9396
info_manager=InfoManager(global_config),
9497
log_reader=(LogReader() if config.log_reader.enabled else None),

kvmd/apps/kvmd/api/auth.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from aiohttp.web import Request
2626
from aiohttp.web import Response
27+
from aiohttp.web_exceptions import HTTPNotFound, HTTPFound, HTTPUnauthorized
2728

2829
from ....htserver import UnauthorizedError
2930
from ....htserver import ForbiddenError
@@ -43,6 +44,7 @@
4344

4445
# =====
4546
_COOKIE_AUTH_TOKEN = "auth_token"
47+
_COOKIE_OAUTH_SESSION = "oauth-session"
4648

4749

4850
async def _check_xhdr(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
@@ -136,3 +138,91 @@ async def __logout_handler(self, req: Request) -> Response:
136138
@exposed_http("GET", "/auth/check", allow_usc=False)
137139
async def __check_handler(self, _: Request) -> Response:
138140
return make_json_response()
141+
142+
@exposed_http("GET", "/auth/oauth/providers", auth_required=False)
143+
async def __oauth_providers(self, request: Request) -> Response:
144+
"""
145+
Return a json containing the available Providers with short_name and long_name and if oauth is enabled
146+
@param request:
147+
@return: json with provider infos
148+
"""
149+
response: dict[str, (bool | dict)] = {}
150+
if self.__auth_manager.oauth_manager is None:
151+
response.update({'enabled': False})
152+
else:
153+
response.update({'enabled': True, 'providers': self.__auth_manager.oauth_manager.get_providers()})
154+
return make_json_response(response)
155+
156+
@exposed_http("GET", "/auth/oauth/login/{provider}", auth_required=False)
157+
async def __oauth(self, request: Request) -> None:
158+
"""
159+
Creates the redirect to the Provider specified in the URL. Checks if the provider is valid.
160+
Also sets a cookie containing session information.
161+
@param request:
162+
@return: redirect to provider
163+
"""
164+
if self.__auth_manager.oauth_manager is None:
165+
return
166+
provider = format(request.match_info['provider'])
167+
if not self.__auth_manager.oauth_manager.valid_provider(provider):
168+
raise HTTPNotFound(reason="Unknown provider %s" % provider)
169+
170+
redirect_url = request.url.with_path(f"/api/auth/oauth/callback/{provider}").with_scheme('https')
171+
oauth_cookie = request.cookies.get(_COOKIE_OAUTH_SESSION, "")
172+
173+
is_valid_session = await self.__auth_manager.oauth_manager.is_valid_session(provider, oauth_cookie)
174+
if not is_valid_session:
175+
session = await self.__auth_manager.oauth_manager.register_new_session(provider)
176+
else:
177+
session = oauth_cookie
178+
179+
response = HTTPFound(
180+
await self.__auth_manager.oauth_manager.get_authorize_url(
181+
provider=provider, redirect_url=redirect_url, session=session,
182+
)
183+
)
184+
response.set_cookie(name=_COOKIE_OAUTH_SESSION, value=session, secure=True, httponly=True, samesite="Lax")
185+
186+
# 302 redirect to provider:
187+
raise response
188+
189+
@exposed_http("GET", "/auth/oauth/callback/{provider}", auth_required=False)
190+
async def __callback(self, request: Request) -> Response:
191+
"""
192+
After successful login on the side of the provider, the user gets redirected here. If everything is correct,
193+
the user gets logged in with the username provided by the Provider.
194+
@param request:
195+
@return:
196+
"""
197+
if self.__auth_manager.oauth_manager is None:
198+
return make_json_response()
199+
200+
if not request.match_info['provider']:
201+
raise HTTPUnauthorized(reason="Provider is missing")
202+
provider = format(request.match_info['provider'])
203+
if not self.__auth_manager.oauth_manager.valid_provider(provider):
204+
raise HTTPNotFound(reason="Unknown provider %s" % provider)
205+
206+
if _COOKIE_OAUTH_SESSION not in request.cookies.keys():
207+
raise HTTPUnauthorized(reason="Cookie is missing")
208+
oauth_session = request.cookies[_COOKIE_OAUTH_SESSION]
209+
210+
if not self.__auth_manager.oauth_manager.is_redirect_from_provider(provider=provider, request_query=dict(request.query)):
211+
raise HTTPUnauthorized(reason="Authorization Code is missing")
212+
213+
redirect_url = request.url.with_query("").with_path(f"/api/auth/oauth/callback/{provider}").with_scheme('https')
214+
user = await self.__auth_manager.oauth_manager.get_user_info(
215+
provider=provider,
216+
oauth_session=oauth_session,
217+
request_query=dict(request.query),
218+
redirect_url=redirect_url
219+
)
220+
221+
if self.__auth_manager.is_auth_enabled():
222+
token = await self.__auth_manager.login_oauth(
223+
user=valid_user(user)
224+
)
225+
if token:
226+
return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token})
227+
raise ForbiddenError()
228+
return make_json_response()

kvmd/apps/kvmd/auth.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import secrets
3030
import pyotp
3131

32+
from .oauth import OAuthManager
3233
from ...logging import get_logger
3334

3435
from ... import aiotools
@@ -41,6 +42,10 @@
4142

4243

4344
# =====
45+
class AuthManager: # pylint: disable=too-many-instance-attributes
46+
def __init__( # pylint: disable=too-many-arguments
47+
48+
4449
@dataclasses.dataclass(frozen=True)
4550
class _Session:
4651
user: str
@@ -69,6 +74,9 @@ def __init__(
6974
ext_kwargs: dict,
7075

7176
totp_secret_path: str,
77+
78+
oauth_enabled: bool = False,
79+
oauth_providers: (dict | None) = None,
7280
) -> None:
7381

7482
logger = get_logger(0)
@@ -107,10 +115,20 @@ def __init__(
107115
logger.info("Using external auth service %r",
108116
self.__ext_service.get_plugin_name())
109117

118+
self.oauth_manager: (OAuthManager | None) = None
119+
if enabled and oauth_enabled:
120+
if oauth_providers is None:
121+
oauth_providers = {}
122+
self.oauth_manager = OAuthManager(oauth_providers)
123+
get_logger().info("Using OAuth service")
124+
110125
self.__totp_secret_path = totp_secret_path
111126

112127
self.__sessions: dict[str, _Session] = {} # {token: session}
113128

129+
self.__tokens: dict[str, str] = {} # {token: user}
130+
self.__oauth_tokens: list[str] = []
131+
114132
def is_auth_enabled(self) -> bool:
115133
return self.__enabled
116134

@@ -172,6 +190,22 @@ async def login(self, user: str, passwd: str, expire: int) -> (str | None):
172190

173191
return None
174192

193+
async def login_oauth(self, user: str) -> (str | None):
194+
"""
195+
registers the user, who logged in with oauth, with a new token.
196+
@param user: the username provided by the oauth provider
197+
@return:
198+
"""
199+
assert user == user.strip()
200+
assert user
201+
assert self.__enabled
202+
assert self.oauth_manager
203+
token = self.__make_new_token()
204+
self.__tokens[token] = user
205+
206+
get_logger().info("Logged in user with OAuth %r", user)
207+
return token
208+
175209
def __make_new_token(self) -> str:
176210
for _ in range(10):
177211
token = secrets.token_hex(32)

0 commit comments

Comments
 (0)