From dfc9aef0aada50ba51cdebe34ffd52c53765713c Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Thu, 29 Feb 2024 21:46:23 +0530 Subject: [PATCH] mgr/dashboard: allow the user to add ttl for the connected cluster's token expiration Fixes: https://tracker.ceph.com/issues/65055 Signed-off-by: Aashish Sharma --- src/pybind/mgr/dashboard/controllers/auth.py | 26 ++++++++-- .../dashboard/controllers/multi_cluster.py | 24 ++++++++-- .../multi-cluster-form.component.html | 18 +++++++ .../multi-cluster-form.component.ts | 13 ++++- .../multi-cluster-list.component.html | 21 +++++++++ .../multi-cluster-list.component.ts | 47 +++++++++++++++---- .../app/shared/api/multi-cluster.service.ts | 40 ++++++++++++++-- .../src/app/shared/models/multi-cluster.ts | 1 + src/pybind/mgr/dashboard/openapi.yaml | 47 ++++++++++++++++++- src/pybind/mgr/dashboard/services/auth.py | 9 ++-- 10 files changed, 217 insertions(+), 29 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index c2287ef51a8..2e6cf855c29 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -4,6 +4,7 @@ import http.cookies import json import logging import sys +from typing import Optional import cherrypy @@ -30,6 +31,17 @@ AUTH_CHECK_SCHEMA = { "pwdUpdateRequired": (bool, "Is password update required?") } +AUTH_SCHEMA = { + "token": (str, "Authentication Token"), + "username": (str, "Username"), + "permissions": ({ + "cephfs": ([str], "") + }, "List of permissions acquired"), + "pwdExpirationDate": (str, "Password expiration date"), + "sso": (bool, "Uses single sign on?"), + "pwdUpdateRequired": (bool, "Is password update required?") +} + @APIRouter('/auth', secure=False) @APIDoc("Initiate a session with Ceph", "Auth") @@ -37,9 +49,15 @@ class Auth(RESTController, ControllerAuthMixin): """ Provide authenticates and returns JWT token. """ - # pylint: disable=R0912 - - def create(self, username, password): + @EndpointDoc("Dashboard Authentication", + parameters={ + 'username': (str, 'Username'), + 'password': (str, 'Password'), + 'ttl': (int, 'Token Time to Live (in hours)') + }, + responses={201: AUTH_SCHEMA}) + def create(self, username, password, ttl: Optional[int] = None): + # pylint: disable=R0912 user_data = AuthManager.authenticate(username, password) user_perms, pwd_expiration_date, pwd_update_required = None, None, None max_attempt = Settings.ACCOUNT_LOCKOUT_ATTEMPTS @@ -60,7 +78,7 @@ class Auth(RESTController, ControllerAuthMixin): logger.info('Login successful: %s', username) mgr.ACCESS_CTRL_DB.reset_attempt(username) mgr.ACCESS_CTRL_DB.save() - token = JwtManager.gen_token(username) + token = JwtManager.gen_token(username, ttl=ttl) # For backward-compatibility: PyJWT versions < 2.0.0 return bytes. token = token.decode('utf-8') if isinstance(token, bytes) else token diff --git a/src/pybind/mgr/dashboard/controllers/multi_cluster.py b/src/pybind/mgr/dashboard/controllers/multi_cluster.py index b1aebddb6f6..ffdf9aed9dc 100644 --- a/src/pybind/mgr/dashboard/controllers/multi_cluster.py +++ b/src/pybind/mgr/dashboard/controllers/multi_cluster.py @@ -55,7 +55,8 @@ class MultiCluster(RESTController): @EndpointDoc("Authenticate to a remote cluster") def auth(self, url: str, cluster_alias: str, username: str, password=None, token=None, hub_url=None, cluster_fsid=None, - prometheus_api_url=None, ssl_verify=False, ssl_certificate=None): + prometheus_api_url=None, ssl_verify=False, ssl_certificate=None, ttl=None): + try: hub_fsid = mgr.get('config')['fsid'] except KeyError: @@ -64,7 +65,8 @@ class MultiCluster(RESTController): if password: payload = { 'username': username, - 'password': password + 'password': password, + 'ttl': ttl } cluster_token = self.check_cluster_connection(url, payload, username, ssl_verify, ssl_certificate) @@ -199,12 +201,13 @@ class MultiCluster(RESTController): @UpdatePermission # pylint: disable=W0613 def reconnect_cluster(self, url: str, username=None, password=None, token=None, - ssl_verify=False, ssl_certificate=None): + ssl_verify=False, ssl_certificate=None, ttl=None): multicluster_config = self.load_multi_cluster_config() if username and password: payload = { 'username': username, - 'password': password + 'password': password, + 'ttl': ttl } cluster_token = self.check_cluster_connection(url, payload, username, @@ -290,6 +293,15 @@ class MultiCluster(RESTController): current_time = time.time() return expiration_time < current_time + def get_time_left(self, jwt_token): + split_message = jwt_token.split(".") + base64_message = split_message[1] + decoded_token = json.loads(base64.urlsafe_b64decode(base64_message + "====")) + expiration_time = decoded_token['exp'] + current_time = time.time() + time_left = expiration_time - current_time + return max(0, time_left) + def check_token_status_expiration(self, token): if self.is_token_expired(token): return 1 @@ -303,7 +315,9 @@ class MultiCluster(RESTController): token = item['token'] user = item['user'] status = self.check_token_status_expiration(token) - token_status_map[cluster_name] = {'status': status, 'user': user} + time_left = self.get_time_left(token) + token_status_map[cluster_name] = {'status': status, 'user': user, + 'time_left': time_left} return token_status_map diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html index 7f92a26dece..30c32c0ec60 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html @@ -188,6 +188,24 @@ i18n>This field is required. +
+ +
+ +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts index e3174e23081..4a6ed695cba 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts @@ -141,6 +141,7 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy { ] }), ssl: new FormControl(false), + ttl: new FormControl(15), ssl_cert: new FormControl('', { validators: [ CdValidators.requiredIf({ @@ -178,6 +179,10 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy { this.activeModal.close(); } + convertToHours(value: number): number { + return value * 24; // Convert days to hours + } + onSubmit() { const url = this.remoteClusterForm.getValue('remoteClusterUrl'); const updatedUrl = url.endsWith('/') ? url.slice(0, -1) : url; @@ -186,7 +191,9 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy { const password = this.remoteClusterForm.getValue('password'); const token = this.remoteClusterForm.getValue('apiToken'); const clusterFsid = this.remoteClusterForm.getValue('clusterFsid'); + const prometheusApiUrl = this.remoteClusterForm.getValue('prometheusApiUrl'); const ssl = this.remoteClusterForm.getValue('ssl'); + const ttl = this.convertToHours(this.remoteClusterForm.getValue('ttl')); const ssl_certificate = this.remoteClusterForm.getValue('ssl_cert')?.trim(); const commonSubscribtion = { @@ -212,7 +219,7 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy { case 'reconnect': this.subs.add( this.multiClusterService - .reConnectCluster(updatedUrl, username, password, token, ssl, ssl_certificate) + .reConnectCluster(updatedUrl, username, password, token, ssl, ssl_certificate, ttl) .subscribe(commonSubscribtion) ); break; @@ -227,8 +234,10 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy { token, window.location.origin, clusterFsid, + prometheusApiUrl, ssl, - ssl_certificate + ssl_certificate, + ttl ) .subscribe(commonSubscribtion) ); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html index 70b657b59f6..ce54299833f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html @@ -61,3 +61,24 @@ + + + + {{ row.remainingTimeWithoutSeconds / 1000 | duration }} + + + + Token expired + + N/A + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts index 8b3a0f712e6..5d0b20f5edd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts @@ -19,7 +19,6 @@ import { MultiCluster } from '~/app/shared/models/multi-cluster'; import { Router } from '@angular/router'; import { CookiesService } from '~/app/shared/services/cookie.service'; import { Observable, Subscription } from 'rxjs'; -import { SettingsService } from '~/app/shared/api/settings.service'; @Component({ selector: 'cd-multi-cluster-list', @@ -31,7 +30,8 @@ export class MultiClusterListComponent implements OnInit, OnDestroy { table: TableComponent; @ViewChild('urlTpl', { static: true }) public urlTpl: TemplateRef; - + @ViewChild('durationTpl', { static: true }) + durationTpl: TemplateRef; private subs = new Subscription(); permissions: Permissions; tableActions: CdTableAction[]; @@ -50,7 +50,6 @@ export class MultiClusterListComponent implements OnInit, OnDestroy { constructor( private multiClusterService: MultiClusterService, - private settingsService: SettingsService, private router: Router, public actionLabels: ActionLabelsI18n, private notificationService: NotificationService, @@ -95,15 +94,25 @@ export class MultiClusterListComponent implements OnInit, OnDestroy { this.subs.add( this.multiClusterService.subscribe((resp: object) => { if (resp && resp['config']) { + this.hubUrl = resp['hub_url']; + this.currentUrl = resp['current_url']; const clusterDetailsArray = Object.values(resp['config']).flat(); this.data = clusterDetailsArray; this.checkClusterConnectionStatus(); + this.data.forEach((cluster: any) => { + cluster['remainingTimeWithoutSeconds'] = 0; + if (cluster['ttl'] && cluster['ttl'] > 0) { + cluster['ttl'] = cluster['ttl'] * 1000; + cluster['remainingTimeWithoutSeconds'] = this.getRemainingTimeWithoutSeconds( + cluster['ttl'] + ); + cluster['remainingDays'] = this.getRemainingDays(cluster['ttl']); + } + }); } }) ); - this.managedByConfig$ = this.settingsService.getValues('MANAGED_BY_CLUSTERS'); - this.columns = [ { prop: 'cluster_alias', @@ -138,6 +147,12 @@ export class MultiClusterListComponent implements OnInit, OnDestroy { prop: 'user', name: $localize`User`, flexGrow: 2 + }, + { + prop: 'ttl', + name: $localize`Token expires`, + flexGrow: 2, + cellTemplate: this.durationTpl } ]; @@ -149,21 +164,35 @@ export class MultiClusterListComponent implements OnInit, OnDestroy { ); } - ngOnDestroy() { + ngOnDestroy(): void { this.subs.unsubscribe(); } + getRemainingDays(time: number): number { + if (time === undefined || time == null) { + return undefined; + } + if (time < 0) { + return 0; + } + const toDays = 1000 * 60 * 60 * 24; + return Math.max(0, Math.floor(time / toDays)); + } + + getRemainingTimeWithoutSeconds(time: number): number { + return Math.floor(time / (1000 * 60)) * 60 * 1000; + } + checkClusterConnectionStatus() { if (this.clusterTokenStatus && this.data) { this.data.forEach((cluster: MultiCluster) => { - const clusterStatus = this.clusterTokenStatus[cluster.name.trim()]; - + const clusterStatus = this.clusterTokenStatus[cluster.name]; if (clusterStatus !== undefined) { cluster.cluster_connection_status = clusterStatus.status; + cluster.ttl = clusterStatus.time_left; } else { cluster.cluster_connection_status = 2; } - if (cluster.cluster_alias === 'local-cluster') { cluster.cluster_connection_status = 0; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts index a5b9a0f89f5..a26e1b7f199 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts @@ -30,12 +30,29 @@ export class MultiClusterService { .subscribe(this.getClusterObserver()); } + getTempMap(clustersConfig: any) { + const tempMap = new Map(); + Object.keys(clustersConfig).forEach((clusterKey: string) => { + const clusterDetailsList = clustersConfig[clusterKey]; + clusterDetailsList.forEach((clusterDetails: any) => { + if (clusterDetails['token'] && clusterDetails['name'] && clusterDetails['user']) { + tempMap.set(clusterDetails['name'], { + token: clusterDetails['token'], + user: clusterDetails['user'] + }); + } + }); + }); + return tempMap; + } + startClusterTokenStatusPolling() { let clustersTokenMap = new Map(); const dataSubscription = this.subscribe((resp: any) => { const clustersConfig = resp['config']; - const tempMap = new Map(); + let tempMap = new Map(); if (clustersConfig) { + tempMap = this.getTempMap(clustersConfig); Object.keys(clustersConfig).forEach((clusterKey: string) => { const clusterDetailsList = clustersConfig[clusterKey]; clusterDetailsList.forEach((clusterDetails: any) => { @@ -74,6 +91,14 @@ export class MultiClusterService { return this.getCluster().subscribe(this.getClusterObserver()); } + refreshTokenStatus() { + this.subscribe((resp: any) => { + const clustersConfig = resp['config']; + let tempMap = this.getTempMap(clustersConfig); + return this.checkTokenStatus(tempMap).subscribe(this.getClusterTokenStatusObserver()); + }); + } + subscribe(next: (data: any) => void, error?: (error: any) => void) { return this.msData$.pipe(filter((value) => !!value)).subscribe(next, error); } @@ -108,7 +133,8 @@ export class MultiClusterService { clusterFsid = '', prometheusApiUrl = '', ssl = false, - cert = '' + cert = '', + ttl: number ) { return this.http.post('api/multi-cluster/auth', { url, @@ -120,7 +146,8 @@ export class MultiClusterService { cluster_fsid: clusterFsid, prometheus_api_url: prometheusApiUrl, ssl_verify: ssl, - ssl_certificate: cert + ssl_certificate: cert, + ttl: ttl }); } @@ -130,7 +157,8 @@ export class MultiClusterService { password: string, token = '', ssl = false, - cert = '' + cert = '', + ttl: number ) { return this.http.put('api/multi-cluster/reconnect_cluster', { url, @@ -138,7 +166,8 @@ export class MultiClusterService { password, token, ssl_verify: ssl, - ssl_certificate: cert + ssl_certificate: cert, + ttl: ttl }); } @@ -181,6 +210,7 @@ export class MultiClusterService { refreshMultiCluster(currentRoute: string) { this.refresh(); + this.refreshTokenStatus(); this.summaryService.refresh(); if (currentRoute.includes('dashboard')) { this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts index 329aefb592e..e41bb12e16d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts @@ -7,4 +7,5 @@ export interface MultiCluster { cluster_connection_status: number; ssl_verify: boolean; ssl_certificate: string; + ttl: number; } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index fcab8fdafda..ae0f9e03921 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -21,8 +21,13 @@ paths: schema: properties: password: + description: Password type: string + ttl: + description: Token Time to Live (in hours) + type: integer username: + description: Username type: string required: - username @@ -32,7 +37,42 @@ paths: '201': content: application/vnd.ceph.api.v1.0+json: - type: object + schema: + properties: + permissions: + description: List of permissions acquired + properties: + cephfs: + description: '' + items: + type: string + type: array + required: + - cephfs + type: object + pwdExpirationDate: + description: Password expiration date + type: string + pwdUpdateRequired: + description: Is password update required? + type: boolean + sso: + description: Uses single sign on? + type: boolean + token: + description: Authentication Token + type: string + username: + description: Username + type: string + required: + - token + - username + - permissions + - pwdExpirationDate + - sso + - pwdUpdateRequired + type: object description: Resource created. '202': content: @@ -48,6 +88,7 @@ paths: '500': description: Unexpected error. Please check the response body for the stack trace. + summary: Dashboard Authentication tags: - Auth /api/auth/check: @@ -7077,6 +7118,8 @@ paths: type: boolean token: type: string + ttl: + type: string url: type: string username: @@ -7257,6 +7300,8 @@ paths: type: boolean token: type: string + ttl: + type: string url: type: string username: diff --git a/src/pybind/mgr/dashboard/services/auth.py b/src/pybind/mgr/dashboard/services/auth.py index 3c600231252..3b8d5ed5f3a 100644 --- a/src/pybind/mgr/dashboard/services/auth.py +++ b/src/pybind/mgr/dashboard/services/auth.py @@ -9,6 +9,7 @@ import os import threading import time import uuid +from typing import Optional import cherrypy @@ -96,11 +97,13 @@ class JwtManager(object): return decoded_message @classmethod - def gen_token(cls, username): + def gen_token(cls, username, ttl: Optional[int] = None): if not cls._secret: cls.init() - ttl = mgr.get_module_option('jwt_token_ttl', cls.JWT_TOKEN_TTL) - ttl = int(ttl) + if ttl is None: + ttl = mgr.get_module_option('jwt_token_ttl', cls.JWT_TOKEN_TTL) + else: + ttl = int(ttl) * 60 * 60 # convert hours to seconds now = int(time.time()) payload = { 'iss': 'ceph-dashboard', -- 2.39.5