From 4a77bf1a61638181fdf90cfe0a3befa88720f82d Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 20 Aug 2019 12:00:54 +0200 Subject: [PATCH] saml --- .travis.yml | 4 +- .../app/core/core-services/auth.service.ts | 72 +++--- .../core/core-services/operator.service.ts | 17 +- .../users/group-repository.service.ts | 2 +- .../users/user-repository.service.ts | 24 -- .../core/ui-services/login-data.service.ts | 25 +- client/src/app/shared/models/users/user.ts | 4 + .../login-mask/login-mask.component.html | 11 +- .../login-mask/login-mask.component.ts | 13 +- client/src/app/site/site.component.html | 35 ++- client/src/app/site/site.component.ts | 16 +- .../user-detail/user-detail.component.html | 2 +- .../user-detail/user-detail.component.ts | 22 +- .../user-list/user-list.component.html | 4 +- .../user-list/user-list.component.ts | 2 +- client/src/app/site/users/models/view-user.ts | 8 +- .../services/user-filter-list.service.ts | 2 +- openslides/__main__.py | 4 +- openslides/asgi.py | 4 +- openslides/core/apps.py | 38 ++- openslides/saml/README.md | 81 +++++++ openslides/saml/__init__.py | 17 ++ openslides/saml/apps.py | 36 +++ openslides/saml/exceptions.py | 5 + openslides/saml/management/__init__.py | 0 .../saml/management/commands/__init__.py | 0 .../commands/create-saml-settings.py | 33 +++ openslides/saml/saml_settings.json.tpl | 70 ++++++ openslides/saml/settings.py | 225 ++++++++++++++++++ openslides/saml/urls.py | 10 + openslides/saml/user_backend.py | 25 ++ openslides/saml/views.py | 225 ++++++++++++++++++ openslides/urls_apps.py | 4 + openslides/users/access_permissions.py | 32 ++- openslides/users/apps.py | 13 +- .../users/migrations/0012_user_auth_type.py | 16 ++ openslides/users/models.py | 2 + openslides/users/serializers.py | 3 +- openslides/users/user_backend.py | 82 +++++++ openslides/users/views.py | 71 +++++- openslides/utils/cache.py | 13 +- openslides/utils/constants.py | 4 + openslides/utils/settings.py.tpl | 10 + openslides/utils/startup.py | 37 +++ setup.cfg | 2 +- tests/conftest.py | 10 + .../commands/create-example-data.py | 4 +- tests/integration/users/test_views.py | 30 ++- tests/unit/users/test_user_backend.py | 56 +++++ 49 files changed, 1282 insertions(+), 143 deletions(-) create mode 100644 openslides/saml/README.md create mode 100644 openslides/saml/__init__.py create mode 100644 openslides/saml/apps.py create mode 100644 openslides/saml/exceptions.py create mode 100644 openslides/saml/management/__init__.py create mode 100644 openslides/saml/management/commands/__init__.py create mode 100644 openslides/saml/management/commands/create-saml-settings.py create mode 100644 openslides/saml/saml_settings.json.tpl create mode 100644 openslides/saml/settings.py create mode 100644 openslides/saml/urls.py create mode 100644 openslides/saml/user_backend.py create mode 100644 openslides/saml/views.py create mode 100644 openslides/users/migrations/0012_user_auth_type.py create mode 100644 openslides/users/user_backend.py create mode 100644 openslides/utils/startup.py create mode 100644 tests/unit/users/test_user_backend.py diff --git a/.travis.yml b/.travis.yml index ebcdaa9ff..e85b67eb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -85,7 +85,7 @@ matrix: - "3.6" script: - mypy openslides/ tests/ - - pytest --cov --cov-fail-under=75 + - pytest --cov --cov-fail-under=73 - name: "Server: Tests Python 3.7" language: python @@ -96,7 +96,7 @@ matrix: - isort --check-only --diff --recursive openslides tests - black --check --diff --target-version py36 openslides tests - mypy openslides/ tests/ - - pytest --cov --cov-fail-under=75 + - pytest --cov --cov-fail-under=73 - name: "Client: Linting" language: node_js diff --git a/client/src/app/core/core-services/auth.service.ts b/client/src/app/core/core-services/auth.service.ts index 9363ed217..b85e44fad 100644 --- a/client/src/app/core/core-services/auth.service.ts +++ b/client/src/app/core/core-services/auth.service.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { environment } from 'environments/environment'; import { OperatorService, WhoAmI } from 'app/core/core-services/operator.service'; +import { DEFAULT_AUTH_TYPE, UserAuthType } from 'app/shared/models/users/user'; import { DataStoreService } from './data-store.service'; import { HttpService } from './http.service'; import { OpenSlidesService } from './openslides.service'; @@ -32,27 +33,33 @@ export class AuthService { ) {} /** - * Try to log in a user. + * Try to log in a user with a given auth type * - * Returns an observable with the correct login information or an error. - * errors will be forwarded to the parents error function. - * - * @param username - * @param password - * @returns The login response. + * - Type "default": username and password needed; the earlySuccessCallback will be called. + * - Type "saml": The windows location will be changed to the single-sign-on service initiator. */ - public async login(username: string, password: string, earlySuccessCallback: () => void): Promise { - const user = { - username: username, - password: password - }; - const response = await this.http.post(environment.urlPrefix + '/users/login/', user); - earlySuccessCallback(); - await this.OpenSlides.shutdown(); - await this.operator.setWhoAmI(response); - await this.OpenSlides.afterLoginBootup(response.user_id); - await this.redirectUser(response.user_id); - return response; + public async login( + authType: UserAuthType, + username: string, + password: string, + earlySuccessCallback: () => void + ): Promise { + if (authType === 'default') { + const user = { + username: username, + password: password + }; + const response = await this.http.post(environment.urlPrefix + '/users/login/', user); + earlySuccessCallback(); + await this.OpenSlides.shutdown(); + await this.operator.setWhoAmI(response); + await this.OpenSlides.afterLoginBootup(response.user_id); + await this.redirectUser(response.user_id); + } else if (authType === 'saml') { + window.location.href = environment.urlPrefix + '/saml/?sso'; // Bye + } else { + throw new Error(`Unsupported auth type "${authType}"`); + } } /** @@ -87,15 +94,24 @@ export class AuthService { * send a `post`-request to `/apps/users/logout/'`. Restarts OpenSlides. */ public async logout(): Promise { - let response = null; - try { - response = await this.http.post(environment.urlPrefix + '/users/logout/', {}); - } catch (e) { - // We do nothing on failures. Reboot OpenSlides anyway. + const authType = this.operator.authType.getValue(); + if (authType === DEFAULT_AUTH_TYPE) { + let response = null; + try { + response = await this.http.post(environment.urlPrefix + '/users/logout/', {}); + } catch (e) { + // We do nothing on failures. Reboot OpenSlides anyway. + } + this.router.navigate(['/']); + await this.DS.clear(); + await this.operator.setWhoAmI(response); + await this.OpenSlides.reboot(); + } else if (authType === 'saml') { + await this.DS.clear(); + await this.operator.setWhoAmI(null); + window.location.href = environment.urlPrefix + '/saml/?slo'; // Bye + } else { + throw new Error(`Unsupported auth type "${authType}"`); } - this.router.navigate(['/']); - await this.DS.clear(); - await this.operator.setWhoAmI(response); - await this.OpenSlides.reboot(); } } diff --git a/client/src/app/core/core-services/operator.service.ts b/client/src/app/core/core-services/operator.service.ts index 3da24aa6f..a0d002a1c 100644 --- a/client/src/app/core/core-services/operator.service.ts +++ b/client/src/app/core/core-services/operator.service.ts @@ -14,7 +14,7 @@ import { OfflineService } from './offline.service'; import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded'; import { OpenSlidesStatusService } from './openslides-status.service'; import { StorageService } from './storage.service'; -import { User } from '../../shared/models/users/user'; +import { DEFAULT_AUTH_TYPE, User, UserAuthType } from '../../shared/models/users/user'; import { UserRepositoryService } from '../repositories/users/user-repository.service'; /** @@ -30,6 +30,7 @@ export interface WhoAmI { user_id: number; guest_enabled: boolean; user: User; + auth_type: UserAuthType; permissions: Permission[]; } @@ -42,7 +43,8 @@ function isWhoAmI(obj: any): obj is WhoAmI { whoAmI.guest_enabled !== undefined && whoAmI.user !== undefined && whoAmI.user_id !== undefined && - whoAmI.permissions !== undefined + whoAmI.permissions !== undefined && + whoAmI.auth_type !== undefined ); } @@ -89,6 +91,8 @@ export class OperatorService implements OnAfterAppsLoaded { return this.isInGroupIdsNonAdminCheck(2); } + public readonly authType: BehaviorSubject = new BehaviorSubject(DEFAULT_AUTH_TYPE); + /** * Save, if guests are enabled. */ @@ -117,10 +121,11 @@ export class OperatorService implements OnAfterAppsLoaded { private userRepository: UserRepositoryService | null; private _currentWhoAmI: WhoAmI | null = null; - private _defaultWhoAMI: WhoAmI = { + private _defaultWhoAmI: WhoAmI = { user_id: null, guest_enabled: false, user: null, + auth_type: DEFAULT_AUTH_TYPE, permissions: [] }; @@ -128,7 +133,7 @@ export class OperatorService implements OnAfterAppsLoaded { * The current WhoAmI response to extract the user (the operator) from. */ private get currentWhoAmI(): WhoAmI { - return this._currentWhoAmI || this._defaultWhoAMI; + return this._currentWhoAmI || this._defaultWhoAmI; } private set currentWhoAmI(value: WhoAmI | null) { @@ -137,7 +142,7 @@ export class OperatorService implements OnAfterAppsLoaded { // Resetting the default whoami, when the current whoami isn't there. This // is for a fresh restart and do not have (old) changed values in this.defaultWhoAmI if (!value) { - this._defaultWhoAMI = this.getDefaultWhoAmIResponse(); + this._defaultWhoAmI = this.getDefaultWhoAmIResponse(); } } @@ -304,6 +309,7 @@ export class OperatorService implements OnAfterAppsLoaded { } this._user = whoami ? whoami.user : null; + this.authType.next(whoami ? whoami.auth_type : DEFAULT_AUTH_TYPE); await this.updatePermissions(); this._loaded.resolve(); } @@ -418,6 +424,7 @@ export class OperatorService implements OnAfterAppsLoaded { user_id: null, guest_enabled: false, user: null, + auth_type: DEFAULT_AUTH_TYPE, permissions: [] }; } diff --git a/client/src/app/core/repositories/users/group-repository.service.ts b/client/src/app/core/repositories/users/group-repository.service.ts index 7263e5829..2fb1ff813 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -107,7 +107,7 @@ export class GroupRepositoryService extends BaseRepository('permissions').subscribe(perms => { + this.constantsService.get('Permissions').subscribe(perms => { let pluginCounter = 0; for (const perm of perms) { // extract the apps name diff --git a/client/src/app/core/repositories/users/user-repository.service.ts b/client/src/app/core/repositories/users/user-repository.service.ts index 699af2609..c0df75b8b 100644 --- a/client/src/app/core/repositories/users/user-repository.service.ts +++ b/client/src/app/core/repositories/users/user-repository.service.ts @@ -154,30 +154,6 @@ export class UserRepositoryService extends BaseRepository, viewUser: ViewUser): Promise { - // if the user deletes the username, reset - // prevents the server of generating ' +1' as username - if (update.username === '') { - update.username = viewUser.username; - } - - // if the update user does not have a gender-field, send gender as empty string. - // This allow to delete a previously selected gender - if (!update.gender) { - update.gender = ''; - } - - return super.update(update, viewUser); - } - /** * Updates the password and sets the password without checking for the old one. * Also resets the 'default password' to the newly created one. diff --git a/client/src/app/core/ui-services/login-data.service.ts b/client/src/app/core/ui-services/login-data.service.ts index b54855c37..8d14be1a3 100644 --- a/client/src/app/core/ui-services/login-data.service.ts +++ b/client/src/app/core/ui-services/login-data.service.ts @@ -5,10 +5,16 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { auditTime } from 'rxjs/operators'; import { ConfigService } from './config.service'; +import { ConstantsService } from '../core-services/constants.service'; import { HttpService } from '../core-services/http.service'; import { OpenSlidesStatusService } from '../core-services/openslides-status.service'; import { StorageService } from '../core-services/storage.service'; +interface SamlSettings { + loginButtonText: string; + changePasswordUrl: string; +} + /** * The login data send by the server. */ @@ -21,6 +27,7 @@ export interface LoginData { display_name: string; }; login_info_text?: string; + saml_settings?: SamlSettings; } /** @@ -105,6 +112,11 @@ export class LoginDataService { return this._logoWebHeader.asObservable(); } + private readonly _samlSettings = new BehaviorSubject(undefined); + public get samlSettings(): Observable { + return this._samlSettings.asObservable(); + } + /** * Emit this event, if the current login data should be stored. This * is debounced to minimize requests to the storage service. @@ -131,7 +143,8 @@ export class LoginDataService { private configService: ConfigService, private storageService: StorageService, private OSStatus: OpenSlidesStatusService, - private httpService: HttpService + private httpService: HttpService, + private constantsService: ConstantsService ) { this.storeLoginDataRequests.pipe(auditTime(100)).subscribe(() => this.storeLoginData()); this.setup(); @@ -167,6 +180,12 @@ export class LoginDataService { this.storeLoginDataRequests.next(); } }); + this.constantsService.get('SamlSettings').subscribe(value => { + if (value !== undefined) { + this._samlSettings.next(value); + this.storeLoginDataRequests.next(); + } + }); this.canRefresh = true; if (this.markRefresh) { this._refresh(); @@ -219,6 +238,7 @@ export class LoginDataService { this._theme.next(loginData.theme); this._logoWebHeader.next(loginData.logo_web_header); this._loginInfoText.next(loginData.login_info_text); + this._samlSettings.next(loginData.saml_settings); } /** @@ -234,7 +254,8 @@ export class LoginDataService { privacy_policy: this._privacyPolicy.getValue(), legal_notice: this._legalNotice.getValue(), theme: this._theme.getValue(), - logo_web_header: this._logoWebHeader.getValue() + logo_web_header: this._logoWebHeader.getValue(), + samlSettings: this._samlSettings.getValue() }; this.storageService.set(LOGIN_DATA_STORAGE_KEY, loginData); } diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts index 8ae43b4fe..96062dd60 100644 --- a/client/src/app/shared/models/users/user.ts +++ b/client/src/app/shared/models/users/user.ts @@ -6,6 +6,9 @@ import { BaseModel } from '../base/base-model'; */ export const genders = [_('female'), _('male'), _('diverse')]; +export const DEFAULT_AUTH_TYPE = 'default'; +export type UserAuthType = 'default' | 'saml'; + /** * Representation of a user in contrast to the operator. * @ignore @@ -30,6 +33,7 @@ export class User extends BaseModel { public comment?: string; public is_active?: boolean; public default_password?: string; + public auth_type?: UserAuthType; public constructor(input?: Partial) { super(User.COLLECTIONSTRING, input); diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.html b/client/src/app/site/login/components/login-mask/login-mask.component.html index 3cc0e2b86..b51af06e4 100644 --- a/client/src/app/site/login/components/login-mask/login-mask.component.html +++ b/client/src/app/site/login/components/login-mask/login-mask.component.html @@ -6,7 +6,7 @@ -