diff --git a/.gitignore b/.gitignore index 041bb0e72..00c0ac912 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ tests/vagrant/setups/ venv/ virtualenv/ wheelhouse/ +.coverage +coverage.xml diff --git a/images/aquarium/config.sh b/images/aquarium/config.sh index ceff81701..30880dfc6 100644 --- a/images/aquarium/config.sh +++ b/images/aquarium/config.sh @@ -103,6 +103,7 @@ if [[ "$kiwi_profiles" == *"Vagrant"* ]]; then fi pip install fastapi==0.63.0 uvicorn==0.13.3 websockets==8.1 \ + bcrypt==3.2.0 pyjwt==2.1.0 python-multipart==0.0.5 \ git+https://github.com/aquarist-labs/aetcd3/@edf633045ce61c7bbac4d4a6ca15b14f8acfe9cd baseInsertService aquarium-boot baseInsertService sshd diff --git a/src/aquarium.py b/src/aquarium.py index c7c2c80df..29cc19729 100755 --- a/src/aquarium.py +++ b/src/aquarium.py @@ -39,12 +39,14 @@ local, devices, nfs, + auth, + user, ) logger: logging.Logger = fastapi_logger -async def aquarium_startup(aquarium_app: FastAPI, aquarium_api: FastAPI): +async def aquarium_startup(_: FastAPI, aquarium_api: FastAPI): lvl = "INFO" if not os.getenv("AQUARIUM_DEBUG") else "DEBUG" setup_logging(lvl) logger.info("Aquarium startup!") @@ -100,7 +102,7 @@ async def aquarium_startup(aquarium_app: FastAPI, aquarium_api: FastAPI): aquarium_api.state.nodemgr = nodemgr -async def aquarium_shutdown(aquarium_app: FastAPI, aquarium_api: FastAPI): +async def aquarium_shutdown(_: FastAPI, aquarium_api: FastAPI): logger.info("Aquarium shutdown!") await aquarium_api.state.gstate.shutdown() logger.info("shutting down node manager") @@ -136,6 +138,14 @@ def aquarium_factory( { "name": "devices", "description": "Obtain and perform operations on cluster devices" + }, + { + "name": "auth", + "description": "Operations related to user authentication" + }, + { + "name": "user", + "description": "Operations related to user management" } ] @@ -163,6 +173,8 @@ async def on_shutdown(): aquarium_api.include_router(nodes.router) aquarium_api.include_router(devices.router) aquarium_api.include_router(nfs.router) + aquarium_api.include_router(auth.router) + aquarium_api.include_router(user.router) # # mounts diff --git a/src/glass/src/app/app-routing.module.ts b/src/glass/src/app/app-routing.module.ts index 6bdd9c7cc..2b1a40d7e 100644 --- a/src/glass/src/app/app-routing.module.ts +++ b/src/glass/src/app/app-routing.module.ts @@ -25,9 +25,12 @@ import { HostsPageComponent } from '~/app/pages/hosts-page/hosts-page.component' import { InstallModePageComponent } from '~/app/pages/install-mode-page/install-mode-page.component'; import { InstallCreateWizardPageComponent } from '~/app/pages/install-wizard/install-create-wizard-page/install-create-wizard-page.component'; import { InstallJoinWizardPageComponent } from '~/app/pages/install-wizard/install-join-wizard-page/install-join-wizard-page.component'; +import { LoginPageComponent } from '~/app/pages/login-page/login-page.component'; import { NotFoundPageComponent } from '~/app/pages/not-found-page/not-found-page.component'; import { ServicesPageComponent } from '~/app/pages/services-page/services-page.component'; +import { UsersPageComponent } from '~/app/pages/users-page/users-page.component'; import { WelcomePageComponent } from '~/app/pages/welcome-page/welcome-page.component'; +import { AuthGuardService } from '~/app/shared/services/auth-guard.service'; import { StatusRouteGuardService } from '~/app/shared/services/status-route-guard.service'; const routes: Routes = [ @@ -40,6 +43,8 @@ const routes: Routes = [ { path: 'dashboard', data: { breadcrumb: TEXT('Dashboard') }, + canActivate: [AuthGuardService], + canActivateChild: [AuthGuardService], children: [ { path: '', component: DashboardPageComponent }, { @@ -47,7 +52,8 @@ const routes: Routes = [ component: ServicesPageComponent, data: { breadcrumb: TEXT('Services') } }, - { path: 'hosts', component: HostsPageComponent, data: { breadcrumb: TEXT('Hosts') } } + { path: 'hosts', component: HostsPageComponent, data: { breadcrumb: TEXT('Hosts') } }, + { path: 'users', component: UsersPageComponent, data: { breadcrumb: TEXT('Users') } } ] } ] @@ -74,6 +80,7 @@ const routes: Routes = [ path: '', component: BlankLayoutComponent, children: [ + { path: 'login', component: LoginPageComponent }, { path: '404', component: NotFoundPageComponent diff --git a/src/glass/src/app/core/layouts/blank-layout/blank-layout.component.html b/src/glass/src/app/core/layouts/blank-layout/blank-layout.component.html index 0680b43f9..358c9c27f 100644 --- a/src/glass/src/app/core/layouts/blank-layout/blank-layout.component.html +++ b/src/glass/src/app/core/layouts/blank-layout/blank-layout.component.html @@ -1 +1,3 @@ - + + + diff --git a/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.html b/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.html index 033acbe25..f9df6d786 100644 --- a/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.html +++ b/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.html @@ -2,18 +2,20 @@

{{ config.title }}

