]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: allow the user to add ttl for the connected cluster's
authorAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Thu, 29 Feb 2024 16:16:23 +0000 (21:46 +0530)
committerAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Fri, 29 Mar 2024 10:32:59 +0000 (16:02 +0530)
token expiration

Fixes: https://tracker.ceph.com/issues/65055
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/multi_cluster.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/auth.py

index c2287ef51a80f97d7b6335927ed5de9859060b0f..2e6cf855c29773d7ef37798c6f0f3f8d3dc03c62 100644 (file)
@@ -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
index b1aebddb6f6bff8c9f75ee74ea895c9e2109fc7c..ffdf9aed9dc0023549209f789073eacf34e5f71d 100644 (file)
@@ -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
 
index 7f92a26dece7c6dbf7dd5ce3f69e12a1f6429580..30c32c0ec608772a409c7519fd14e7a1e2f75eb7 100644 (file)
                   i18n>This field is required.</span>
           </div>
         </div>
+        <div class="form-group row"
+             *ngIf="!remoteClusterForm.getValue('showToken') && action !== 'edit'">
+          <label class="cd-col-form-label"
+                 for="ttl"
+                 i18n>Login Expiration</label>
+          <div class="cd-col-form-input">
+            <select class="form-select"
+                    id="ttl"
+                    formControlName="ttl"
+                    name="ttl">
+              <option value="1">1 day</option>
+              <option value="7">1 week</option>
+              <option value="15"
+                      [selected]="true">15 days</option>
+              <option value="30">30 days</option>
+            </select>
+          </div>
+        </div>
         <div class="form-group row"
              *ngIf="action !== 'edit'">
           <div class="cd-col-form-offset">
index e3174e230816c1d6061067f85a340ecca3659692..4a6ed695cba44301924c5c64c2343cbdc1416d5f 100644 (file)
@@ -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)
         );
index 70b657b59f643318313b956d39244a7ac8b3771e..ce54299833f58dd5a4158f14182c3c89c96243a0 100644 (file)
   </a>
 </ng-template>
 
+<ng-template #durationTpl
+             let-column="column"
+             let-value="value"
+             let-row="row">
+  <span *ngIf="row.remainingTimeWithoutSeconds > 0 && row.cluster_alias !== 'local-cluster'">
+    <i *ngIf="row.remainingDays < 8"
+       i18n-title
+       title="Cluster's token is about to expire"
+       [class.icon-danger-color]="row.remainingDays < 2"
+       [class.icon-warning-color]="row.remainingDays < 8"
+       class="{{ icons.warning }}"></i>
+    <span title="{{ value | cdDate }}">{{ row.remainingTimeWithoutSeconds / 1000 | duration }}</span>
+  </span>
+  <span *ngIf="row.remainingTimeWithoutSeconds <= 0 && row.remainingDays <=0 && row.cluster_alias !== 'local-cluster'">
+    <i i18n-title
+       title="Cluster's token has expired"
+       class="{{ icons.danger }}"></i>
+    <span class="text-danger">Token expired</span>
+  </span>
+  <span *ngIf="row.cluster_alias === 'local-cluster'">N/A</span>
+</ng-template>
index 8b3a0f712e6354208e862c31c922a3878948ab6e..5d0b20f5edd93ce159219280a87d6485864558d5 100644 (file)
@@ -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<any>;
-
+  @ViewChild('durationTpl', { static: true })
+  durationTpl: TemplateRef<any>;
   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;
         }
index a5b9a0f89f58c0de3bcc502764c99957c4b1b0b4..a26e1b7f199ab97846c2d828e330faff850f934f 100644 (file)
@@ -30,12 +30,29 @@ export class MultiClusterService {
       .subscribe(this.getClusterObserver());
   }
 
+  getTempMap(clustersConfig: any) {
+    const tempMap = new Map<string, { token: string; user: string }>();
+    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<string, { token: string; user: string }>();
     const dataSubscription = this.subscribe((resp: any) => {
       const clustersConfig = resp['config'];
-      const tempMap = new Map<string, { token: string; user: string }>();
+      let tempMap = new Map<string, { token: string; user: string }>();
       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(() => {
index 329aefb592eb5a5cf678f1d28cacd7f30884cf93..e41bb12e16d4cd5c2c7988ec441343d081f75939 100644 (file)
@@ -7,4 +7,5 @@ export interface MultiCluster {
   cluster_connection_status: number;
   ssl_verify: boolean;
   ssl_certificate: string;
+  ttl: number;
 }
index fcab8fdafdaef48db2798990cb4f6cb2ca72fcf6..ae0f9e03921728cb05a6d35f3fb3fd676c5145b8 100644 (file)
@@ -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:
index 3c6002312524dc6b4245da46f9faf88e041eb644..3b8d5ed5f3ac5d21feb3b4b9d63b3b1b24fafa43 100644 (file)
@@ -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',