import json
import logging
import sys
+from typing import Optional
import cherrypy
"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")
"""
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
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
@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:
if password:
payload = {
'username': username,
- 'password': password
+ 'password': password,
+ 'ttl': ttl
}
cluster_token = self.check_cluster_connection(url, payload, username,
ssl_verify, ssl_certificate)
@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,
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
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
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">
]
}),
ssl: new FormControl(false),
+ ttl: new FormControl(15),
ssl_cert: new FormControl('', {
validators: [
CdValidators.requiredIf({
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;
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 = {
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;
token,
window.location.origin,
clusterFsid,
+ prometheusApiUrl,
ssl,
- ssl_certificate
+ ssl_certificate,
+ ttl
)
.subscribe(commonSubscribtion)
);
</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>
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',
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[];
constructor(
private multiClusterService: MultiClusterService,
- private settingsService: SettingsService,
private router: Router,
public actionLabels: ActionLabelsI18n,
private notificationService: NotificationService,
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',
prop: 'user',
name: $localize`User`,
flexGrow: 2
+ },
+ {
+ prop: 'ttl',
+ name: $localize`Token expires`,
+ flexGrow: 2,
+ cellTemplate: this.durationTpl
}
];
);
}
- 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;
}
.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) => {
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);
}
clusterFsid = '',
prometheusApiUrl = '',
ssl = false,
- cert = ''
+ cert = '',
+ ttl: number
) {
return this.http.post('api/multi-cluster/auth', {
url,
cluster_fsid: clusterFsid,
prometheus_api_url: prometheusApiUrl,
ssl_verify: ssl,
- ssl_certificate: cert
+ ssl_certificate: cert,
+ ttl: ttl
});
}
password: string,
token = '',
ssl = false,
- cert = ''
+ cert = '',
+ ttl: number
) {
return this.http.put('api/multi-cluster/reconnect_cluster', {
url,
password,
token,
ssl_verify: ssl,
- ssl_certificate: cert
+ ssl_certificate: cert,
+ ttl: ttl
});
}
refreshMultiCluster(currentRoute: string) {
this.refresh();
+ this.refreshTokenStatus();
this.summaryService.refresh();
if (currentRoute.includes('dashboard')) {
this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => {
cluster_connection_status: number;
ssl_verify: boolean;
ssl_certificate: string;
+ ttl: number;
}
schema:
properties:
password:
+ description: Password
type: string
+ ttl:
+ description: Token Time to Live (in hours)
+ type: integer
username:
+ description: Username
type: string
required:
- username
'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:
'500':
description: Unexpected error. Please check the response body for the stack
trace.
+ summary: Dashboard Authentication
tags:
- Auth
/api/auth/check:
type: boolean
token:
type: string
+ ttl:
+ type: string
url:
type: string
username:
type: boolean
token:
type: string
+ ttl:
+ type: string
url:
type: string
username:
import threading
import time
import uuid
+from typing import Optional
import cherrypy
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',