- - + + + {{ config.submitButtonText! | translate }} + diff --git a/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.ts b/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.ts index a1260e319..3e6c07b32 100644 --- a/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.ts +++ b/src/glass/src/app/core/modals/declarative-form/declarative-form-modal.component.ts @@ -23,8 +23,9 @@ export class DeclarativeFormModalComponent { ) { this.config = _.defaultsDeep(data, { fields: [], - okButtonVisible: true, - okButtonText: TEXT('OK'), + submitButtonVisible: true, + submitButtonText: TEXT('OK'), + submitButtonResult: undefined, cancelButtonVisible: true, cancelButtonText: TEXT('Cancel'), cancelButtonResult: false @@ -32,9 +33,9 @@ export class DeclarativeFormModalComponent { } onOK(): void { - const result = _.isUndefined(this.config.okButtonResult) + const result = _.isUndefined(this.config.submitButtonResult) ? this.form.values - : this.config.okButtonResult; + : this.config.submitButtonResult; this.matDialogRef.close(result); } } diff --git a/src/glass/src/app/core/modals/file-service/file-service-modal.component.html b/src/glass/src/app/core/modals/file-service/file-service-modal.component.html index f2ced2b47..397d2275e 100644 --- a/src/glass/src/app/core/modals/file-service/file-service-modal.component.html +++ b/src/glass/src/app/core/modals/file-service/file-service-modal.component.html @@ -130,7 +130,9 @@ - + + {{ username }} + + diff --git a/src/glass/src/app/core/top-bar/top-bar.component.scss b/src/glass/src/app/core/top-bar/top-bar.component.scss index 022363d63..12713e929 100644 --- a/src/glass/src/app/core/top-bar/top-bar.component.scss +++ b/src/glass/src/app/core/top-bar/top-bar.component.scss @@ -1,7 +1,23 @@ +@use '~@angular/material' as mat; @import 'styles.scss'; -.mat-toolbar { - ::ng-deep button .mat-icon { +$mat-typography-config: mat.define-typography-config(); + +.glass-top-bar { + ::ng-deep &.mat-toolbar { + .mat-icon { + color: $glass-color-primary-complementary; + } + .mat-divider { + height: 24px; + background-color: $glass-color-primary-complementary; + } + } + + .username { color: $glass-color-primary-complementary; + font-size: mat.font-size($mat-typography-config, body-1); + font-weight: mat.font-weight($mat-typography-config, body-1); + padding: 16px; } } diff --git a/src/glass/src/app/core/top-bar/top-bar.component.ts b/src/glass/src/app/core/top-bar/top-bar.component.ts index 8be162c8e..c98bd2755 100644 --- a/src/glass/src/app/core/top-bar/top-bar.component.ts +++ b/src/glass/src/app/core/top-bar/top-bar.component.ts @@ -1,5 +1,11 @@ import { Component, Input } from '@angular/core'; import { MatSidenav } from '@angular/material/sidenav'; +import { marker as TEXT } from '@biesbjerg/ngx-translate-extract-marker'; + +import { DialogComponent } from '~/app/shared/components/dialog/dialog.component'; +import { AuthService } from '~/app/shared/services/api/auth.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { DialogService } from '~/app/shared/services/dialog.service'; @Component({ selector: 'glass-top-bar', @@ -11,9 +17,36 @@ export class TopBarComponent { @Input('navigationSidenav') navigationSidenav!: MatSidenav; - constructor() {} + username: string | null; + + constructor( + private authService: AuthService, + private authStorageService: AuthStorageService, + private dialogService: DialogService + ) { + this.username = this.authStorageService.getUsername(); + } - onToggleNavigationBar() { + onToggleNavigationBar(): void { this.navigationSidenav.toggle(); } + + onLogout(): void { + this.dialogService.open( + DialogComponent, + (res: boolean) => { + if (res) { + this.authService.logout().subscribe(); + } + }, + { + width: '40%', + data: { + type: 'yesNo', + icon: 'question', + message: TEXT('Do you really want to logout?') + } + } + ); + } } diff --git a/src/glass/src/app/material.modules.ts b/src/glass/src/app/material.modules.ts index 16c5ab9d4..5004839e1 100644 --- a/src/glass/src/app/material.modules.ts +++ b/src/glass/src/app/material.modules.ts @@ -17,8 +17,10 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; @@ -62,7 +64,9 @@ import { DomSanitizer } from '@angular/platform-browser'; MatRadioModule, MatRippleModule, MatSelectModule, - MatBadgeModule + MatBadgeModule, + MatCheckboxModule, + MatDividerModule ] }) export class MaterialModule { diff --git a/src/glass/src/app/pages/dashboard-page/dashboard-page.component.spec.ts b/src/glass/src/app/pages/dashboard-page/dashboard-page.component.spec.ts index cae01bc36..cdeb57f89 100644 --- a/src/glass/src/app/pages/dashboard-page/dashboard-page.component.spec.ts +++ b/src/glass/src/app/pages/dashboard-page/dashboard-page.component.spec.ts @@ -1,6 +1,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; @@ -16,7 +16,7 @@ describe('DashboardPageComponent', () => { imports: [ HttpClientTestingModule, PagesModule, - BrowserAnimationsModule, + NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot() ] diff --git a/src/glass/src/app/pages/hosts-page/hosts-page.component.ts b/src/glass/src/app/pages/hosts-page/hosts-page.component.ts index 3c14e0e81..fd20f4c98 100644 --- a/src/glass/src/app/pages/hosts-page/hosts-page.component.ts +++ b/src/glass/src/app/pages/hosts-page/hosts-page.component.ts @@ -50,7 +50,7 @@ export class HostsPageComponent { this.dialogService.open(DeclarativeFormModalComponent, undefined, { width: '40%', data: { - title: 'Authentication Token', + title: TEXT('Authentication Token'), subtitle: TEXT( 'Use this token to authenticate a new node when adding it to the cluster.' ), @@ -64,7 +64,7 @@ export class HostsPageComponent { class: 'glass-text-monospaced' } ], - okButtonVisible: false, + submitButtonVisible: false, cancelButtonText: TEXT('Close') } }); diff --git a/src/glass/src/app/pages/login-page/login-page.component.html b/src/glass/src/app/pages/login-page/login-page.component.html new file mode 100644 index 000000000..444946122 --- /dev/null +++ b/src/glass/src/app/pages/login-page/login-page.component.html @@ -0,0 +1,18 @@ + diff --git a/src/glass/src/app/pages/login-page/login-page.component.scss b/src/glass/src/app/pages/login-page/login-page.component.scss new file mode 100644 index 000000000..26e3358d0 --- /dev/null +++ b/src/glass/src/app/pages/login-page/login-page.component.scss @@ -0,0 +1,29 @@ +@import 'styles.scss'; + +.glass-login-page { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: #001b38; + + .glass-login-page-background { + position: absolute; + height: 100%; + width: 100%; + background-color: $glass-color-primary; + background-image: url('/assets/images/mohamed-ahsan-pV4utCVv6vk-unsplash.jpg'); + background-blend-mode: multiply; + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + } + + .glass-login-page-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/glass/src/app/pages/login-page/login-page.component.spec.ts b/src/glass/src/app/pages/login-page/login-page.component.spec.ts new file mode 100644 index 000000000..69479fb2f --- /dev/null +++ b/src/glass/src/app/pages/login-page/login-page.component.spec.ts @@ -0,0 +1,38 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ToastrModule } from 'ngx-toastr'; + +import { LoginPageComponent } from '~/app/pages/login-page/login-page.component'; +import { PagesModule } from '~/app/pages/pages.module'; + +describe('LoginPageComponent', () => { + let component: LoginPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LoginPageComponent], + imports: [ + HttpClientTestingModule, + NoopAnimationsModule, + PagesModule, + RouterTestingModule, + ToastrModule.forRoot(), + TranslateModule.forRoot() + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/glass/src/app/pages/login-page/login-page.component.ts b/src/glass/src/app/pages/login-page/login-page.component.ts new file mode 100644 index 000000000..1324095e0 --- /dev/null +++ b/src/glass/src/app/pages/login-page/login-page.component.ts @@ -0,0 +1,64 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { marker as TEXT } from '@biesbjerg/ngx-translate-extract-marker'; +import { BlockUI, NgBlockUI } from 'ng-block-ui'; +import { finalize } from 'rxjs/operators'; + +import { translate } from '~/app/i18n.helper'; +import { DeclarativeFormComponent } from '~/app/shared/components/declarative-form/declarative-form.component'; +import { DeclarativeFormConfig } from '~/app/shared/models/declarative-form-config.type'; +import { AuthService } from '~/app/shared/services/api/auth.service'; + +@Component({ + selector: 'glass-login-page', + templateUrl: './login-page.component.html', + styleUrls: ['./login-page.component.scss'] +}) +export class LoginPageComponent implements OnInit { + @BlockUI() + blockUI!: NgBlockUI; + + @ViewChild(DeclarativeFormComponent, { static: true }) + form!: DeclarativeFormComponent; + + public config: DeclarativeFormConfig = { + id: 'loginPage', + fields: [ + { + name: 'username', + type: 'text', + label: TEXT('Username'), + value: '', + validators: { + required: true + } + }, + { + name: 'password', + type: 'password', + label: TEXT('Password'), + value: '', + validators: { + required: true + } + } + ] + }; + + constructor(private authService: AuthService, private router: Router) {} + + ngOnInit(): void { + this.blockUI.resetGlobal(); + } + + onLogin(): void { + this.blockUI.start(translate(TEXT('Please wait ...'))); + const values = this.form.values; + this.authService + .login(values.username, values.password) + .pipe(finalize(() => this.blockUI.stop())) + .subscribe(() => { + this.router.navigate(['/dashboard']); + }); + } +} diff --git a/src/glass/src/app/pages/pages.module.ts b/src/glass/src/app/pages/pages.module.ts index c4f481821..10829faf3 100644 --- a/src/glass/src/app/pages/pages.module.ts +++ b/src/glass/src/app/pages/pages.module.ts @@ -11,8 +11,10 @@ import { DashboardPageComponent } from '~/app/pages/dashboard-page/dashboard-pag import { HostsPageComponent } from '~/app/pages/hosts-page/hosts-page.component'; import { InstallModePageComponent } from '~/app/pages/install-mode-page/install-mode-page.component'; import { InstallWizardModule } from '~/app/pages/install-wizard/install-wizard.module'; +import { LoginPageComponent } from '~/app/pages/login-page/login-page.component'; import { NotFoundPageComponent } from '~/app/pages/not-found-page/not-found-page.component'; import { ServicesPageComponent } from '~/app/pages/services-page/services-page.component'; +import { UsersPageComponent } from '~/app/pages/users-page/users-page.component'; import { WelcomePageComponent } from '~/app/pages/welcome-page/welcome-page.component'; import { SharedModule } from '~/app/shared/shared.module'; @@ -23,7 +25,9 @@ import { SharedModule } from '~/app/shared/shared.module'; WelcomePageComponent, NotFoundPageComponent, ServicesPageComponent, - HostsPageComponent + HostsPageComponent, + LoginPageComponent, + UsersPageComponent ], imports: [ CommonModule, diff --git a/src/glass/src/app/pages/services-page/services-page.component.ts b/src/glass/src/app/pages/services-page/services-page.component.ts index 3cb9c3cd2..e7fc0753a 100644 --- a/src/glass/src/app/pages/services-page/services-page.component.ts +++ b/src/glass/src/app/pages/services-page/services-page.component.ts @@ -118,10 +118,11 @@ export class ServicesPageComponent { name: 'key', label: TEXT('Key'), value: auth.key, - readonly: true + readonly: true, + hasCopyToClipboardButton: true } ], - okButtonVisible: false, + submitButtonVisible: false, cancelButtonText: TEXT('Close') } }); @@ -209,7 +210,7 @@ export class ServicesPageComponent { class: 'glass-text-monospaced' } ], - okButtonVisible: false, + submitButtonVisible: false, cancelButtonText: TEXT('Close') } }); diff --git a/src/glass/src/app/pages/users-page/users-page.component.html b/src/glass/src/app/pages/users-page/users-page.component.html new file mode 100644 index 000000000..ca132d5fb --- /dev/null +++ b/src/glass/src/app/pages/users-page/users-page.component.html @@ -0,0 +1,23 @@ +
+
+ +
+
+ + + + + + +
+
diff --git a/src/glass/src/app/pages/users-page/users-page.component.scss b/src/glass/src/app/pages/users-page/users-page.component.scss new file mode 100644 index 000000000..ce580db9b --- /dev/null +++ b/src/glass/src/app/pages/users-page/users-page.component.scss @@ -0,0 +1,17 @@ +@import 'styles.scss'; + +.glass-users-page { + margin: 24px; + + .glass-users-page-actions { + margin-bottom: 8px; + button { + @extend .glass-color-theme-primary; + } + } + .glass-users-page-content { + .mat-card { + padding: unset; + } + } +} diff --git a/src/glass/src/app/pages/users-page/users-page.component.spec.ts b/src/glass/src/app/pages/users-page/users-page.component.spec.ts new file mode 100644 index 000000000..1526dbf7f --- /dev/null +++ b/src/glass/src/app/pages/users-page/users-page.component.spec.ts @@ -0,0 +1,34 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; + +import { PagesModule } from '~/app/pages/pages.module'; +import { UsersPageComponent } from '~/app/pages/users-page/users-page.component'; + +describe('UsersPageComponent', () => { + let component: UsersPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UsersPageComponent], + imports: [ + HttpClientTestingModule, + NoopAnimationsModule, + PagesModule, + TranslateModule.forRoot() + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/glass/src/app/pages/users-page/users-page.component.ts b/src/glass/src/app/pages/users-page/users-page.component.ts new file mode 100644 index 000000000..3345f6e2c --- /dev/null +++ b/src/glass/src/app/pages/users-page/users-page.component.ts @@ -0,0 +1,210 @@ +import { Component } from '@angular/core'; +import { marker as TEXT } from '@biesbjerg/ngx-translate-extract-marker'; +import { BlockUI, NgBlockUI } from 'ng-block-ui'; +import { finalize } from 'rxjs/operators'; + +import { DeclarativeFormModalComponent } from '~/app/core/modals/declarative-form/declarative-form-modal.component'; +import { translate } from '~/app/i18n.helper'; +import { DialogComponent } from '~/app/shared/components/dialog/dialog.component'; +import { DatatableActionItem } from '~/app/shared/models/datatable-action-item.type'; +import { DatatableColumn } from '~/app/shared/models/datatable-column.type'; +import { DatatableData } from '~/app/shared/models/datatable-data.type'; +import { DeclarativeFormModalConfig } from '~/app/shared/models/declarative-form-modal-config.type'; +import { User, UserService } from '~/app/shared/services/api/user.service'; +import { DialogService } from '~/app/shared/services/dialog.service'; + +@Component({ + selector: 'glass-users-page', + templateUrl: './users-page.component.html', + styleUrls: ['./users-page.component.scss'] +}) +export class UsersPageComponent { + @BlockUI() + blockUI!: NgBlockUI; + + loading = false; + firstLoadComplete = false; + data: User[] = []; + columns: DatatableColumn[]; + + constructor(private dialogService: DialogService, private userService: UserService) { + this.columns = [ + { + name: TEXT('Name'), + prop: 'username', + sortable: true + }, + { + name: TEXT('Full Name'), + prop: 'full_name', + sortable: true + }, + { + name: TEXT('Disabled'), + prop: 'disabled', + sortable: true, + cellTemplateName: 'checkIcon' + }, + { + name: '', + prop: '', + cellTemplateName: 'actionMenu', + cellTemplateConfig: this.onActionMenu.bind(this) + } + ]; + } + + loadData(): void { + this.loading = true; + this.userService.list().subscribe((data) => { + this.data = data; + this.loading = this.firstLoadComplete = true; + }); + } + + onAdd(): void { + this.dialogService.open( + DeclarativeFormModalComponent, + (res: User | boolean) => { + if (res) { + this.blockUI.start(translate(TEXT('Please wait, creating user ...'))); + this.userService + .create(res as User) + .pipe(finalize(() => this.blockUI.stop())) + .subscribe(() => { + this.loadData(); + }); + } + }, + { + width: '40%', + data: { + title: TEXT('Add User'), + fields: [ + { + type: 'text', + label: TEXT('Name'), + name: 'username', + value: '', + validators: { + required: true + } + }, + { + type: 'text', + label: TEXT('Full Name'), + name: 'full_name', + value: '' + }, + { + type: 'password', + label: TEXT('Password'), + name: 'password', + value: '', + validators: { + required: true + } + }, + { + type: 'checkbox', + label: TEXT('Disabled'), + name: 'disabled', + value: false, + hint: TEXT('Temporarily prohibit the user from logging in.') + } + ], + submitButtonText: TEXT('Add') + } as DeclarativeFormModalConfig + } + ); + } + + onActionMenu(user: User): DatatableActionItem[] { + const result: DatatableActionItem[] = [ + { + title: TEXT('Edit'), + callback: (data: DatatableData) => { + this.dialogService.open( + DeclarativeFormModalComponent, + (res: User | boolean) => { + if (res) { + this.blockUI.start(translate(TEXT('Please wait, updating user ...'))); + this.userService + .update((res as User).username, res as User) + .pipe(finalize(() => this.blockUI.stop())) + .subscribe(() => { + this.loadData(); + }); + } + }, + { + width: '40%', + data: { + title: TEXT('Edit User'), + fields: [ + { + type: 'text', + label: TEXT('Name'), + name: 'username', + value: user.username, + readonly: true + }, + { + type: 'text', + label: TEXT('Full Name'), + name: 'full_name', + value: user.full_name + }, + { + type: 'password', + label: TEXT('Password'), + name: 'password', + value: '' + }, + { + type: 'checkbox', + label: TEXT('Disabled'), + name: 'disabled', + value: user.disabled, + hint: TEXT('Temporarily prohibit the user from logging in.') + } + ], + submitButtonText: TEXT('Edit') + } as DeclarativeFormModalConfig + } + ); + } + }, + { + title: TEXT('Delete'), + callback: (data: DatatableData) => { + this.dialogService.open( + DialogComponent, + (res: boolean) => { + if (res) { + this.blockUI.start(translate(TEXT('Please wait, deleting user ...'))); + this.userService + .delete(data.username) + .pipe(finalize(() => this.blockUI.stop())) + .subscribe(() => { + this.loadData(); + }); + } + }, + { + width: '40%', + data: { + type: 'yesNo', + icon: 'question', + message: TEXT( + `Do you really want to delete user ${data.username}?` + ) + } + } + ); + } + } + ]; + return result; + } +} diff --git a/src/glass/src/app/shared/components/datatable/datatable.component.html b/src/glass/src/app/shared/components/datatable/datatable.component.html index 52e00aaf8..125f97ac3 100644 --- a/src/glass/src/app/shared/components/datatable/datatable.component.html +++ b/src/glass/src/app/shared/components/datatable/datatable.component.html @@ -48,7 +48,7 @@ - diff --git a/src/glass/src/app/shared/components/declarative-form/declarative-form.component.html b/src/glass/src/app/shared/components/declarative-form/declarative-form.component.html index 5fcd00084..7d5b4d3c0 100644 --- a/src/glass/src/app/shared/components/declarative-form/declarative-form.component.html +++ b/src/glass/src/app/shared/components/declarative-form/declarative-form.component.html @@ -9,85 +9,172 @@

- - {{ field.label | translate }} - - - - - - - - - - - - {{ field.hint | translate }} - - - - This field is required. - - - translate - The value must be at least {{ field.validators?.min }}. - - - The value cannot exceed {{ field.validators?.max }}. - - - translate - The value is invalid. - - - This field must be an IP address or FQDN. - - - + + + + + {{ field.label | translate }} + + + + + + + + This field is required. + + + translate + The value is invalid. + + + + + + + + + + {{ field.label | translate }} + + + + + + This field is required. + + + translate + The value is invalid. + + + This field must be an IP address or FQDN. + + + + + + + + + + {{ field.label | translate }} + + + + + + This field is required. + + + translate + The value must be at least {{ field.validators?.min }}. + + + The value cannot exceed {{ field.validators?.max }}. + + + + + + + + + + {{ field.label | translate }} + + + + + This field is required. + + + + + + + + +
+ + {{ field.label! | translate }} + + + + This field is required. + + + + +
+
+ +
diff --git a/src/glass/src/app/shared/components/declarative-form/declarative-form.component.ts b/src/glass/src/app/shared/components/declarative-form/declarative-form.component.ts index 160e1c1d0..b5b804329 100644 --- a/src/glass/src/app/shared/components/declarative-form/declarative-form.component.ts +++ b/src/glass/src/app/shared/components/declarative-form/declarative-form.component.ts @@ -73,18 +73,9 @@ export class DeclarativeFormComponent implements OnInit { ngOnInit(): void { _.forEach(this.config?.fields, (field: FormFieldConfig) => { - switch (field.type) { - case 'password': - _.defaultsDeep(field, { - hasCopyToClipboardButton: true - }); - break; - default: - _.defaultsDeep(field, { - hasCopyToClipboardButton: false - }); - break; - } + _.defaultsDeep(field, { + hasCopyToClipboardButton: false + }); }); this.formGroup = this.createForm(); } diff --git a/src/glass/src/app/shared/components/dialog/dialog.component.html b/src/glass/src/app/shared/components/dialog/dialog.component.html index 9adc58793..ac98a52bb 100644 --- a/src/glass/src/app/shared/components/dialog/dialog.component.html +++ b/src/glass/src/app/shared/components/dialog/dialog.component.html @@ -1,9 +1,9 @@ -
- {{ config.title | translate }} +
{{ config.title | translate }}
diff --git a/src/glass/src/app/shared/components/submit-button/submit-button.component.html b/src/glass/src/app/shared/components/submit-button/submit-button.component.html index 725bd510b..41fda250b 100644 --- a/src/glass/src/app/shared/components/submit-button/submit-button.component.html +++ b/src/glass/src/app/shared/components/submit-button/submit-button.component.html @@ -1,6 +1,7 @@ diff --git a/src/glass/src/app/shared/components/submit-button/submit-button.component.ts b/src/glass/src/app/shared/components/submit-button/submit-button.component.ts index 5231c78e1..3d3f97ade 100644 --- a/src/glass/src/app/shared/components/submit-button/submit-button.component.ts +++ b/src/glass/src/app/shared/components/submit-button/submit-button.component.ts @@ -9,7 +9,10 @@ import * as _ from 'lodash'; }) export class SubmitButtonComponent implements OnInit { @Input() - form?: FormGroup; + formId?: string; + + @Input() + form?: FormGroup | undefined; @Output() buttonClick = new EventEmitter(); @@ -20,19 +23,16 @@ export class SubmitButtonComponent implements OnInit { onSubmit(event: Event) { if (this.form && this.form.invalid) { - if (this.form instanceof AbstractControl) { - // Process all invalid controls and update them to draw the - // as invalid. - _.forEach>( - this.form.controls, - (control: AbstractControl, key: string) => { - if (control.invalid) { - control.markAllAsTouched(); - control.updateValueAndValidity(); - } + // Process all invalid controls and update them to draw the + _.forEach>( + this.form.controls, + (control: AbstractControl, key: string) => { + if (control.invalid) { + control.markAllAsTouched(); + control.updateValueAndValidity(); } - ); - } + } + ); return; } this.buttonClick.emit(event); diff --git a/src/glass/src/app/shared/models/declarative-form-config.type.ts b/src/glass/src/app/shared/models/declarative-form-config.type.ts index 800345dae..156b9412c 100644 --- a/src/glass/src/app/shared/models/declarative-form-config.type.ts +++ b/src/glass/src/app/shared/models/declarative-form-config.type.ts @@ -1,6 +1,6 @@ export type FormFieldConfig = { name: string; - type: 'text' | 'number' | 'password' | 'token'; + type: 'text' | 'number' | 'password' | 'token' | 'checkbox'; label?: string; value?: any; readonly?: boolean; @@ -21,6 +21,7 @@ export type FormFieldConfig = { }; export type DeclarativeFormConfig = { + id?: string; // A unique form ID. hint?: string; subtitle?: string; fields: FormFieldConfig[]; diff --git a/src/glass/src/app/shared/models/declarative-form-modal-config.type.ts b/src/glass/src/app/shared/models/declarative-form-modal-config.type.ts index 966151328..8c99b7ccd 100644 --- a/src/glass/src/app/shared/models/declarative-form-modal-config.type.ts +++ b/src/glass/src/app/shared/models/declarative-form-modal-config.type.ts @@ -3,10 +3,9 @@ import { DeclarativeFormConfig } from '~/app/shared/models/declarative-form-conf export type DeclarativeFormModalConfig = DeclarativeFormConfig & { title: string; subtitle?: string; - okButtonVisible?: boolean; // Defaults to `true` - okButtonText?: string; // Defaults to `OK` - okButtonResult?: any; // Defaults to form values - okButtonClass?: string; + submitButtonVisible?: boolean; // Defaults to `true` + submitButtonText?: string; // Defaults to `OK` + submitButtonResult?: any; // Defaults to form values cancelButtonVisible?: boolean; // Defaults to `true` cancelButtonText?: string; // Defaults to `Cancel` cancelButtonResult?: any; // Defaults to `false` diff --git a/src/glass/src/app/shared/services/api/auth.service.spec.ts b/src/glass/src/app/shared/services/api/auth.service.spec.ts new file mode 100644 index 000000000..dceac4b7c --- /dev/null +++ b/src/glass/src/app/shared/services/api/auth.service.spec.ts @@ -0,0 +1,33 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + service = TestBed.inject(AuthService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call login', () => { + service.login('test', '01234').subscribe(); + const req = httpTesting.expectOne('api/auth/login'); + expect(req.request.method).toBe('POST'); + }); + + it('should call logout', () => { + service.logout().subscribe(); + const req = httpTesting.expectOne('api/auth/logout'); + expect(req.request.method).toBe('POST'); + }); +}); diff --git a/src/glass/src/app/shared/services/api/auth.service.ts b/src/glass/src/app/shared/services/api/auth.service.ts new file mode 100644 index 000000000..4b1fe6136 --- /dev/null +++ b/src/glass/src/app/shared/services/api/auth.service.ts @@ -0,0 +1,62 @@ +/* + * Project Aquarium's frontend (glass) + * Copyright (C) 2021 SUSE, LLC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +export type LoginReply = { + access_token: string; + token_type: string; +}; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private url = 'api/auth'; + + constructor(private http: HttpClient, private authStorageService: AuthStorageService) {} + + public login(username: string, password: string): Observable { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization + const credentials = btoa(`${username}:${password}`); + const headers = new HttpHeaders({ + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }); + const body = new HttpParams() + .set('username', username) + .set('password', password) + .set('grant_type', 'password'); + return this.http.post(`${this.url}/login`, body, { headers }).pipe( + tap((resp: LoginReply) => { + this.authStorageService.set(username); + }) + ); + } + + logout(): Observable { + return this.http.post(`${this.url}/logout`, null).pipe( + tap(() => { + this.authStorageService.revoke(); + document.location.replace(''); + }) + ); + } +} diff --git a/src/glass/src/app/shared/services/api/user.service.spec.ts b/src/glass/src/app/shared/services/api/user.service.spec.ts new file mode 100644 index 000000000..1e119c0c9 --- /dev/null +++ b/src/glass/src/app/shared/services/api/user.service.spec.ts @@ -0,0 +1,53 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { UserService } from '~/app/shared/services/api/user.service'; + +describe('UserService', () => { + let service: UserService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + service = TestBed.inject(UserService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/user/'); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + /* eslint-disable @typescript-eslint/naming-convention */ + service + .create({ + username: 'foo', + password: 'test123', + full_name: 'foo bar', + disabled: true + }) + .subscribe(); + const req = httpTesting.expectOne('api/user/create'); + expect(req.request.method).toBe('POST'); + }); + + it('should call delete', () => { + service.delete('foo').subscribe(); + const req = httpTesting.expectOne('api/user/foo'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call update', () => { + service.update('foo', { full_name: 'baz' }).subscribe(); + const req = httpTesting.expectOne('api/user/foo'); + expect(req.request.method).toBe('PATCH'); + }); +}); diff --git a/src/glass/src/app/shared/services/api/user.service.ts b/src/glass/src/app/shared/services/api/user.service.ts new file mode 100644 index 000000000..9d1fbaa62 --- /dev/null +++ b/src/glass/src/app/shared/services/api/user.service.ts @@ -0,0 +1,49 @@ +/* + * Project Aquarium's frontend (glass) + * Copyright (C) 2021 SUSE, LLC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +export type User = { + username: string; + password: string; + full_name: string; + disabled: boolean; +}; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + private url = 'api/user'; + constructor(private http: HttpClient) {} + + public list(): Observable { + return this.http.get(`${this.url}/`); + } + + public create(user: User): Observable { + return this.http.post(`${this.url}/create`, user); + } + + public delete(username: string): Observable { + return this.http.delete(`${this.url}/${username}`); + } + + public update(username: string, user: Partial): Observable { + return this.http.patch(`${this.url}/${username}`, user); + } +} diff --git a/src/glass/src/app/shared/services/auth-guard.service.spec.ts b/src/glass/src/app/shared/services/auth-guard.service.spec.ts new file mode 100644 index 000000000..8724aa5bf --- /dev/null +++ b/src/glass/src/app/shared/services/auth-guard.service.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { AuthGuardService } from './auth-guard.service'; + +describe('AuthGuardService', () => { + let service: AuthGuardService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule] + }); + service = TestBed.inject(AuthGuardService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/glass/src/app/shared/services/auth-guard.service.ts b/src/glass/src/app/shared/services/auth-guard.service.ts new file mode 100644 index 000000000..6ea8c4459 --- /dev/null +++ b/src/glass/src/app/shared/services/auth-guard.service.ts @@ -0,0 +1,46 @@ +/* + * Project Aquarium's frontend (glass) + * Copyright (C) 2021 SUSE, LLC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + Router, + RouterStateSnapshot +} from '@angular/router'; + +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthGuardService implements CanActivate, CanActivateChild { + constructor(private authStorageService: AuthStorageService, private router: Router) {} + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + if (this.authStorageService.isLoggedIn()) { + return true; + } + this.router.navigate( + ['/login'], + state.url === '/' ? undefined : { queryParams: { returnUrl: state.url } } + ); + return false; + } + + canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + return this.canActivate(childRoute, state); + } +} diff --git a/src/glass/src/app/shared/services/auth-storage.service.spec.ts b/src/glass/src/app/shared/services/auth-storage.service.spec.ts new file mode 100644 index 000000000..680067c58 --- /dev/null +++ b/src/glass/src/app/shared/services/auth-storage.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthStorageService } from './auth-storage.service'; + +describe('AuthStorageService', () => { + let service: AuthStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthStorageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/glass/src/app/shared/services/auth-storage.service.ts b/src/glass/src/app/shared/services/auth-storage.service.ts new file mode 100644 index 000000000..e8cb102a0 --- /dev/null +++ b/src/glass/src/app/shared/services/auth-storage.service.ts @@ -0,0 +1,39 @@ +/* + * Project Aquarium's frontend (glass) + * Copyright (C) 2021 SUSE, LLC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ +import { Injectable } from '@angular/core'; +import * as _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthStorageService { + constructor() {} + + set(username: string): void { + localStorage.setItem('username', username); + } + + getUsername(): string | null { + return localStorage.getItem('username'); + } + + revoke(): void { + localStorage.removeItem('username'); + } + + isLoggedIn() { + return !_.isNull(this.getUsername()); + } +} diff --git a/src/glass/src/app/shared/services/dialog.service.ts b/src/glass/src/app/shared/services/dialog.service.ts index c3f16c337..d2f8d3fe8 100644 --- a/src/glass/src/app/shared/services/dialog.service.ts +++ b/src/glass/src/app/shared/services/dialog.service.ts @@ -21,12 +21,8 @@ export class DialogService { } open(component: ComponentType, onClose?: (result: any) => void, config?: MatDialogConfig) { - if (config) { - config.width = '60%'; - } else { - config = { width: '60%' }; - } - + config = _.defaultTo(config, {}); + _.defaultsDeep(config, { width: '60%' }); const ref = this.dialog.open(component, config); ref.afterClosed().subscribe({ next: (result) => { diff --git a/src/glass/src/app/shared/services/http-error-interceptor.service.spec.ts b/src/glass/src/app/shared/services/http-error-interceptor.service.spec.ts index 06b966106..57b551a13 100644 --- a/src/glass/src/app/shared/services/http-error-interceptor.service.spec.ts +++ b/src/glass/src/app/shared/services/http-error-interceptor.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { ToastrModule } from 'ngx-toastr'; import { HttpErrorInterceptorService } from '~/app/shared/services/http-error-interceptor.service'; @@ -9,7 +10,7 @@ describe('HttpErrorInterceptorService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [SharedModule, ToastrModule.forRoot()] + imports: [RouterTestingModule, SharedModule, ToastrModule.forRoot()] }); service = TestBed.inject(HttpErrorInterceptorService); }); diff --git a/src/glass/src/app/shared/services/http-error-interceptor.service.ts b/src/glass/src/app/shared/services/http-error-interceptor.service.ts index 46a58e673..4adcc47fa 100644 --- a/src/glass/src/app/shared/services/http-error-interceptor.service.ts +++ b/src/glass/src/app/shared/services/http-error-interceptor.service.ts @@ -1,3 +1,17 @@ +/* + * Project Aquarium's frontend (glass) + * Copyright (C) 2021 SUSE, LLC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ import { HttpErrorResponse, HttpEvent, @@ -6,32 +20,45 @@ import { HttpRequest } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import * as _ from 'lodash'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { NotificationService } from '~/app/shared/services/notification.service'; @Injectable({ providedIn: 'root' }) export class HttpErrorInterceptorService implements HttpInterceptor { - constructor(private notificationService: NotificationService) {} + constructor( + private authStorageService: AuthStorageService, + private notificationService: NotificationService, + private router: Router + ) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { return next.handle(request).pipe( catchError((err) => { if (err instanceof HttpErrorResponse) { - const notificationId: number = this.notificationService.show(err.message, { - type: 'error' - }); - - /** - * Decorate preventDefault method. If called, it will prevent a - * notification to be shown. - */ - (err as any).preventDefault = () => { - this.notificationService.cancel(notificationId); - }; + switch (err.status) { + case 401: + this.authStorageService.revoke(); + this.router.navigate(['/login']); + break; + default: + const message = _.get(err, 'error.detail', err.message); + const notificationId: number = this.notificationService.show(message, { + type: 'error' + }); + // Decorate preventDefault method. If called, it will prevent a + // notification to be shown. + (err as any).preventDefault = () => { + this.notificationService.cancel(notificationId); + }; + break; + } } return throwError(err); }) diff --git a/src/gravel/api/__init__.py b/src/gravel/api/__init__.py index 6e9bbe36f..d8c510025 100644 --- a/src/gravel/api/__init__.py +++ b/src/gravel/api/__init__.py @@ -1,2 +1,69 @@ +# project aquarium's backend +# Copyright (C) 2021 SUSE, LLC. # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from fastapi import HTTPException, Request +from fastapi.security import OAuth2PasswordBearer +from typing import Optional + +from gravel.controllers.auth import ( + JWTDenyList, + JWTMgr, + JWT, + UserMgr, + UserModel +) + + +class JWTAuthSchema(OAuth2PasswordBearer): + def __init__(self): + super().__init__(tokenUrl="auth/login") + + async def __call__(self, request: Request) -> Optional[JWT]: # type: ignore[override] + state = request.app.state + # Disable authentication as long as the node is not ready. + if not state.nodemgr.deployment_state.ready: + return None + # Get and validate the token. + token = JWTMgr.get_token_from_cookie(request) + if token is None: + # Fallback: Try to get it from the headers. + token = await super().__call__(request) + # Decode token and do the following checks: + jwt_mgr = JWTMgr(state.gstate.config.options.auth) + raw_token: JWT = jwt_mgr.get_raw_access_token(token) + # - Is the token revoked? + deny_list = JWTDenyList(state.gstate.store) + await deny_list.load() + if deny_list.includes(raw_token): + raise HTTPException( + status_code=401, + detail="Token has been revoked" + ) + # - Does the user exist? + user_mgr = UserMgr(state.gstate.store) + user: Optional[UserModel] = await user_mgr.get(str(raw_token.sub)) + if user is None: + raise HTTPException( + status_code=401, + detail="User does not exist" + ) + # - Is user disabled? + if user.disabled: + raise HTTPException( + status_code=401, + detail="User is disabled" + ) + return raw_token + + +jwt_auth_scheme = JWTAuthSchema() diff --git a/src/gravel/api/auth.py b/src/gravel/api/auth.py new file mode 100644 index 000000000..353ffa63b --- /dev/null +++ b/src/gravel/api/auth.py @@ -0,0 +1,65 @@ +# project aquarium's backend +# Copyright (C) 2021 SUSE, LLC. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from logging import Logger +from fastapi.logger import logger as fastapi_logger +from fastapi.routing import APIRouter +from fastapi import HTTPException, Request, Response, Depends, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, Field + +from gravel.api import jwt_auth_scheme +from gravel.controllers.auth import ( + JWTDenyList, + JWTMgr, + JWT, + UserMgr +) + + +logger: Logger = fastapi_logger + +router: APIRouter = APIRouter( + prefix="/auth", + tags=["auth"] +) + + +class LoginReplyModel(BaseModel): + access_token: str = Field(title="The access token") + token_type: str = Field("bearer") + + +@router.post("/login", response_model=LoginReplyModel) +async def login( + request: Request, response: Response, + form_data: OAuth2PasswordRequestForm = Depends() +) -> LoginReplyModel: + user_mgr = UserMgr(request.app.state.gstate.store) + authenticated = await user_mgr.authenticate(form_data.username, + form_data.password) + if not authenticated: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Bad username or password") + jwt_mgr = JWTMgr(request.app.state.gstate.config.options.auth) + access_token = jwt_mgr.create_access_token(subject=form_data.username) + jwt_mgr.set_token_cookie(response, access_token) + return LoginReplyModel(access_token=access_token) + + +@router.post("/logout") +async def logout(request: Request, token: JWT = Depends(jwt_auth_scheme)) -> None: + deny_list = JWTDenyList(request.app.state.gstate.store) + await deny_list.load() + deny_list.add(token) + await deny_list.save() diff --git a/src/gravel/api/devices.py b/src/gravel/api/devices.py index e393e55a4..7598db595 100644 --- a/src/gravel/api/devices.py +++ b/src/gravel/api/devices.py @@ -13,10 +13,11 @@ from logging import Logger from typing import Dict -from fastapi import Request +from fastapi import Depends, Request from fastapi.logger import logger as fastapi_logger from fastapi.routing import APIRouter +from gravel.api import jwt_auth_scheme from gravel.controllers.resources.devices import DeviceHostModel logger: Logger = fastapi_logger @@ -32,5 +33,8 @@ name="Obtain devices being used for storage, per host", response_model=Dict[str, DeviceHostModel] ) -def get_per_host_devices(request: Request) -> Dict[str, DeviceHostModel]: +def get_per_host_devices( + request: Request, + _=Depends(jwt_auth_scheme) +) -> Dict[str, DeviceHostModel]: return request.app.state.gstate.devices.devices_per_host diff --git a/src/gravel/api/local.py b/src/gravel/api/local.py index 6b683cb86..128b562b3 100644 --- a/src/gravel/api/local.py +++ b/src/gravel/api/local.py @@ -15,12 +15,13 @@ from typing import List from fastapi.logger import logger as fastapi_logger from fastapi.routing import APIRouter -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from pydantic import ( BaseModel, Field ) +from gravel.api import jwt_auth_scheme from gravel.cephadm.models import ( NodeInfoModel, VolumeDeviceModel @@ -47,7 +48,7 @@ class NodeStatusReplyModel(BaseModel): name="Obtain local volumes", response_model=List[VolumeDeviceModel] ) -async def get_volumes(request: Request) -> List[VolumeDeviceModel]: +async def get_volumes(request: Request, _=Depends(jwt_auth_scheme)) -> List[VolumeDeviceModel]: """ List this node's volumes. @@ -67,7 +68,7 @@ async def get_volumes(request: Request) -> List[VolumeDeviceModel]: name="Obtain local node information", response_model=NodeInfoModel ) -async def get_node_info(request: Request) -> NodeInfoModel: +async def get_node_info(request: Request, _=Depends(jwt_auth_scheme)) -> NodeInfoModel: """ Obtain this node's information and facts. @@ -87,7 +88,7 @@ async def get_node_info(request: Request) -> NodeInfoModel: name="Obtain local node inventory", response_model=NodeInfoModel ) -async def get_inventory(request: Request) -> NodeInfoModel: +async def get_inventory(request: Request, _=Depends(jwt_auth_scheme)) -> NodeInfoModel: """ Obtain this node's inventory. @@ -114,7 +115,7 @@ async def get_inventory(request: Request) -> NodeInfoModel: name="Obtain local node's status", response_model=NodeStatusReplyModel ) -async def get_status(request: Request) -> NodeStatusReplyModel: +async def get_status(request: Request, _=Depends(jwt_auth_scheme)) -> NodeStatusReplyModel: """ Obtain this node's current status. diff --git a/src/gravel/api/nfs.py b/src/gravel/api/nfs.py index a61f90bf8..6383214bb 100644 --- a/src/gravel/api/nfs.py +++ b/src/gravel/api/nfs.py @@ -14,12 +14,13 @@ from logging import Logger from typing import List, Optional -from fastapi import HTTPException, status, Request +from fastapi import Depends, HTTPException, status, Request from fastapi.logger import logger as fastapi_logger from fastapi.routing import APIRouter from pydantic import BaseModel +from gravel.api import jwt_auth_scheme from gravel.controllers.orch.nfs import \ NFSError, NFSBackingStoreEnum, \ NFSExport, NFSExportModel, \ @@ -55,7 +56,8 @@ class Response(BaseModel): name='create an nfs service', response_model=Response ) -async def service_create(request: Request, service_id: str, req: ServiceRequest) -> Response: +async def service_create(request: Request, service_id: str, req: ServiceRequest, + _=Depends(jwt_auth_scheme)) -> Response: try: mgr = request.app.state.gstate.ceph_mgr res = NFSService(mgr).create(service_id, placement=req.placement) @@ -69,7 +71,8 @@ async def service_create(request: Request, service_id: str, req: ServiceRequest) '/service/{service_id}', name='update an nfs service', response_model=Response) -async def service_update(request: Request, service_id: str, req: ServiceRequest) -> Response: +async def service_update(request: Request, service_id: str, req: ServiceRequest, + _=Depends(jwt_auth_scheme)) -> Response: try: mgr = request.app.state.gstate.ceph_mgr res = NFSService(mgr).update(service_id, req.placement if req.placement else '*') @@ -83,7 +86,8 @@ async def service_update(request: Request, service_id: str, req: ServiceRequest) '/service/{service_id}', name='delete an nfs service', response_model=Response) -async def service_delete(request: Request, service_id: str) -> Response: +async def service_delete(request: Request, service_id: str, + _=Depends(jwt_auth_scheme)) -> Response: try: mgr = request.app.state.gstate.ceph_mgr res = NFSService(mgr).delete(service_id) @@ -97,7 +101,7 @@ async def service_delete(request: Request, service_id: str) -> Response: '/service', name='list nfs service names', response_model=List[str]) -def get_service_ls(request: Request) -> List[str]: +def get_service_ls(request: Request, _=Depends(jwt_auth_scheme)) -> List[str]: mgr = request.app.state.gstate.ceph_mgr return NFSService(mgr).ls() @@ -106,7 +110,8 @@ def get_service_ls(request: Request) -> List[str]: '/service/{service_id}', name='nfs service detail', response_model=NFSServiceModel) -def get_service_info(request: Request, service_id: str) -> NFSServiceModel: +def get_service_info(request: Request, service_id: str, + _=Depends(jwt_auth_scheme)) -> NFSServiceModel: mgr = request.app.state.gstate.ceph_mgr try: for svc in NFSService(mgr).info(service_id=service_id): @@ -125,7 +130,8 @@ def get_service_info(request: Request, service_id: str) -> NFSServiceModel: async def export_create( request: Request, service_id: str, - req: ExportRequest) -> NFSExportModel: + req: ExportRequest, + _=Depends(jwt_auth_scheme)) -> NFSExportModel: try: mgr = request.app.state.gstate.ceph_mgr res: NFSExportModel = \ @@ -146,7 +152,8 @@ async def export_create( '/export/{service_id}/{export_id}', name='delete an nfs export', response_model=Response) -async def export_delete(request: Request, service_id: str, export_id: int) -> Response: +async def export_delete(request: Request, service_id: str, export_id: int, + _=Depends(jwt_auth_scheme)) -> Response: mgr = request.app.state.gstate.ceph_mgr try: res = NFSExport(mgr).delete(service_id, export_id) @@ -160,7 +167,8 @@ async def export_delete(request: Request, service_id: str, export_id: int) -> Re '/export/{service_id}', name='list nfs export ids', response_model=List[int]) -async def get_export_ls(request: Request, service_id: str) -> List[int]: +async def get_export_ls(request: Request, service_id: str, + _=Depends(jwt_auth_scheme)) -> List[int]: mgr = request.app.state.gstate.ceph_mgr try: res = NFSExport(mgr).ls(service_id) @@ -174,7 +182,8 @@ async def get_export_ls(request: Request, service_id: str) -> List[int]: '/export/{service_id}/{export_id}', name='nfs export detail', response_model=NFSExportModel) -async def get_export_info(request: Request, service_id: str, export_id: int) -> NFSExportModel: +async def get_export_info(request: Request, service_id: str, export_id: int, + _=Depends(jwt_auth_scheme)) -> NFSExportModel: mgr = request.app.state.gstate.ceph_mgr try: res = NFSExport(mgr).info(service_id) diff --git a/src/gravel/api/nodes.py b/src/gravel/api/nodes.py index c03381bdc..0496c8b08 100644 --- a/src/gravel/api/nodes.py +++ b/src/gravel/api/nodes.py @@ -13,11 +13,12 @@ from logging import Logger from typing import List, Optional -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from fastapi.logger import logger as fastapi_logger from fastapi.routing import APIRouter from pydantic import BaseModel, Field +from gravel.api import jwt_auth_scheme from gravel.controllers.nodes.conn import IncomingConnection from gravel.controllers.nodes.deployment import ( DeploymentErrorEnum, @@ -87,7 +88,7 @@ class SetHostnameRequest(BaseModel): @router.get("/deployment/disksolution", response_model=DiskSolution) -async def node_get_disk_solution(request: Request) -> DiskSolution: +async def node_get_disk_solution(request: Request, _=Depends(jwt_auth_scheme)) -> DiskSolution: """ Obtain the list of disks and a deployment solution, if possible. """ @@ -106,7 +107,8 @@ async def node_get_disk_solution(request: Request) -> DiskSolution: @router.post("/deployment/start", response_model=DeployStartReplyModel) async def node_deploy( request: Request, - req_params: DeployParamsModel + req_params: DeployParamsModel, + _=Depends(jwt_auth_scheme) ) -> DeployStartReplyModel: """ Start deploying this node. The host will be configured according to user @@ -153,7 +155,10 @@ async def node_deploy( @router.get("/deployment/status", response_model=DeployStatusReplyModel) -async def get_deployment_status(request: Request) -> DeployStatusReplyModel: +async def get_deployment_status( + request: Request, + _=Depends(jwt_auth_scheme) +) -> DeployStatusReplyModel: """ Get deployment status from this node. @@ -179,7 +184,7 @@ async def get_deployment_status(request: Request) -> DeployStatusReplyModel: @router.post("/deployment/finished", response_model=bool) -async def finish_deployment(request: Request) -> bool: +async def finish_deployment(request: Request, _=Depends(jwt_auth_scheme)) -> bool: """ Mark a deployment as finished. Triggers internal actions required for node operation. @@ -204,7 +209,8 @@ async def finish_deployment(request: Request) -> bool: @router.post("/join") -async def node_join(req: NodeJoinRequestModel, request: Request): +async def node_join(req: NodeJoinRequestModel, request: Request, + _=Depends(jwt_auth_scheme)): logger.debug(f"api > join {req.address} with {req.token}") if not req.address or not req.token: raise HTTPException( @@ -221,7 +227,7 @@ async def node_join(req: NodeJoinRequestModel, request: Request): @router.get("/token", response_model=TokenReplyModel) -async def nodes_get_token(request: Request): +async def nodes_get_token(request: Request, _=Depends(jwt_auth_scheme)): nodemgr = request.app.state.nodemgr token: Optional[str] = nodemgr.token return TokenReplyModel( diff --git a/src/gravel/api/orch.py b/src/gravel/api/orch.py index 9a151f484..d4c150280 100644 --- a/src/gravel/api/orch.py +++ b/src/gravel/api/orch.py @@ -14,10 +14,11 @@ from logging import Logger from fastapi.routing import APIRouter from fastapi.logger import logger as fastapi_logger -from fastapi import HTTPException, status, Request +from fastapi import Depends, HTTPException, status, Request from pydantic import BaseModel from typing import Dict, List +from gravel.api import jwt_auth_scheme from gravel.controllers.orch.models import OrchDevicesPerHostModel from gravel.controllers.orch.orchestrator \ import Orchestrator @@ -54,7 +55,7 @@ class HostsDevicesModel(BaseModel): @router.get("/hosts", response_model=List[HostModel]) -def get_hosts(request: Request) -> List[HostModel]: +def get_hosts(request: Request, _=Depends(jwt_auth_scheme)) -> List[HostModel]: orch = Orchestrator(request.app.state.gstate.ceph_mgr) orch_hosts = orch.host_ls() hosts: List[HostModel] = [] @@ -64,7 +65,7 @@ def get_hosts(request: Request) -> List[HostModel]: @router.get("/devices", response_model=Dict[str, HostsDevicesModel]) -def get_devices(request: Request) -> Dict[str, HostsDevicesModel]: +def get_devices(request: Request, _=Depends(jwt_auth_scheme)) -> Dict[str, HostsDevicesModel]: orch = Orchestrator(request.app.state.gstate.ceph_mgr) orch_devs_per_host: List[OrchDevicesPerHostModel] = orch.devices_ls() host_devs: Dict[str, HostsDevicesModel] = {} @@ -96,7 +97,7 @@ def get_devices(request: Request) -> Dict[str, HostsDevicesModel]: @router.get("/pubkey") -async def get_pubkey(request: Request) -> str: +async def get_pubkey(request: Request, _=Depends(jwt_auth_scheme)) -> str: try: orch = Orchestrator(request.app.state.gstate.ceph_mgr) return orch.get_public_key() diff --git a/src/gravel/api/services.py b/src/gravel/api/services.py index 43a04c578..f15676d78 100644 --- a/src/gravel/api/services.py +++ b/src/gravel/api/services.py @@ -15,9 +15,11 @@ from typing import Dict, List, Optional from fastapi.logger import logger as fastapi_logger from fastapi.routing import APIRouter -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from pydantic import BaseModel from pydantic.fields import Field + +from gravel.api import jwt_auth_scheme from gravel.controllers.orch.cephfs import ( CephFS, CephFSError, @@ -70,13 +72,13 @@ class CreateResponse(BaseModel): name="Obtain service constraints", response_model=ConstraintsModel ) -async def get_constraints(request: Request) -> ConstraintsModel: +async def get_constraints(request: Request, _=Depends(jwt_auth_scheme)) -> ConstraintsModel: services = request.app.state.gstate.services return services.constraints @router.get("/", response_model=List[ServiceModel]) -async def get_services(request: Request) -> List[ServiceModel]: +async def get_services(request: Request, _=Depends(jwt_auth_scheme)) -> List[ServiceModel]: services = request.app.state.gstate.services return services.ls() @@ -84,7 +86,11 @@ async def get_services(request: Request) -> List[ServiceModel]: @router.get("/get/{service_name}", name="Get service by name", response_model=ServiceModel) -async def get_service(service_name: str, request: Request) -> ServiceModel: +async def get_service( + service_name: str, + request: Request, + _=Depends(jwt_auth_scheme) +) -> ServiceModel: services = request.app.state.gstate.services try: return services.get(service_name) @@ -96,7 +102,8 @@ async def get_service(service_name: str, request: Request) -> ServiceModel: @router.post("/check-requirements", response_model=RequirementsResponse) async def check_requirements( requirements: RequirementsRequest, - request: Request + request: Request, + _=Depends(jwt_auth_scheme) ) -> RequirementsResponse: size: int = requirements.size @@ -116,7 +123,8 @@ async def check_requirements( @router.post("/create", response_model=CreateResponse) async def create_service( req: CreateRequest, - request: Request + request: Request, + _=Depends(jwt_auth_scheme) ) -> CreateResponse: services = request.app.state.gstate.services @@ -146,7 +154,10 @@ async def create_service( name="Obtain services statistics", response_model=Dict[str, ServiceStorageModel] ) -async def get_statistics(request: Request) -> Dict[str, ServiceStorageModel]: +async def get_statistics( + request: Request, + _=Depends(jwt_auth_scheme) +) -> Dict[str, ServiceStorageModel]: """ Returns a dictionary of service names to a dictionary containing the allocated space for said service and how much space is being used, along @@ -171,6 +182,7 @@ async def get_authorization( request: Request, name: str, clientid: Optional[str] = None, + _=Depends(jwt_auth_scheme) ) -> CephFSAuthorizationModel: """ Obtain authorization credentials for a given service `name`. In case of diff --git a/src/gravel/api/status.py b/src/gravel/api/status.py index a81accb36..01820061c 100644 --- a/src/gravel/api/status.py +++ b/src/gravel/api/status.py @@ -14,11 +14,12 @@ from logging import Logger from typing import Optional from pathlib import Path -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from fastapi.routing import APIRouter from fastapi.logger import logger as fastapi_logger from pydantic import BaseModel, Field +from gravel.api import jwt_auth_scheme from gravel.controllers.orch.models import CephStatusModel from gravel.controllers.resources.status import ( CephStatusNotAvailableError, @@ -41,7 +42,7 @@ class StatusModel(BaseModel): @router.get("/", response_model=StatusModel) -async def get_status(request: Request) -> StatusModel: +async def get_status(request: Request, _=Depends(jwt_auth_scheme)) -> StatusModel: status_ctrl: Status = request.app.state.gstate.status cluster: Optional[CephStatusModel] = None @@ -59,7 +60,7 @@ async def get_status(request: Request) -> StatusModel: @router.get("/logs") -async def get_logs() -> str: +async def get_logs(_=Depends(jwt_auth_scheme)) -> str: logfile: Path = Path("/tmp/aquarium.log") if not logfile.exists(): @@ -72,7 +73,10 @@ async def get_logs() -> str: name="Obtain Client I/O rates for the cluster and individual services", response_model=OverallClientIORateModel ) -async def get_client_io_rates(request: Request) -> OverallClientIORateModel: +async def get_client_io_rates( + request: Request, + _=Depends(jwt_auth_scheme) +) -> OverallClientIORateModel: """ Obtain the cluster's overal IO rates, and service-specific IO rates. diff --git a/src/gravel/api/user.py b/src/gravel/api/user.py new file mode 100644 index 000000000..11050d6ea --- /dev/null +++ b/src/gravel/api/user.py @@ -0,0 +1,91 @@ +# project aquarium's backend +# Copyright (C) 2021 SUSE, LLC. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from logging import Logger +from fastapi.logger import logger as fastapi_logger +from fastapi.routing import APIRouter +from fastapi import Depends, HTTPException, Request, status +from typing import List + +from gravel.api import jwt_auth_scheme +from gravel.controllers.auth import JWT, UserMgr, UserModel + + +logger: Logger = fastapi_logger + +router: APIRouter = APIRouter( + prefix="/user", + tags=["user"] +) + + +@router.get("/", name="Get list of users", response_model=List[UserModel]) +async def enumerate_users(request: Request, + _=Depends(jwt_auth_scheme)) -> List[UserModel]: + user_mgr = UserMgr(request.app.state.gstate.store) + return await user_mgr.enumerate() + + +@router.post("/create", name="Create a new user") +async def create_user(user: UserModel, + request: Request, + _=Depends(jwt_auth_scheme)) -> None: + user_mgr = UserMgr(request.app.state.gstate.store) + if await user_mgr.exists(user.username): + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="User already exists") + user.hash_password() + await user_mgr.put(user) + + +@router.get("/{username}", name="Get a user by name", response_model=UserModel) +async def get_user(username: str, + request: Request, + _=Depends(jwt_auth_scheme)) -> UserModel: + user_mgr = UserMgr(request.app.state.gstate.store) + if not await user_mgr.exists(username): + raise HTTPException(status.HTTP_404_NOT_FOUND, + detail="User does not exist") + return await user_mgr.get(username) # type: ignore[return-value] + + +@router.delete("/{username}", name="Delete a user by name") +async def delete_user(username: str, + request: Request, + token: JWT = Depends(jwt_auth_scheme)) -> None: + user_mgr = UserMgr(request.app.state.gstate.store) + if not await user_mgr.exists(username): + raise HTTPException(status.HTTP_404_NOT_FOUND, + detail="User does not exist") + if token.sub == username: + raise HTTPException(status.HTTP_400_BAD_REQUEST, + detail="Cannot delete current user") + await user_mgr.remove(username) + + +@router.patch("/{username}", name="Update a user by name", response_model=UserModel) +async def patch_user(username: str, + update_user: UserModel, + request: Request, + _=Depends(jwt_auth_scheme)) -> UserModel: + user_mgr = UserMgr(request.app.state.gstate.store) + user = await user_mgr.get(username) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, + detail="User does not exist") + if update_user.password: + update_user.hash_password() + update_data = update_user.dict(exclude_unset=True, exclude_defaults=True) + user = user.copy(update=update_data) + await user_mgr.put(user) + return user diff --git a/src/gravel/controllers/auth.py b/src/gravel/controllers/auth.py new file mode 100644 index 000000000..a68dfac6c --- /dev/null +++ b/src/gravel/controllers/auth.py @@ -0,0 +1,167 @@ +# project aquarium's backend +# Copyright (C) 2021 SUSE, LLC. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +import bcrypt +import json +import jwt +import uuid + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Union, NamedTuple + +from fastapi import Request, Response +from fastapi.security.utils import get_authorization_scheme_param +from pydantic import BaseModel, Field + +from gravel.controllers.config import AuthOptionsModel +from gravel.controllers.kv import KV + + +class UserModel(BaseModel): + username: str = Field("", title="The user name") + password: str = Field("", title="The password") + full_name: str = Field("", title="The full name of the user") + disabled: bool = Field(False, title="Is the user disabled?") + + def hash_password(self) -> None: + salt = bcrypt.gensalt() + self.password = bcrypt.hashpw(self.password.encode("utf8"), salt).decode("utf8") + + def verify_password(self, password) -> bool: + result = bcrypt.checkpw(password.encode("utf8"), self.password.encode("utf8")) + return result + + +class UserMgr: + def __init__(self, store: KV): + self._store: KV = store + + async def enumerate(self) -> List[UserModel]: + values = await self._store.get_prefix("/auth/user/") + return [UserModel.parse_raw(value) for value in values] + + async def exists(self, username: str) -> bool: + user = await self.get(username) + return user is not None + + async def get(self, username: str) -> Optional[UserModel]: + value: Optional[str] = await self._store.get(f"/auth/user/{username}") + if value is None: + return None + user = UserModel.parse_raw(value) + return user + + async def put(self, user: UserModel) -> None: + await self._store.put(f"/auth/user/{user.username}", user.json()) + + async def remove(self, username: str) -> None: + await self._store.rm(f"/auth/user/{username}") + + async def authenticate(self, username: str, password: str) -> bool: + user: Optional[UserModel] = await self.get(username) + if user is None: + return False + if user.disabled: + return False + return user.verify_password(password) + + +class JWT(NamedTuple): + iss: str # Issuer + sub: Union[str, int] # Subject + iat: int # Issued at + nbf: int # Not before + exp: int # Expiration time + jti: str # JWT ID + + +class JWTMgr: + JWT_ISSUER = "Aquarium" + JWT_ALGORITHM = "HS256" + COOKIE_KEY = "access_token" + + def __init__(self, config: AuthOptionsModel): + self._config: AuthOptionsModel = config + + def create_access_token(self, subject: Union[str, int]) -> str: + now = int(datetime.now(timezone.utc).timestamp()) + payload = JWT( + iss=self.JWT_ISSUER, + sub=subject, + iat=now, + nbf=now, + exp=now + self._config.jwt_ttl, + jti=str(uuid.uuid4()) + )._asdict() + encoded_token = jwt.encode( + payload, + self._config.jwt_secret, + algorithm=self.JWT_ALGORITHM) + return encoded_token + + def get_raw_access_token(self, token, verify=True) -> JWT: + options = {} + if not verify: + options = {"verify_signature": False} + raw_token = jwt.decode(token, + self._config.jwt_secret, + algorithms=[self.JWT_ALGORITHM], + options=options) + return JWT(**raw_token) + + @staticmethod + def set_token_cookie(response: Response, token: str) -> None: + response.set_cookie(key=JWTMgr.COOKIE_KEY, value=f"Bearer {token}", + httponly=True, samesite="strict") + + @staticmethod + def get_token_from_cookie(request: Request) -> Optional[str]: + value: Optional[str] = request.cookies.get(JWTMgr.COOKIE_KEY) + if value is None: + return None + scheme, token = get_authorization_scheme_param(value) + if scheme.lower() == "bearer": + return token + return None + + +class JWTDenyList: + """ + This list contains JWT tokens that are not allowed to use anymore. + E.g. a token is added when a user logs out of the UI. The list will + automatically remove expired tokens. + """ + + def __init__(self, store: KV): + self._store: KV = store + self._jti_dict: Dict[str, int] = {} + + def _cleanup(self, now: int) -> None: + self._jti_dict = {jti: exp for jti, exp in self._jti_dict.items() if exp > now} + + async def load(self) -> None: + self._jti_dict = {} + value = await self._store.get("/auth/jwt_deny_list") + if value is not None: + self._jti_dict = json.loads(value) + now = int(datetime.now(timezone.utc).timestamp()) + self._cleanup(now) + + async def save(self) -> None: + await self._store.put("/auth/jwt_deny_list", json.dumps(self._jti_dict)) + + def add(self, token: JWT) -> None: + self._jti_dict[token.jti] = token.exp + + def includes(self, token: JWT) -> bool: + return token.jti in self._jti_dict diff --git a/src/gravel/controllers/config.py b/src/gravel/controllers/config.py index 087f45b52..435a59fe8 100644 --- a/src/gravel/controllers/config.py +++ b/src/gravel/controllers/config.py @@ -60,6 +60,13 @@ class EtcdOptionsModel(BaseModel): data_dir: str = Field("/var/lib/etcd", title="Etcd Data Dir") +class AuthOptionsModel(BaseModel): + jwt_secret: str = Field(title="The access token secret", + default_factory=lambda: utils.random_string(24)) + jwt_ttl: int = Field(36000, + title="How long an access token should live before it expires") + + class OptionsModel(BaseModel): service_state_path: Path = Field(Path(_get_default_confdir()).joinpath("storage.json"), title="Path to Service State file") @@ -69,6 +76,7 @@ class OptionsModel(BaseModel): status: StatusOptionsModel = Field(StatusOptionsModel()) services: ServicesOptionsModel = Field(ServicesOptionsModel()) etcd: EtcdOptionsModel = Field(EtcdOptionsModel()) + auth: AuthOptionsModel = Field(AuthOptionsModel()) class ConfigModel(BaseModel): diff --git a/src/gravel/controllers/kv.py b/src/gravel/controllers/kv.py index ac47d6d66..425fc9bd3 100644 --- a/src/gravel/controllers/kv.py +++ b/src/gravel/controllers/kv.py @@ -12,7 +12,7 @@ # GNU General Public License for more details. import asyncio -from typing import Callable, Optional +from typing import Callable, Optional, List import aetcd3 import aetcd3.locks import aetcd3.events @@ -96,6 +96,14 @@ async def get(self, key: str) -> Optional[str]: return None return value.decode("utf-8") + async def get_prefix(self, key: str) -> List[str]: + """ Get a range of keys with a prefix """ + assert self._client + values = [] + async for value, _ in self._client.get_prefix(key): # type: ignore[attr-defined] + values.append(value.decode("utf-8")) + return values + async def rm(self, key: str) -> None: """ Remove key from store """ assert self._client diff --git a/src/gravel/controllers/nodes/mgr.py b/src/gravel/controllers/nodes/mgr.py index 4c5cfee7d..5fcea3b0f 100644 --- a/src/gravel/controllers/nodes/mgr.py +++ b/src/gravel/controllers/nodes/mgr.py @@ -29,6 +29,7 @@ from fastapi.logger import logger as fastapi_logger from gravel.cephadm.cephadm import CephadmError from gravel.cephadm.models import NodeInfoModel +from gravel.controllers.auth import UserModel, UserMgr from gravel.controllers.gstate import GlobalState from gravel.controllers.nodes.deployment import ( DeploymentConfig, @@ -427,6 +428,11 @@ async def deploy(self, params: DeployParamsModel) -> None: await self._save_token() await self._save_ntp_addr(params.ntpaddr) + admin_user = UserModel(username="admin", password="aquarium") + admin_user.hash_password() + user_mgr = UserMgr(self.gstate.store) + await user_mgr.put(admin_user) + async def _post_bootstrap_finisher( self, success: bool, diff --git a/src/gravel/controllers/utils.py b/src/gravel/controllers/utils.py index c311e2c4b..e9abcdb33 100644 --- a/src/gravel/controllers/utils.py +++ b/src/gravel/controllers/utils.py @@ -11,8 +11,9 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. - import asyncio +import random +import string from logging import Logger from pathlib import Path from typing import ( @@ -86,3 +87,12 @@ async def aqr_run_cmd( logger.debug(f"run {args}: retcode = {proc.returncode}") return retcode, stdout, stderr + + +def random_string(length: int) -> str: + """ + Return a random text string containing printable characters. + :param length: The length of the string. + :return: Returns a random string. + """ + return ''.join(random.choices(string.printable, k=length)) diff --git a/src/gravel/tests/conftest.py b/src/gravel/tests/conftest.py index 2bad89dd4..58dabacc5 100644 --- a/src/gravel/tests/conftest.py +++ b/src/gravel/tests/conftest.py @@ -154,6 +154,15 @@ async def get(self, key: str) -> Optional[str]: return None return self._storage[key] + async def get_prefix(self, key: str) -> List[str]: + """ Get a range of keys with a prefix """ + assert self._is_open + values = [] + for k in self._storage.keys(): + if k.startswith(key): + values.append(self._storage[k]) + return values + async def rm(self, key: str) -> None: """ Remove key from store """ assert self._is_open diff --git a/src/gravel/tests/unit/controllers/test_auth.py b/src/gravel/tests/unit/controllers/test_auth.py new file mode 100644 index 000000000..d2af7bff6 --- /dev/null +++ b/src/gravel/tests/unit/controllers/test_auth.py @@ -0,0 +1,107 @@ +# project aquarium's backend +# Copyright (C) 2021 SUSE, LLC. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +import pytest + +from gravel.controllers.auth import ( + JWT, + JWTDenyList, + JWTMgr, + UserModel, + UserMgr +) +from gravel.controllers.config import AuthOptionsModel +from gravel.controllers.gstate import GlobalState + + +def test_user_model_hash_password(): + user = UserModel(password="bar") + assert user.password == "bar" + user.hash_password() + assert user.password != "bar" + + +def test_user_model_verify_password(): + user = UserModel(password="foo") + user.hash_password() + assert user.verify_password("foo") + assert not user.verify_password("bar") + + +@pytest.mark.asyncio +async def test_user_mgr(gstate: GlobalState): + await gstate.store.ensure_connection() + user_mgr = UserMgr(gstate.store) + user1 = UserModel( + username="foo", + password="test1", + full_name="foo loo" + ) + user1.hash_password() + user2 = UserModel( + username="bar", + password="test2", + full_name="bar baz" + ) + user2.hash_password() + await user_mgr.put(user1) + await user_mgr.put(user2) + assert await user_mgr.exists("foo") + assert not await user_mgr.exists("xyz") + assert len(await user_mgr.enumerate()) == 2 + user = await user_mgr.get("bar") + assert user.username == "bar" + await user_mgr.remove("bar") + assert len(await user_mgr.enumerate()) == 1 + assert await user_mgr.authenticate("foo", "test1") + assert not await user_mgr.authenticate("foo", "abc") + + +def test_jwt_mgr_create_access_token(): + config = AuthOptionsModel() + jwt_mgr = JWTMgr(config) + assert type(jwt_mgr.create_access_token("foo")) is str + + +def test_jwt_mgr_get_raw_access_token(): + config = AuthOptionsModel(jwt_secret="m[>\\Ura3,C`