Merge pull request #5000 from FinnStutzenstein/saml
[WIP] saml integration
This commit is contained in:
commit
caf05a3e87
@ -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
|
||||
|
@ -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<WhoAmI> {
|
||||
const user = {
|
||||
username: username,
|
||||
password: password
|
||||
};
|
||||
const response = await this.http.post<WhoAmI>(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<void> {
|
||||
if (authType === 'default') {
|
||||
const user = {
|
||||
username: username,
|
||||
password: password
|
||||
};
|
||||
const response = await this.http.post<WhoAmI>(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<void> {
|
||||
let response = null;
|
||||
try {
|
||||
response = await this.http.post<WhoAmI>(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<WhoAmI>(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();
|
||||
}
|
||||
}
|
||||
|
@ -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<UserAuthType> = 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: []
|
||||
};
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
||||
* read the constants, add them to an array of apps
|
||||
*/
|
||||
private sortPermsPerApp(): void {
|
||||
this.constantsService.get<any>('permissions').subscribe(perms => {
|
||||
this.constantsService.get<any>('Permissions').subscribe(perms => {
|
||||
let pluginCounter = 0;
|
||||
for (const perm of perms) {
|
||||
// extract the apps name
|
||||
|
@ -154,30 +154,6 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a the selected user with the form values.
|
||||
* Since user should actually "delete" field, the unified update method
|
||||
* cannot be used
|
||||
*
|
||||
* @param update the forms values
|
||||
* @param viewUser
|
||||
*/
|
||||
public async update(update: Partial<User>, viewUser: ViewUser): Promise<void> {
|
||||
// if the user deletes the username, reset
|
||||
// prevents the server of generating '<firstname> <lastname> +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.
|
||||
|
@ -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<SamlSettings>(undefined);
|
||||
public get samlSettings(): Observable<SamlSettings> {
|
||||
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>('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);
|
||||
}
|
||||
|
@ -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<User> {
|
||||
public comment?: string;
|
||||
public is_active?: boolean;
|
||||
public default_password?: string;
|
||||
public auth_type?: UserAuthType;
|
||||
|
||||
public constructor(input?: Partial<User>) {
|
||||
super(User.COLLECTIONSTRING, input);
|
||||
|
@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<!-- login form -->
|
||||
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin()">
|
||||
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin('default')">
|
||||
<mat-form-field>
|
||||
<input
|
||||
matInput
|
||||
@ -51,5 +51,14 @@
|
||||
>
|
||||
{{ 'Login as guest' | translate }}
|
||||
</button>
|
||||
<a
|
||||
mat-stroked-button
|
||||
*ngIf="samlLoginButtonText"
|
||||
class="login-button"
|
||||
type="button"
|
||||
(click)="formLogin('saml')"
|
||||
>
|
||||
{{ samlLoginButtonText | translate }}
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -11,6 +11,7 @@ import { AuthService } from 'app/core/core-services/auth.service';
|
||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||
import { LoginDataService } from 'app/core/ui-services/login-data.service';
|
||||
import { OverlayService } from 'app/core/ui-services/overlay.service';
|
||||
import { UserAuthType } from 'app/shared/models/users/user';
|
||||
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
|
||||
@ -52,6 +53,8 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
|
||||
|
||||
public operatorSubscription: Subscription | null;
|
||||
|
||||
public samlLoginButtonText: string | null = null;
|
||||
|
||||
/**
|
||||
* The message, that should appear, when the user logs in.
|
||||
*/
|
||||
@ -97,6 +100,12 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
|
||||
this.loginDataService.loginInfoText.subscribe(notice => (this.installationNotice = notice))
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.loginDataService.samlSettings.subscribe(
|
||||
samlSettings => (this.samlLoginButtonText = samlSettings ? samlSettings.loginButtonText : null)
|
||||
)
|
||||
);
|
||||
|
||||
// Maybe the operator changes and the user is logged in. If so, redirect him and boot OpenSlides.
|
||||
this.operatorSubscription = this.operator.getUserObservable().subscribe(user => {
|
||||
if (user) {
|
||||
@ -138,12 +147,12 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
|
||||
*
|
||||
* Send username and password to the {@link AuthService}
|
||||
*/
|
||||
public async formLogin(): Promise<void> {
|
||||
public async formLogin(authType: UserAuthType): Promise<void> {
|
||||
this.loginErrorMsg = '';
|
||||
try {
|
||||
this.overlayService.logout(); // Ensures displaying spinner, if logging in
|
||||
this.overlayService.showSpinner(this.translate.instant(this.loginMessage), true);
|
||||
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
|
||||
await this.authService.login(authType, this.loginForm.value.username, this.loginForm.value.password, () => {
|
||||
this.clearOperatorSubscription(); // We take control, not the subscription.
|
||||
});
|
||||
} catch (e) {
|
||||
|
@ -29,7 +29,7 @@
|
||||
<mat-expansion-panel class="user-menu mat-elevation-z0">
|
||||
<mat-expansion-panel-header class="username">
|
||||
<!-- Get the username from operator -->
|
||||
<span *ngIf="username">{{ username }}</span>
|
||||
{{ username }}
|
||||
</mat-expansion-panel-header>
|
||||
<mat-nav-list>
|
||||
<a mat-list-item [matMenuTriggerFor]="languageMenu">
|
||||
@ -45,16 +45,29 @@
|
||||
<mat-icon>person</mat-icon>
|
||||
<span translate>Show profile</span>
|
||||
</a>
|
||||
<a
|
||||
*osPerms="'users.can_change_password'"
|
||||
routerLink="/users/password"
|
||||
(click)="mobileAutoCloseNav()"
|
||||
mat-list-item
|
||||
>
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
<span translate>Change password</span>
|
||||
</a>
|
||||
<a *ngIf="isLoggedIn" (click)="logout()" mat-list-item>
|
||||
<ng-container *ngIf="authType === 'default'">
|
||||
<a
|
||||
*osPerms="'users.can_change_password'"
|
||||
routerLink="/users/password"
|
||||
(click)="mobileAutoCloseNav()"
|
||||
mat-list-item
|
||||
>
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
<span translate>Change password</span>
|
||||
</a>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngIf="authType === 'saml'">
|
||||
<a
|
||||
*osPerms="'users.can_change_password'"
|
||||
[href]="samlChangePasswordUrl"
|
||||
mat-list-item
|
||||
>
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
<span translate>Change password</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<a (click)="logout()" mat-list-item>
|
||||
<mat-icon>exit_to_app</mat-icon>
|
||||
<span translate>Logout</span>
|
||||
</a>
|
||||
|
@ -11,8 +11,10 @@ import { filter } from 'rxjs/operators';
|
||||
|
||||
import { navItemAnim } from '../shared/animations';
|
||||
import { OfflineService } from 'app/core/core-services/offline.service';
|
||||
import { LoginDataService } from 'app/core/ui-services/login-data.service';
|
||||
import { OverlayService } from 'app/core/ui-services/overlay.service';
|
||||
import { UpdateService } from 'app/core/ui-services/update.service';
|
||||
import { DEFAULT_AUTH_TYPE } from 'app/shared/models/users/user';
|
||||
import { langToLocale } from 'app/shared/utils/lang-to-locale';
|
||||
import { AuthService } from '../core/core-services/auth.service';
|
||||
import { BaseComponent } from '../base.component';
|
||||
@ -46,7 +48,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
||||
/**
|
||||
* Get the username from the operator (should be known already)
|
||||
*/
|
||||
public username: string;
|
||||
public username = '';
|
||||
|
||||
public authType = DEFAULT_AUTH_TYPE;
|
||||
|
||||
/**
|
||||
* is the user logged in, or the anonymous is active.
|
||||
@ -73,6 +77,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
||||
*/
|
||||
private delayedUpdateAvailable = false;
|
||||
|
||||
public samlChangePasswordUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
@ -100,7 +106,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
||||
public OSStatus: OpenSlidesStatusService,
|
||||
public timeTravel: TimeTravelService,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private overlayService: OverlayService
|
||||
private overlayService: OverlayService,
|
||||
private loginDataService: LoginDataService
|
||||
) {
|
||||
super(title, translate);
|
||||
overlayService.showSpinner(translate.instant('Loading data. Please wait...'));
|
||||
@ -114,11 +121,16 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
});
|
||||
this.operator.authType.subscribe(authType => (this.authType = authType));
|
||||
|
||||
offlineService.isOffline().subscribe(offline => {
|
||||
this.isOffline = offline;
|
||||
});
|
||||
|
||||
this.loginDataService.samlSettings.subscribe(
|
||||
samlSettings => (this.samlChangePasswordUrl = samlSettings ? samlSettings.changePasswordUrl : null)
|
||||
);
|
||||
|
||||
this.searchform = new FormGroup({ query: new FormControl([]) });
|
||||
|
||||
// detect routing data such as base perm and noInterruption
|
||||
|
@ -320,7 +320,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isAllowed('seePersonal') && user.is_last_email_send">
|
||||
<div *ngIf="isAllowed('seePersonal') && user.isLastEmailSend">
|
||||
<div>
|
||||
<h4 translate>Last email sent</h4>
|
||||
<span>{{ getEmailSentTime() }}</span>
|
||||
|
@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ConstantsService } from 'app/core/core-services/constants.service';
|
||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
|
||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||
@ -18,6 +19,12 @@ import { UserPdfExportService } from '../../services/user-pdf-export.service';
|
||||
import { ViewGroup } from '../../models/view-group';
|
||||
import { ViewUser } from '../../models/view-user';
|
||||
|
||||
interface UserBackends {
|
||||
[name: string]: {
|
||||
disallowedUpdateKeys: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Users detail component for both new and existing users
|
||||
*/
|
||||
@ -67,6 +74,8 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
||||
*/
|
||||
public genderList = genders;
|
||||
|
||||
private userBackends: UserBackends | null = null;
|
||||
|
||||
/**
|
||||
* Constructor for user
|
||||
*
|
||||
@ -93,11 +102,14 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
||||
private operator: OperatorService,
|
||||
private promptService: PromptService,
|
||||
private pdfService: UserPdfExportService,
|
||||
private groupRepo: GroupRepositoryService
|
||||
private groupRepo: GroupRepositoryService,
|
||||
private constantsService: ConstantsService
|
||||
) {
|
||||
super(title, translate, matSnackBar);
|
||||
this.createForm();
|
||||
|
||||
this.constantsService.get<UserBackends>('UserBackends').subscribe(backends => (this.userBackends = backends));
|
||||
|
||||
this.groupRepo
|
||||
.getViewModelListObservable()
|
||||
.subscribe(groups => this.groups.next(groups.filter(group => group.id !== 1)));
|
||||
@ -236,6 +248,12 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.newUser && this.user.auth_type && this.userBackends && this.userBackends[this.user.auth_type]) {
|
||||
this.userBackends[this.user.auth_type].disallowedUpdateKeys.forEach(key => {
|
||||
this.personalInfoForm.get(key).disable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -350,7 +368,7 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
||||
* @returns a translated string with either the localized date/time; of 'No email sent'
|
||||
*/
|
||||
public getEmailSentTime(): string {
|
||||
if (!this.user.is_last_email_send) {
|
||||
if (!this.user.isLastEmailSend) {
|
||||
return this.translate.instant('No email sent');
|
||||
}
|
||||
return this.repo.lastSentEmailTimeString(this.user);
|
||||
|
@ -78,7 +78,7 @@
|
||||
<!-- Email-sent indicator -->
|
||||
<mat-icon
|
||||
inline
|
||||
*ngIf="user.is_last_email_send"
|
||||
*ngIf="user.isLastEmailSend"
|
||||
matTooltip="{{ 'Email sent' | translate }} ({{ getEmailSentTime(user) }})"
|
||||
>
|
||||
mail
|
||||
@ -88,6 +88,8 @@
|
||||
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}">
|
||||
comment
|
||||
</mat-icon>
|
||||
|
||||
<os-icon-container *ngIf="user.isSamlUser" icon="device_hub"><span translate>Is SAML user</span></os-icon-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -355,7 +355,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
|
||||
* @returns a string representation about the last time an email was sent to a user
|
||||
*/
|
||||
public getEmailSentTime(user: ViewUser): string {
|
||||
if (!user.is_last_email_send) {
|
||||
if (!user.isLastEmailSend) {
|
||||
return this.translate.instant('No email sent');
|
||||
}
|
||||
return this.repo.lastSentEmailTimeString(user);
|
||||
|
@ -21,8 +21,12 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
|
||||
return this._model;
|
||||
}
|
||||
|
||||
public get is_last_email_send(): boolean {
|
||||
return this.user && !!this.user.last_email_send;
|
||||
public get isSamlUser(): boolean {
|
||||
return this.auth_type === 'saml';
|
||||
}
|
||||
|
||||
public get isLastEmailSend(): boolean {
|
||||
return !!this.user.last_email_send;
|
||||
}
|
||||
|
||||
public get short_name(): string {
|
||||
|
@ -79,7 +79,7 @@ export class UserFilterListService extends BaseFilterListService<ViewUser> {
|
||||
]
|
||||
},
|
||||
{
|
||||
property: 'is_last_email_send',
|
||||
property: 'isLastEmailSend',
|
||||
label: this.translate.instant('Last email send'),
|
||||
options: [
|
||||
{ condition: true, label: this.translate.instant('Got an email') },
|
||||
|
@ -8,7 +8,6 @@ import django
|
||||
from django.core.management import call_command, execute_from_command_line
|
||||
|
||||
import openslides
|
||||
from openslides.core.apps import startup
|
||||
from openslides.utils.arguments import arguments
|
||||
from openslides.utils.main import (
|
||||
ExceptionArgumentParser,
|
||||
@ -20,6 +19,7 @@ from openslides.utils.main import (
|
||||
setup_django_settings_module,
|
||||
write_settings,
|
||||
)
|
||||
from openslides.utils.startup import run_startup_hooks
|
||||
|
||||
|
||||
def main():
|
||||
@ -227,7 +227,7 @@ def start(args):
|
||||
if not args.no_browser:
|
||||
open_browser(args.host, args.port)
|
||||
|
||||
startup()
|
||||
run_startup_hooks()
|
||||
|
||||
# Start the built-in webserver
|
||||
#
|
||||
|
@ -6,13 +6,13 @@ defined in the ASGI_APPLICATION setting.
|
||||
import django
|
||||
from channels.routing import get_default_application
|
||||
|
||||
from .core.apps import startup
|
||||
from .utils.main import setup_django_settings_module
|
||||
from .utils.startup import run_startup_hooks
|
||||
|
||||
|
||||
# Loads the openslides setting. You can use your own settings by setting the
|
||||
# environment variable DJANGO_SETTINGS_MODULE
|
||||
setup_django_settings_module()
|
||||
django.setup()
|
||||
startup()
|
||||
run_startup_hooks()
|
||||
application = get_default_application()
|
||||
|
@ -1,4 +1,3 @@
|
||||
import os
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from operator import attrgetter
|
||||
@ -95,9 +94,6 @@ class CoreAppConfig(AppConfig):
|
||||
self.get_model("Countdown").get_collection_string(), CountdownViewSet
|
||||
)
|
||||
|
||||
if "runserver" in sys.argv or "changeconfig" in sys.argv:
|
||||
startup()
|
||||
|
||||
# Register client messages
|
||||
register_client_message(NotifyWebsocketClientMessage())
|
||||
register_client_message(ConstantsWebsocketClientMessage())
|
||||
@ -106,6 +102,22 @@ class CoreAppConfig(AppConfig):
|
||||
register_client_message(ListenToProjectors())
|
||||
register_client_message(PingPong())
|
||||
|
||||
if "runserver" in sys.argv or "changeconfig" in sys.argv:
|
||||
from openslides.utils.startup import run_startup_hooks
|
||||
|
||||
run_startup_hooks()
|
||||
|
||||
def get_startup_hooks(self):
|
||||
from openslides.utils.constants import set_constants_from_apps
|
||||
from openslides.utils.cache import element_cache
|
||||
from openslides.core.models import History
|
||||
|
||||
return {
|
||||
10: element_cache.ensure_schema_version,
|
||||
40: set_constants_from_apps,
|
||||
90: History.objects.build_history,
|
||||
}
|
||||
|
||||
def get_config_variables(self):
|
||||
from .config_variables import get_config_variables
|
||||
|
||||
@ -193,21 +205,3 @@ def manage_config(**kwargs):
|
||||
if altered:
|
||||
config.increment_version()
|
||||
logging.getLogger(__name__).info("Updated config variables")
|
||||
|
||||
|
||||
def startup():
|
||||
"""
|
||||
Runs commands that are needed at startup.
|
||||
|
||||
Sets the cache, constants and startup history
|
||||
"""
|
||||
if os.environ.get("NO_STARTUP"):
|
||||
return
|
||||
|
||||
from openslides.utils.constants import set_constants, get_constants_from_apps
|
||||
from openslides.utils.cache import element_cache
|
||||
from openslides.core.models import History
|
||||
|
||||
element_cache.ensure_schema_version()
|
||||
set_constants(get_constants_from_apps())
|
||||
History.objects.build_history()
|
||||
|
81
openslides/saml/README.md
Normal file
81
openslides/saml/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
|
||||
# OpenSlides SAML Plugin
|
||||
|
||||
This app for OpenSlides provides a login via a SAML single-sign-on service.
|
||||
|
||||
## Requirements
|
||||
|
||||
Install `python3-saml` via `pip install python3-saml`.
|
||||
|
||||
Note: python3-saml needs thy python package `xmlsec <https://pypi.python.org/pypi/xmlsec/1.3.3>`_ which depends on `libxml2 <http://xmlsoft.org/>`_. Those packages need to be installed on a Debian-like system::
|
||||
|
||||
$ apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl pkg-config
|
||||
|
||||
For more information about other operating systems or distributions visit http://pythonhosted.org/xmlsec/install.html.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable the feature by setting ``ENABLE_SAML=True`` in the ``settings.py``. Make sure you
|
||||
have installed the extra dependencies from the section above.
|
||||
|
||||
On startup of OpenSlides the ``saml_settings.json`` is created in the settings folder if
|
||||
it does not exist. To force creating this file run
|
||||
|
||||
$ python manage.py create-saml-settings [--dir /<path to custom settings dir>/]
|
||||
|
||||
The path has to match with the settings path OpenSlides is started with.
|
||||
|
||||
For the first part in the settings file refer to `python3-saml settings documentation
|
||||
<https://github.com/onelogin/python3-saml#settings>`_. All settings described there are
|
||||
merged into the ``saml_settings.json``. Also note the ``README`` file in the ``certs``
|
||||
folder next to the ``saml_settings.json``.
|
||||
|
||||
## Additional Settings
|
||||
|
||||
The following settings are given in the `saml_settings.json`. All entries are required, except for the request settings.
|
||||
|
||||
### General settings
|
||||
Here you can provide a custom text for the SAML login button. The `changePasswordUrl`
|
||||
redirects the user to the given URL when click on `Change password` in the OpenSlides user
|
||||
menu.
|
||||
|
||||
### Attributes
|
||||
The identity provider sends attributes to the server if a user sucessfully logged in. To
|
||||
map these attributes to attributes of OpenSlides users, the section `attributeMapping`
|
||||
exists. The structure is like this::
|
||||
|
||||
"attributeMapping: {
|
||||
"attributeFromIDP": ["attributeOfOpenSlidesUser", <used for lookup>],
|
||||
"anotherAttributeFromIDP": ["anotherAttributeOfOpenSlidesUser", <used for lookup>]
|
||||
}
|
||||
|
||||
All available OpenSlides user attributes are:
|
||||
|
||||
- ``username``: Has to be unique. Identifies the user.
|
||||
- ``first_name``: The user's first name.
|
||||
- ``last_name``: The user's last name.
|
||||
- ``title``: The title of the user, e.g. "Dr.".
|
||||
- ``email``: The user's email addreess.
|
||||
- ``structure_level``: The structure level.
|
||||
- ``number``: The participant number (text, not an actual number). Note: This field is not unique.
|
||||
- ``about_me``: A free text field.
|
||||
- ``is_active``, ``is_present``, ``is_committee``: Boolean flags.
|
||||
|
||||
To get detailed information see the [models.py](https://github.com/OpenSlides/OpenSlides/blob/master/openslides/users/models.py)
|
||||
|
||||
The ``<used for lookup>`` has either to be ``true`` or ``false``. All attributes with this
|
||||
value being true are used to search for an existing user. If the user is found, the user gets
|
||||
updated with all changed values and used to log in. If the user is not found, it will be
|
||||
created with all values given. Try to choose unique attributes (e.g. the username),
|
||||
attributes you are sure about to be unique (e.g. maybe the number) or use a combination of
|
||||
attributes.
|
||||
|
||||
### Requests
|
||||
|
||||
One can overwrite the data extracted from the request headers of saml-requests. E.g. if the public port is 80 and the server is reverse-proxied and listen to port 8000, one should set the `server_port` to 80, so OpenSlides does not take the port of the request header. If not specified all these values are taken from the requests meta information:
|
||||
|
||||
- ``https``: Either ``on`` or ``off``.
|
||||
- ``http_host``: The hostname.
|
||||
- ``script_name``: The aquivalent to ``PATH_INFO`` in the meta values.
|
||||
- ``server_port``: The port listen by the server.
|
17
openslides/saml/__init__.py
Normal file
17
openslides/saml/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.conf import settings
|
||||
|
||||
from .exceptions import SamlException
|
||||
|
||||
|
||||
default_app_config = "openslides.saml.apps.SamlAppConfig"
|
||||
|
||||
|
||||
SAML_ENABLED = getattr(settings, "ENABLE_SAML", False)
|
||||
|
||||
if SAML_ENABLED:
|
||||
try:
|
||||
import onelogin.saml2 # noqa
|
||||
except ImportError:
|
||||
raise SamlException(
|
||||
"SAML is enabled, but we could not import onelogin.saml2. Is python3-saml installed?"
|
||||
)
|
36
openslides/saml/apps.py
Normal file
36
openslides/saml/apps.py
Normal file
@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
from . import SAML_ENABLED
|
||||
from .user_backend import SamlUserBackend
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SamlAppConfig(AppConfig):
|
||||
name = "openslides.saml"
|
||||
verbose_name = "OpenSlides SAML"
|
||||
user_backend_class = SamlUserBackend
|
||||
|
||||
def get_angular_constants(self):
|
||||
from .settings import get_saml_settings
|
||||
|
||||
return {"SamlSettings": get_saml_settings().general_settings}
|
||||
|
||||
def get_startup_hooks(self):
|
||||
return {20: saml_startup}
|
||||
|
||||
|
||||
def saml_startup():
|
||||
# Import all required stuff.
|
||||
from .settings import load_settings
|
||||
|
||||
if not SAML_ENABLED:
|
||||
logger.info("SAML is disabled.")
|
||||
return
|
||||
|
||||
load_settings()
|
||||
|
||||
logger.info("SAML is enabled and loaded.")
|
5
openslides/saml/exceptions.py
Normal file
5
openslides/saml/exceptions.py
Normal file
@ -0,0 +1,5 @@
|
||||
from openslides.utils.exceptions import OpenSlidesError
|
||||
|
||||
|
||||
class SamlException(OpenSlidesError):
|
||||
pass
|
0
openslides/saml/management/__init__.py
Normal file
0
openslides/saml/management/__init__.py
Normal file
0
openslides/saml/management/commands/__init__.py
Normal file
0
openslides/saml/management/commands/__init__.py
Normal file
33
openslides/saml/management/commands/create-saml-settings.py
Normal file
33
openslides/saml/management/commands/create-saml-settings.py
Normal file
@ -0,0 +1,33 @@
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from ...settings import create_saml_settings, get_settings_dir_and_path
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to create the saml_settings.json file.
|
||||
"""
|
||||
|
||||
help = "Create the saml_settings.json settings file."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--dir",
|
||||
default=None,
|
||||
help="Directory for the saml_settings.json file.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
settings_dir = options.get("dir")
|
||||
|
||||
if settings_dir is not None:
|
||||
settings_path = os.path.join(settings_dir, "saml_settings.json")
|
||||
if not os.path.isdir(settings_path):
|
||||
print(f"The directory '{settings_dir}' does not exist. Aborting...")
|
||||
return
|
||||
else:
|
||||
_, settings_path = get_settings_dir_and_path()
|
||||
create_saml_settings(settings_path)
|
70
openslides/saml/saml_settings.json.tpl
Normal file
70
openslides/saml/saml_settings.json.tpl
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"strict": true,
|
||||
"debug": true,
|
||||
"sp": {
|
||||
"entityId": "https://sp.domain.xyz/metadata/",
|
||||
"assertionConsumerService": {
|
||||
"url": "https://sp.domain.xyz/?acs",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": "https://sp.domain.xyz/?sls",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
},
|
||||
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
||||
"x509cert": "",
|
||||
"privateKey": ""
|
||||
},
|
||||
"idp": {
|
||||
"entityId": "https://idp.domain.xyz/metadata",
|
||||
"singleSignOnService": {
|
||||
"url": "https://idp.domain.xyz/sso",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": "https://idp.domain.xyz/slo",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
},
|
||||
"x509cert": ""
|
||||
},
|
||||
"security": {
|
||||
"nameIdEncrypted": false,
|
||||
"authnRequestsSigned": false,
|
||||
"logoutRequestSigned": false,
|
||||
"logoutResponseSigned": false,
|
||||
"signMetadata": false,
|
||||
"wantMessagesSigned": false,
|
||||
"wantAssertionsSigned": false,
|
||||
"wantNameId" : true,
|
||||
"wantNameIdEncrypted": false,
|
||||
"wantAssertionsEncrypted": false,
|
||||
"signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
||||
"digestAlgorithm": "http://www.w3.org/2000/09/xmldsig#sha1"
|
||||
},
|
||||
"contactPerson": {
|
||||
"technical": {
|
||||
"givenName": "technical_name",
|
||||
"emailAddress": "technical@example.com"
|
||||
},
|
||||
"support": {
|
||||
"givenName": "support_name",
|
||||
"emailAddress": "support@example.com"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
"en-US": {
|
||||
"name": "OpenSlides",
|
||||
"displayname": "OpenSlides",
|
||||
"url": "http://openslides.org"
|
||||
}
|
||||
},
|
||||
"generalSettings": {
|
||||
"loginButtonText": "Login via SAML",
|
||||
"changePasswordUrl": "https://idp.domain.xyz"
|
||||
},
|
||||
"attributeMapping": {
|
||||
"UserID": ["username", true],
|
||||
"FirstName": ["first_name", false],
|
||||
"LastName": ["last_name", false]
|
||||
}
|
||||
}
|
225
openslides/saml/settings.py
Normal file
225
openslides/saml/settings.py
Normal file
@ -0,0 +1,225 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
from .exceptions import SamlException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
README = """\
|
||||
Take care of this folder that could contain private key. Be sure that this folder never is published.
|
||||
OpenSlides SAML plugin expects that certs for the SP could be stored in this folder as:
|
||||
* sp.key Private Key
|
||||
* sp.crt Public cert
|
||||
* sp_new.crt Future Public cert
|
||||
Also you can use other cert to sign the metadata of the SP using the:
|
||||
* metadata.key
|
||||
* metadata.crt"""
|
||||
|
||||
|
||||
def get_settings_dir_and_path() -> Tuple[str, str]:
|
||||
"""
|
||||
Returns the settings directory and as the seconds return value
|
||||
the path to the saml settings file.
|
||||
"""
|
||||
try:
|
||||
settings_dir = os.path.dirname(os.path.abspath(settings.SETTINGS_FILEPATH))
|
||||
except AttributeError:
|
||||
raise SamlException(
|
||||
"'SETTINGS_FILEPATH' is not in your settings.py. "
|
||||
+ "Would you kindly add the following line: 'SETTINGS_FILEPATH = __file__'?"
|
||||
)
|
||||
settings_path = os.path.join(settings_dir, "saml_settings.json")
|
||||
return settings_dir, settings_path
|
||||
|
||||
|
||||
def create_saml_settings(
|
||||
settings_path: str = None, template: str = None, **context: str
|
||||
) -> None:
|
||||
"""
|
||||
Creates the SAML settings file 'saml_settings.json'
|
||||
|
||||
if the path is given, the settings will be written! If not, it is checked, if the
|
||||
settings do exists.
|
||||
"""
|
||||
# If settings_path is none, do not force writing the file.
|
||||
if settings_path is None:
|
||||
# Check, if the file exists and exit then.
|
||||
_, settings_path = get_settings_dir_and_path()
|
||||
if os.path.isfile(settings_path):
|
||||
return # it exist.
|
||||
|
||||
# OK, write the file.
|
||||
settings_path = os.path.realpath(settings_path)
|
||||
if template is None:
|
||||
with open(
|
||||
os.path.join(os.path.dirname(__file__), "saml_settings.json.tpl")
|
||||
) as template_file:
|
||||
template = template_file.read()
|
||||
content = template % context
|
||||
with open(settings_path, "w") as settings_file:
|
||||
settings_file.write(content)
|
||||
|
||||
# create cert folder and add thr README
|
||||
cert_dir = os.path.join(os.path.dirname(settings_path), "certs")
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
|
||||
# create README there
|
||||
readme_path = os.path.join(cert_dir, "README")
|
||||
if not os.path.isfile(readme_path):
|
||||
with open(readme_path, "w") as readme:
|
||||
readme.write(README)
|
||||
logger.info(f"Written README into the certs folder: {cert_dir}")
|
||||
logger.info(f"Created SAML settings at: {settings_path}")
|
||||
|
||||
|
||||
class SamlSettings:
|
||||
"""
|
||||
Holds all custom settings and saml settings from the saml_settings.json
|
||||
|
||||
Custom Settings:
|
||||
- general_settings: {
|
||||
loginButtonText: <str>,
|
||||
changePasswordUrl: <str>
|
||||
}
|
||||
- attribute_mapping: {
|
||||
<idp_attr>: [<OS_attr>, <lookup>]
|
||||
}
|
||||
- request_settings: {
|
||||
<key>: <value>,
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
create_saml_settings()
|
||||
self.load_settings()
|
||||
|
||||
def load_settings(self):
|
||||
# Try to open the settings file.
|
||||
content = None
|
||||
settings_dir, settings_path = get_settings_dir_and_path()
|
||||
try:
|
||||
with open(settings_path, "r") as settings_file:
|
||||
content = json.load(settings_file)
|
||||
except IOError:
|
||||
raise SamlException(
|
||||
f"Could not read settings file located at: {settings_path}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
raise SamlException(
|
||||
f"The settings file located at {settings_path} could not be loaded."
|
||||
)
|
||||
logger.info(f"Loaded settings: {settings_path}")
|
||||
|
||||
# Extract special settings
|
||||
self.load_general_settings(content)
|
||||
self.load_attribute_mapping(content)
|
||||
self.load_request_settings(content)
|
||||
|
||||
# Load saml settings
|
||||
self.saml_settings = OneLogin_Saml2_Settings(
|
||||
content, custom_base_path=settings_dir
|
||||
)
|
||||
|
||||
def load_general_settings(self, content):
|
||||
if "generalSettings" not in content:
|
||||
raise SamlException(
|
||||
"The saml_settings.json does not contain 'generalSettings'!"
|
||||
)
|
||||
self.general_settings = content.pop("generalSettings")
|
||||
|
||||
if not isinstance(self.general_settings, dict):
|
||||
raise SamlException("The generalSettings have to be a dict.")
|
||||
if "loginButtonText" not in self.general_settings:
|
||||
raise SamlException("The loginButtonText is not given.")
|
||||
if not isinstance(self.general_settings["loginButtonText"], str):
|
||||
raise SamlException("The loginButtonText has to be a string.")
|
||||
if "changePasswordUrl" not in self.general_settings:
|
||||
raise SamlException("The changePasswordUrl is not given.")
|
||||
if not isinstance(self.general_settings["changePasswordUrl"], str):
|
||||
raise SamlException("The changePasswordUrl has to be a string.")
|
||||
|
||||
def load_attribute_mapping(self, content):
|
||||
if "attributeMapping" not in content:
|
||||
raise SamlException(
|
||||
"The saml_settings.json does not contain 'attributeMapping'!"
|
||||
)
|
||||
self.attribute_mapping = content.pop("attributeMapping")
|
||||
|
||||
allowed_attributes = [
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"gender",
|
||||
"email",
|
||||
"title",
|
||||
"structure_level",
|
||||
"number",
|
||||
"comment",
|
||||
"is_active",
|
||||
"is_present",
|
||||
"is_committee",
|
||||
]
|
||||
|
||||
one_lookup_true = False
|
||||
if not isinstance(self.attribute_mapping, dict):
|
||||
raise SamlException("The attributeMapping is not a dict.")
|
||||
for key, value in self.attribute_mapping.items():
|
||||
if not isinstance(key, str):
|
||||
raise SamlException(f'The key "{key}" has to be a string.')
|
||||
if not isinstance(value, list):
|
||||
raise SamlException(f'The value from key "{key}" has to be a list.')
|
||||
if not len(value) == 2:
|
||||
raise SamlException(f'The value from key "{key}" has ot two entries.')
|
||||
os_attribute, lookup = value
|
||||
if not isinstance(os_attribute, str):
|
||||
raise SamlException(
|
||||
f'The first value from key "{key}" has to be a string.'
|
||||
)
|
||||
if os_attribute not in allowed_attributes:
|
||||
all_attrs = ", ".join(allowed_attributes)
|
||||
raise SamlException(
|
||||
f"The attribute {os_attribute} is not allowed. All allowed attributes: {all_attrs}"
|
||||
)
|
||||
if not isinstance(value[1], bool):
|
||||
raise SamlException(
|
||||
f'The lookup value from key "{key}" has to be a boolean.'
|
||||
)
|
||||
if value[1]:
|
||||
one_lookup_true = True
|
||||
|
||||
if not one_lookup_true:
|
||||
raise SamlException(
|
||||
"At least one attribute has to be used as a lookup value."
|
||||
)
|
||||
|
||||
def load_request_settings(self, content):
|
||||
self.request_settings: Dict[str, str] = {}
|
||||
if "requestSettings" in content:
|
||||
self.request_settings = content.pop("requestSettings")
|
||||
|
||||
if not isinstance(self.request_settings, dict):
|
||||
raise SamlException("The requestSettings have to be a dict")
|
||||
if "https" in self.request_settings and self.request_settings[
|
||||
"https"
|
||||
] not in ("on", "off"):
|
||||
raise SamlException('The https value must be "on" or "off"')
|
||||
|
||||
|
||||
saml_settings = None
|
||||
|
||||
|
||||
def get_saml_settings():
|
||||
global saml_settings
|
||||
return saml_settings
|
||||
|
||||
|
||||
def load_settings():
|
||||
global saml_settings
|
||||
saml_settings = SamlSettings()
|
10
openslides/saml/urls.py
Normal file
10
openslides/saml/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.conf.urls import url
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^$", csrf_exempt(views.SamlView.as_view())),
|
||||
url(r"^metadata/$", views.serve_metadata),
|
||||
]
|
25
openslides/saml/user_backend.py
Normal file
25
openslides/saml/user_backend.py
Normal file
@ -0,0 +1,25 @@
|
||||
from typing import List
|
||||
|
||||
from openslides.users.user_backend import BaseUserBackend
|
||||
|
||||
from .settings import get_saml_settings
|
||||
|
||||
|
||||
class SamlUserBackend(BaseUserBackend):
|
||||
"""
|
||||
User backend for SAML users.
|
||||
Disallowed update keys are the keys given by the IDP.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.disallowed_update_keys: List[str] = [
|
||||
os_attribute
|
||||
for os_attribute, _ in get_saml_settings().attribute_mapping.values()
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "saml"
|
||||
|
||||
def get_disallowed_update_keys(self) -> List[str]:
|
||||
return self.disallowed_update_keys
|
225
openslides/saml/views.py
Normal file
225
openslides/saml/views.py
Normal file
@ -0,0 +1,225 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import (
|
||||
get_user_model,
|
||||
login as auth_login,
|
||||
logout as auth_logout,
|
||||
)
|
||||
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseServerError
|
||||
from django.views.generic import View
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||
|
||||
from .settings import get_saml_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SamlView(View):
|
||||
"""
|
||||
View for the SAML Interface.
|
||||
Some SAML termina:
|
||||
- IDP: Identity provider. The service providing the actual login.
|
||||
- SP: Service provider. That is OpenSlides.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
return super().__init__(*args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" POST requests should do the same as GET requests. """
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Switches specific saml types
|
||||
|
||||
First user-initiated requests:
|
||||
- sso: SingleSignOn -> Redirect to IDP
|
||||
- sso2: Also SingleSingOn with a special redirect url
|
||||
- slo: SingleLogOut: Logs the user out of OpenSlides and the IDP.
|
||||
To only log out from OpenSlides, use the standard logout url.
|
||||
|
||||
Second, requests from the IDP:
|
||||
- acs: AssertionConsumerService: Response from the IDP to the SP. Contains
|
||||
login data for a valid user
|
||||
- sls: SingleLogoutService: Request to log the user out.
|
||||
|
||||
TODO: Nicer errors
|
||||
"""
|
||||
url, auth = self.get_saml_auth(request)
|
||||
|
||||
if "sso" in request.GET:
|
||||
return HttpResponseRedirect(auth.login())
|
||||
|
||||
elif "sso2" in request.GET:
|
||||
return_to = url + "/"
|
||||
return HttpResponseRedirect(auth.login(return_to))
|
||||
|
||||
elif "slo" in request.GET:
|
||||
name_id = request.session.get("samlNameId")
|
||||
session_index = request.session.get("samlSessionIndex")
|
||||
auth_logout(request) # Logout from OpenSlides
|
||||
if name_id is None and session_index is None:
|
||||
# Not a SAML user
|
||||
return HttpResponseRedirect("/")
|
||||
else:
|
||||
request.session["samlNameId"] = None
|
||||
request.session["samlSessionIndex"] = None
|
||||
# Logout from IDP
|
||||
return HttpResponseRedirect(
|
||||
auth.logout(name_id=name_id, session_index=session_index)
|
||||
)
|
||||
|
||||
elif "acs" in request.GET:
|
||||
error_msg = ""
|
||||
try:
|
||||
auth.process_response()
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
error_msg = "".join(errors)
|
||||
except OneLogin_Saml2_Error as e:
|
||||
auth_errors = auth.get_errors()
|
||||
if auth_errors:
|
||||
auth_errors = "".join(auth_errors)
|
||||
error_msg = f"auth: {auth_errors}, "
|
||||
error_msg += f"detail: {str(e)}, code: {e.code}"
|
||||
|
||||
if error_msg:
|
||||
return HttpResponseServerError(content=error_msg)
|
||||
|
||||
request.session["samlNameId"] = auth.get_nameid()
|
||||
request.session["samlSessionIndex"] = auth.get_session_index()
|
||||
self.login_user(request, auth.get_attributes())
|
||||
|
||||
if "RelayState" in request.POST and url != request.POST["RelayState"]:
|
||||
return HttpResponseRedirect(
|
||||
auth.redirect_to(request.POST["RelayState"])
|
||||
)
|
||||
else:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
elif "sls" in request.GET:
|
||||
error_msg = ""
|
||||
try:
|
||||
url = auth.process_slo(
|
||||
delete_session_cb=lambda: request.session.flush()
|
||||
)
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
error_msg = "".join(errors)
|
||||
except OneLogin_Saml2_Error as e:
|
||||
auth_errors = auth.get_errors()
|
||||
if auth_errors:
|
||||
auth_errors = "".join(auth_errors)
|
||||
error_msg = f"auth: {auth_errors}, "
|
||||
error_msg += f"detail: {str(e)}, code: {e.code}"
|
||||
|
||||
if error_msg:
|
||||
return HttpResponseServerError(content=error_msg)
|
||||
else:
|
||||
return HttpResponseRedirect(url or "/")
|
||||
|
||||
else:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
def login_user(self, request, attributes):
|
||||
"""
|
||||
Logs in a user given by the attributes
|
||||
"""
|
||||
verbose_attrs = ", ".join(attributes.keys())
|
||||
logger.info(f"Login saml user with these attributes: {verbose_attrs}")
|
||||
|
||||
# Get arguments for querying the one user
|
||||
queryargs = self.get_queryargs(attributes)
|
||||
User = get_user_model()
|
||||
user, created = User.objects.get_or_create(**queryargs)
|
||||
if created:
|
||||
logger.info(
|
||||
f"Created new saml user with id {user.id} and username {user.username}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Found saml user with id {user.id} and username {user.username}"
|
||||
)
|
||||
self.update_user(user, queryargs["defaults"])
|
||||
auth_login(request, user)
|
||||
|
||||
def get_queryargs(self, attributes):
|
||||
"""
|
||||
Build the arguments for getting or creating a user
|
||||
attributes with lookup=True are "normal" queryargs.
|
||||
The rest are default values. Ensures the auth_type
|
||||
to be "saml".
|
||||
"""
|
||||
queryargs = {}
|
||||
defaults = {}
|
||||
mapping = get_saml_settings().attribute_mapping
|
||||
for key, (value, lookup) in mapping.items():
|
||||
attribute = attributes.get(key)
|
||||
if isinstance(attribute, list):
|
||||
attribute = ", ".join(attribute)
|
||||
|
||||
if lookup:
|
||||
queryargs[value] = attribute
|
||||
else:
|
||||
defaults[value] = attribute
|
||||
|
||||
# Add the auth_type to the defaults:
|
||||
defaults["auth_type"] = "saml"
|
||||
|
||||
queryargs["defaults"] = defaults
|
||||
verbose_queryargs = json.dumps(queryargs)
|
||||
logger.debug(f"User queryargs: {verbose_queryargs}")
|
||||
return queryargs
|
||||
|
||||
def update_user(self, user, attributes):
|
||||
""" Updates a user with the new attributes """
|
||||
if "auth_type" in attributes:
|
||||
del attributes["auth_type"]
|
||||
|
||||
changed = False
|
||||
for key, value in attributes.items():
|
||||
user_attr = getattr(user, key)
|
||||
if user_attr != value:
|
||||
setattr(user, key, value)
|
||||
changed = True
|
||||
if changed:
|
||||
user.save()
|
||||
|
||||
def get_saml_auth(self, request):
|
||||
saml_request = dict(get_saml_settings().request_settings)
|
||||
# Update not existing keys
|
||||
saml_request["https"] = saml_request.get(
|
||||
"https", "on" if request.is_secure() else "off"
|
||||
)
|
||||
saml_request["http_host"] = saml_request.get(
|
||||
"http_host", request.META["HTTP_HOST"]
|
||||
)
|
||||
saml_request["script_name"] = saml_request.get(
|
||||
"script_name", request.META["PATH_INFO"]
|
||||
)
|
||||
saml_request["server_port"] = saml_request.get(
|
||||
"server_port", request.META["SERVER_PORT"]
|
||||
)
|
||||
# add get and post data
|
||||
saml_request["get_data"] = request.GET.copy()
|
||||
saml_request["post_data"] = request.POST.copy()
|
||||
return (
|
||||
OneLogin_Saml2_Utils.get_self_url(saml_request),
|
||||
OneLogin_Saml2_Auth(saml_request, get_saml_settings().saml_settings),
|
||||
)
|
||||
|
||||
|
||||
def serve_metadata(request, *args, **kwargs):
|
||||
settings = get_saml_settings().saml_settings
|
||||
metadata = settings.get_sp_metadata()
|
||||
errors = settings.validate_metadata(metadata)
|
||||
|
||||
if len(errors) > 0:
|
||||
return HttpResponseServerError(content=", ".join(errors))
|
||||
else:
|
||||
return HttpResponse(content=metadata, content_type="text/xml")
|
@ -1,5 +1,6 @@
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from openslides.saml import SAML_ENABLED
|
||||
from openslides.utils.plugins import get_all_plugin_urlpatterns
|
||||
|
||||
|
||||
@ -9,3 +10,6 @@ urlpatterns += [
|
||||
url(r"^core/", include("openslides.core.urls")),
|
||||
url(r"^users/", include("openslides.users.urls")),
|
||||
]
|
||||
|
||||
if SAML_ENABLED:
|
||||
urlpatterns += [url(r"^saml/", include("openslides.saml.urls"))]
|
||||
|
@ -23,18 +23,26 @@ class UserAccessPermissions(BaseAccessPermissions):
|
||||
USERCANSEEEXTRASERIALIZER_FIELDS,
|
||||
)
|
||||
|
||||
def filtered_data(full_data, whitelist):
|
||||
def filtered_data(full_data, whitelist, whitelist_operator=None):
|
||||
"""
|
||||
Returns a new dict like full_data but only with whitelisted keys.
|
||||
If the whitelist_operator is given and the full_data-user is the
|
||||
oeperator (the user with user_id), the whitelist_operator will
|
||||
be used instead of the whitelist.
|
||||
"""
|
||||
return {key: full_data[key] for key in whitelist}
|
||||
if whitelist_operator is not None and full_data["id"] == user_id:
|
||||
return {key: full_data[key] for key in whitelist_operator}
|
||||
else:
|
||||
return {key: full_data[key] for key in whitelist}
|
||||
|
||||
# We have five sets of data to be sent:
|
||||
# We have some sets of data to be sent:
|
||||
# * full data i. e. all fields (including session_auth_hash),
|
||||
# * all data i. e. all fields but not session_auth_hash,
|
||||
# * many data i. e. all fields but not the default password and session_auth_hash,
|
||||
# * little data i. e. all fields but not the default password, session_auth_hash,
|
||||
# comments, gender, email, last_email_send and active status,
|
||||
# comments, gender, email, last_email_send, active status and auth_type
|
||||
# * own data i. e. all little data fields plus email and gender. This is applied
|
||||
# to the own user, if he just can see little or no data.
|
||||
# * no data.
|
||||
|
||||
# Prepare field set for users with "all" data, "many" data and with "little" data.
|
||||
@ -44,9 +52,12 @@ class UserAccessPermissions(BaseAccessPermissions):
|
||||
all_data_fields.add("default_password")
|
||||
many_data_fields = all_data_fields.copy()
|
||||
many_data_fields.discard("default_password")
|
||||
litte_data_fields = set(USERCANSEESERIALIZER_FIELDS)
|
||||
litte_data_fields.add("groups_id")
|
||||
litte_data_fields.discard("groups")
|
||||
little_data_fields = set(USERCANSEESERIALIZER_FIELDS)
|
||||
little_data_fields.add("groups_id")
|
||||
little_data_fields.discard("groups")
|
||||
own_data_fields = set(little_data_fields)
|
||||
own_data_fields.add("email")
|
||||
own_data_fields.add("gender")
|
||||
|
||||
# Check user permissions.
|
||||
if await async_has_perm(user_id, "users.can_see_name"):
|
||||
@ -56,7 +67,10 @@ class UserAccessPermissions(BaseAccessPermissions):
|
||||
else:
|
||||
data = [filtered_data(full, many_data_fields) for full in full_data]
|
||||
else:
|
||||
data = [filtered_data(full, litte_data_fields) for full in full_data]
|
||||
data = [
|
||||
filtered_data(full, little_data_fields, own_data_fields)
|
||||
for full in full_data
|
||||
]
|
||||
else:
|
||||
# Build a list of users, that can be seen without any permissions (with little fields).
|
||||
|
||||
@ -84,7 +98,7 @@ class UserAccessPermissions(BaseAccessPermissions):
|
||||
|
||||
# Parse data.
|
||||
data = [
|
||||
filtered_data(full, litte_data_fields)
|
||||
filtered_data(full, little_data_fields, own_data_fields)
|
||||
for full in full_data
|
||||
if full["id"] in user_ids
|
||||
]
|
||||
|
@ -2,10 +2,13 @@ from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
|
||||
from .user_backend import DefaultUserBackend, user_backend_manager
|
||||
|
||||
|
||||
class UsersAppConfig(AppConfig):
|
||||
name = "openslides.users"
|
||||
verbose_name = "OpenSlides Users"
|
||||
user_backend_class = DefaultUserBackend
|
||||
|
||||
def ready(self):
|
||||
# Import all required stuff.
|
||||
@ -39,6 +42,9 @@ class UsersAppConfig(AppConfig):
|
||||
self.get_model("PersonalNote").get_collection_string(), PersonalNoteViewSet
|
||||
)
|
||||
|
||||
def get_startup_hooks(self):
|
||||
return {30: user_backend_manager.collect_backends_from_apps}
|
||||
|
||||
def get_config_variables(self):
|
||||
from .config_variables import get_config_variables
|
||||
|
||||
@ -55,6 +61,7 @@ class UsersAppConfig(AppConfig):
|
||||
def get_angular_constants(self):
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Permissions
|
||||
permissions = []
|
||||
for permission in Permission.objects.all():
|
||||
permissions.append(
|
||||
@ -65,4 +72,8 @@ class UsersAppConfig(AppConfig):
|
||||
),
|
||||
}
|
||||
)
|
||||
return {"permissions": permissions}
|
||||
|
||||
# Backends
|
||||
backends = user_backend_manager.get_backends_for_client()
|
||||
|
||||
return {"Permissions": permissions, "UserBackends": backends}
|
||||
|
16
openslides/users/migrations/0012_user_auth_type.py
Normal file
16
openslides/users/migrations/0012_user_auth_type.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Generated by Django 2.2.4 on 2019-08-21 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0011_postgresql_auth_group_id_sequence")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="auth_type",
|
||||
field=models.CharField(default="default", max_length=64),
|
||||
)
|
||||
]
|
@ -122,6 +122,8 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
|
||||
|
||||
username = models.CharField(max_length=255, unique=True, blank=True)
|
||||
|
||||
auth_type = models.CharField(max_length=64, default="default")
|
||||
|
||||
first_name = models.CharField(max_length=255, blank=True)
|
||||
|
||||
last_name = models.CharField(max_length=255, blank=True)
|
||||
|
@ -33,6 +33,7 @@ USERCANSEEEXTRASERIALIZER_FIELDS = USERCANSEESERIALIZER_FIELDS + (
|
||||
"last_email_send",
|
||||
"comment",
|
||||
"is_active",
|
||||
"auth_type",
|
||||
)
|
||||
|
||||
|
||||
@ -60,7 +61,7 @@ class UserFullSerializer(ModelSerializer):
|
||||
"default_password",
|
||||
"session_auth_hash",
|
||||
)
|
||||
read_only_fields = ("last_email_send",)
|
||||
read_only_fields = ("last_email_send", "auth_type")
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
|
82
openslides/users/user_backend.py
Normal file
82
openslides/users/user_backend.py
Normal file
@ -0,0 +1,82 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from openslides.utils import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserBackendException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BaseUserBackend:
|
||||
"""
|
||||
Base user backend providing methods to overwrite and provides
|
||||
a representation for clients. The backendname must be unique.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
raise NotImplementedError("Each Backend must provie a name")
|
||||
|
||||
def get_disallowed_update_keys(self) -> List[str]:
|
||||
raise NotImplementedError("Each Backend must provie a name")
|
||||
|
||||
def for_client(self) -> Dict[str, Any]:
|
||||
return {"disallowedUpdateKeys": self.get_disallowed_update_keys()}
|
||||
|
||||
|
||||
class DefaultUserBackend(BaseUserBackend):
|
||||
""" The default user backend for OpenSlides """
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "default"
|
||||
|
||||
def get_disallowed_update_keys(self) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
class UserBackendManager:
|
||||
"""
|
||||
Manages user backends.
|
||||
|
||||
Can collect backends from app configs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.backends: Dict[str, BaseUserBackend] = {}
|
||||
|
||||
def collect_backends_from_apps(self):
|
||||
""" Iterate through app configs and get an optional "user_backend_class" for a backend """
|
||||
for app in apps.get_app_configs():
|
||||
user_backend_class = getattr(app, "user_backend_class", None)
|
||||
if user_backend_class:
|
||||
self.register_user_backend(user_backend_class())
|
||||
|
||||
def register_user_backend(self, backend: BaseUserBackend):
|
||||
""" Registeres a user backend """
|
||||
if backend.name in self.backends:
|
||||
raise UserBackendException(
|
||||
f"The user backend {backend.name} already exists."
|
||||
)
|
||||
self.backends[backend.name] = backend
|
||||
logger.debug(f'Registered user backend "{backend.name}"')
|
||||
|
||||
def get_backend(self, name: str) -> Optional[BaseUserBackend]:
|
||||
if name not in self.backends:
|
||||
all_backend_names = ", ".join(self.backends.keys())
|
||||
raise UserBackendException(
|
||||
f'The backend "{name}" is not registered. All Backends: "{all_backend_names}"'
|
||||
)
|
||||
return self.backends[name]
|
||||
|
||||
def get_backends_for_client(self) -> Dict[str, Dict[str, Any]]:
|
||||
""" Formats the backends for the client """
|
||||
return {name: backend.for_client() for name, backend in self.backends.items()}
|
||||
|
||||
|
||||
user_backend_manager = UserBackendManager()
|
@ -21,6 +21,8 @@ from django.http.request import QueryDict
|
||||
from django.utils.encoding import force_bytes, force_text
|
||||
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
||||
|
||||
from openslides.saml import SAML_ENABLED
|
||||
|
||||
from ..core.config import config
|
||||
from ..core.signals import permission_change
|
||||
from ..utils.auth import (
|
||||
@ -48,6 +50,7 @@ from .access_permissions import (
|
||||
)
|
||||
from .models import Group, PersonalNote, User
|
||||
from .serializers import GroupSerializer, PermissionRelatedField
|
||||
from .user_backend import user_backend_manager
|
||||
|
||||
|
||||
# Viewsets for the REST API
|
||||
@ -126,8 +129,24 @@ class UserViewSet(ModelViewSet):
|
||||
# Remove fields that the user is not allowed to change.
|
||||
# The list() is required because we want to use del inside the loop.
|
||||
for key in list(request.data.keys()):
|
||||
if key not in ("username", "about_me"):
|
||||
if key not in ("username", "about_me", "email"):
|
||||
del request.data[key]
|
||||
|
||||
user_backend = user_backend_manager.get_backend(user.auth_type)
|
||||
if user_backend:
|
||||
disallowed_keys = user_backend.get_disallowed_update_keys()
|
||||
for key in list(request.data.keys()):
|
||||
if key in disallowed_keys:
|
||||
del request.data[key]
|
||||
|
||||
# Hack to make the serializers validation work again if no username, last- or firstname is given:
|
||||
if (
|
||||
"username" not in request.data
|
||||
and "first_name" not in request.data
|
||||
and "last_name" not in request.data
|
||||
):
|
||||
request.data["username"] = user.username
|
||||
|
||||
response = super().update(request, *args, **kwargs)
|
||||
return response
|
||||
|
||||
@ -150,6 +169,13 @@ class UserViewSet(ModelViewSet):
|
||||
Expected data: { pasword: <the new password> }
|
||||
"""
|
||||
user = self.get_object()
|
||||
if user.auth_type != "default":
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "The user does not have the login information stored in OpenSlides"
|
||||
}
|
||||
)
|
||||
|
||||
password = request.data.get("password")
|
||||
if not isinstance(password, str):
|
||||
raise ValidationError({"detail": "Password has to be a string."})
|
||||
@ -173,7 +199,7 @@ class UserViewSet(ModelViewSet):
|
||||
self.assert_list_of_ints(ids)
|
||||
|
||||
# Exclude the request user
|
||||
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
|
||||
users = self.bulk_get_users(request, ids)
|
||||
for user in users:
|
||||
password = User.objects.make_random_password()
|
||||
user.set_password(password)
|
||||
@ -192,7 +218,7 @@ class UserViewSet(ModelViewSet):
|
||||
self.assert_list_of_ints(ids)
|
||||
|
||||
# Exclude the request user
|
||||
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
|
||||
users = self.bulk_get_users(request, ids)
|
||||
# Validate all default passwords
|
||||
for user in users:
|
||||
try:
|
||||
@ -236,7 +262,7 @@ class UserViewSet(ModelViewSet):
|
||||
if not isinstance(value, bool):
|
||||
raise ValidationError({"detail": "value must be true or false"})
|
||||
|
||||
users = User.objects.filter(pk__in=ids)
|
||||
users = User.objects.filter(auth_type="default").filter(pk__in=ids)
|
||||
if field == "is_active":
|
||||
users = users.exclude(pk=request.user.id)
|
||||
for user in users:
|
||||
@ -265,7 +291,7 @@ class UserViewSet(ModelViewSet):
|
||||
if action not in ("add", "remove"):
|
||||
raise ValidationError({"detail": "The action must be add or remove"})
|
||||
|
||||
users = User.objects.exclude(pk=request.user.id).filter(pk__in=user_ids)
|
||||
users = self.bulk_get_users(request, user_ids)
|
||||
groups = list(Group.objects.filter(pk__in=group_ids))
|
||||
|
||||
for user in users:
|
||||
@ -287,12 +313,23 @@ class UserViewSet(ModelViewSet):
|
||||
self.assert_list_of_ints(ids)
|
||||
|
||||
# Exclude the request user
|
||||
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
|
||||
users = self.bulk_get_users(request, ids)
|
||||
for user in list(users):
|
||||
user.delete()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def bulk_get_users(self, request, ids):
|
||||
"""
|
||||
Get all users for the given ids. Exludes the request user and all
|
||||
users with a non-default auth_type.
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(auth_type="default")
|
||||
.exclude(pk=request.user.id)
|
||||
.filter(pk__in=ids)
|
||||
)
|
||||
|
||||
@list_route(methods=["post"])
|
||||
@transaction.atomic
|
||||
def mass_import(self, request):
|
||||
@ -673,14 +710,19 @@ class WhoAmIDataView(APIView):
|
||||
"""
|
||||
Appends the user id to the context. Uses None for the anonymous
|
||||
user. Appends also a flag if guest users are enabled in the config.
|
||||
Appends also the serialized user if available.
|
||||
Appends also the serialized user if available and auth_type.
|
||||
"""
|
||||
user_id = self.request.user.pk or 0
|
||||
guest_enabled = anonymous_is_enabled()
|
||||
|
||||
auth_type = "default"
|
||||
if user_id:
|
||||
user_data = async_to_sync(element_cache.get_element_data)(
|
||||
self.request.user.get_collection_string(), user_id, user_id
|
||||
user_full_data = async_to_sync(element_cache.get_element_data)(
|
||||
self.request.user.get_collection_string(), user_id
|
||||
)
|
||||
auth_type = user_full_data["auth_type"]
|
||||
user_data = async_to_sync(element_cache.restrict_element_data)(
|
||||
user_full_data, self.request.user.get_collection_string(), user_id
|
||||
)
|
||||
group_ids = user_data["groups_id"] or [GROUP_DEFAULT_PK]
|
||||
else:
|
||||
@ -697,6 +739,7 @@ class WhoAmIDataView(APIView):
|
||||
"user_id": user_id or None,
|
||||
"guest_enabled": guest_enabled,
|
||||
"user": user_data,
|
||||
"auth_type": auth_type,
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
@ -722,6 +765,8 @@ class UserLoginView(WhoAmIDataView):
|
||||
if not form.is_valid():
|
||||
raise ValidationError({"detail": "Username or password is not correct."})
|
||||
self.user = form.get_user()
|
||||
if self.user.auth_type != "default":
|
||||
raise ValidationError({"detail": "Please login via your identity provider"})
|
||||
auth_login(self.request, self.user)
|
||||
return super().post(*args, **kwargs)
|
||||
|
||||
@ -755,6 +800,11 @@ class UserLoginView(WhoAmIDataView):
|
||||
# Add the theme, so the loginpage is themed correctly
|
||||
context["theme"] = config["openslides_theme"]
|
||||
context["logo_web_header"] = config["logo_web_header"]
|
||||
|
||||
if SAML_ENABLED:
|
||||
from openslides.saml.settings import get_saml_settings
|
||||
|
||||
context["saml_settings"] = get_saml_settings().general_settings
|
||||
else:
|
||||
# self.request.method == 'POST'
|
||||
context.update(self.get_whoami_data())
|
||||
@ -795,6 +845,7 @@ class SetPasswordView(APIView):
|
||||
if not (
|
||||
has_perm(user, "users.can_change_password")
|
||||
or has_perm(user, "users.can_manage")
|
||||
or user.auth_type != "default"
|
||||
):
|
||||
self.permission_denied(request)
|
||||
if user.check_password(request.data["old_password"]):
|
||||
@ -900,7 +951,7 @@ class PasswordResetView(APIView):
|
||||
resetting their password.
|
||||
"""
|
||||
active_users = User.objects.filter(
|
||||
**{"email__iexact": email, "is_active": True}
|
||||
**{"email__iexact": email, "is_active": True, "auth_type": "default"}
|
||||
)
|
||||
return [u for u in active_users if u.has_usable_password()]
|
||||
|
||||
|
@ -258,11 +258,18 @@ class ElementCache:
|
||||
element = json.loads(encoded_element.decode()) # type: ignore
|
||||
|
||||
if user_id is not None:
|
||||
restricter = self.cachables[collection_string].restrict_elements
|
||||
restricted_elements = await restricter(user_id, [element])
|
||||
element = restricted_elements[0] if restricted_elements else None
|
||||
element = await self.restrict_element_data(
|
||||
element, collection_string, user_id
|
||||
)
|
||||
return element
|
||||
|
||||
async def restrict_element_data(
|
||||
self, element: Dict[str, Any], collection_string: str, user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
restricter = self.cachables[collection_string].restrict_elements
|
||||
restricted_elements = await restricter(user_id, [element])
|
||||
return restricted_elements[0] if restricted_elements else None
|
||||
|
||||
async def get_data_since(
|
||||
self, user_id: Optional[int] = None, change_id: int = 0, max_change_id: int = -1
|
||||
) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]:
|
||||
|
@ -38,3 +38,7 @@ def set_constants(value: Dict[str, Any]) -> None:
|
||||
"""
|
||||
global constants
|
||||
constants = value
|
||||
|
||||
|
||||
def set_constants_from_apps() -> None:
|
||||
set_constants(get_constants_from_apps())
|
||||
|
@ -118,6 +118,14 @@ if use_redis:
|
||||
'socket_timeout': 2
|
||||
}
|
||||
|
||||
# SAML integration
|
||||
# Please read https://github.com/OpenSlides/OpenSlides/blob/master/openslides/saml/README.md
|
||||
# for additional requirements.
|
||||
|
||||
ENABLE_SAML = False
|
||||
if ENABLE_SAML:
|
||||
INSTALLED_APPS += ['openslides.saml']
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
||||
@ -174,3 +182,5 @@ LOGGING = {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
SETTINGS_FILEPATH = __file__
|
||||
|
37
openslides/utils/startup.py
Normal file
37
openslides/utils/startup.py
Normal file
@ -0,0 +1,37 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, List
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from . import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_startup_hooks() -> None:
|
||||
"""
|
||||
Collects all hooks via `get_startup_hooks` (optional) from all
|
||||
app configs. Sort the hooks witrh their weight and execute them in order.
|
||||
"""
|
||||
if os.environ.get("NO_STARTUP"):
|
||||
return
|
||||
|
||||
startup_hooks: Dict[int, List[Callable[[], None]]] = defaultdict(list)
|
||||
for app in apps.get_app_configs():
|
||||
try:
|
||||
get_startup_hooks = app.get_startup_hooks
|
||||
except AttributeError:
|
||||
# The app doesn't have this method. Continue to next app.
|
||||
continue
|
||||
app_hooks = get_startup_hooks()
|
||||
for weight, hooks in app_hooks.items():
|
||||
if not isinstance(hooks, list):
|
||||
hooks = [hooks]
|
||||
startup_hooks[weight].extend(hooks)
|
||||
|
||||
for weight in sorted(startup_hooks.keys()):
|
||||
for hook in startup_hooks[weight]:
|
||||
logger.debug(f'Running startup hook "{hook.__name__}"')
|
||||
hook()
|
@ -19,7 +19,7 @@ force_grid_wrap = 0
|
||||
use_parentheses = true
|
||||
line_length = 88
|
||||
known_first_party = openslides
|
||||
known_third_party = pytest
|
||||
known_third_party = pytest,onelogin
|
||||
|
||||
[mypy]
|
||||
ignore_missing_imports = true
|
||||
|
@ -86,3 +86,13 @@ def reset_cache(request):
|
||||
|
||||
# Set constant default change_id
|
||||
cast(MemoryCacheProvider, element_cache.cache_provider).default_change_id = 1
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def set_default_user_backend(request):
|
||||
"""
|
||||
Sets the userbackend once.
|
||||
"""
|
||||
from openslides.users.user_backend import user_backend_manager
|
||||
|
||||
user_backend_manager.collect_backends_from_apps()
|
||||
|
@ -9,10 +9,10 @@ from django.utils.crypto import get_random_string
|
||||
|
||||
from openslides.agenda.models import Item, ListOfSpeakers
|
||||
from openslides.assignments.models import Assignment
|
||||
from openslides.core.apps import startup
|
||||
from openslides.motions.models import Motion
|
||||
from openslides.topics.models import Topic
|
||||
from openslides.users.models import Group, User
|
||||
from openslides.utils.startup import run_startup_hooks
|
||||
|
||||
|
||||
MOTION_NUMBER_OF_PARAGRAPHS = 4
|
||||
@ -118,7 +118,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
startup()
|
||||
run_startup_hooks()
|
||||
self.create_topics(options)
|
||||
self.create_motions(options)
|
||||
self.create_assignments(options)
|
||||
|
@ -15,7 +15,13 @@ class TestWhoAmIView(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
json.loads(response.content.decode()),
|
||||
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
|
||||
{
|
||||
"auth_type": "default",
|
||||
"user_id": None,
|
||||
"user": None,
|
||||
"permissions": [],
|
||||
"guest_enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_authenticated_user(self):
|
||||
@ -58,7 +64,13 @@ class TestUserLogoutView(TestCase):
|
||||
self.assertFalse(hasattr(self.client.session, "test_key"))
|
||||
self.assertEqual(
|
||||
json.loads(response.content.decode()),
|
||||
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
|
||||
{
|
||||
"auth_type": "default",
|
||||
"user_id": None,
|
||||
"user": None,
|
||||
"permissions": [],
|
||||
"guest_enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -72,7 +84,12 @@ class TestUserLoginView(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(json.loads(response.content.decode()).get("login_info_text"))
|
||||
content = json.loads(response.content.decode())
|
||||
self.assertTrue("login_info_text" in content)
|
||||
self.assertTrue("privacy_policy" in content)
|
||||
self.assertTrue("legal_notice" in content)
|
||||
self.assertTrue("theme" in content)
|
||||
self.assertTrue("logo_web_header" in content)
|
||||
|
||||
def test_post_no_data(self):
|
||||
response = self.client.post(self.url)
|
||||
@ -85,7 +102,12 @@ class TestUserLoginView(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(json.loads(response.content.decode()).get("user_id"), 1)
|
||||
content = json.loads(response.content.decode())
|
||||
self.assertEqual(content.get("user_id"), 1)
|
||||
self.assertTrue(isinstance(content.get("user"), dict))
|
||||
self.assertTrue(isinstance(content.get("permissions"), list))
|
||||
self.assertFalse(content.get("guest_enabled", True))
|
||||
self.assertEqual(content.get("auth_type"), "default")
|
||||
|
||||
def test_post_incorrect_data(self):
|
||||
response = self.client.post(
|
||||
|
56
tests/unit/users/test_user_backend.py
Normal file
56
tests/unit/users/test_user_backend.py
Normal file
@ -0,0 +1,56 @@
|
||||
from typing import List
|
||||
from unittest import TestCase
|
||||
|
||||
from openslides.users.user_backend import (
|
||||
BaseUserBackend,
|
||||
UserBackendException,
|
||||
UserBackendManager,
|
||||
)
|
||||
|
||||
|
||||
class TUserBackend(BaseUserBackend):
|
||||
|
||||
disallowed_update_keys = ["test_key1", "another_test_key"]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "test_backend"
|
||||
|
||||
def get_disallowed_update_keys(self) -> List[str]:
|
||||
return self.disallowed_update_keys
|
||||
|
||||
|
||||
class UserManagerTest(TestCase):
|
||||
def setUp(self):
|
||||
self.manager = UserBackendManager()
|
||||
|
||||
def test_register_backend(self):
|
||||
self.manager.register_user_backend(TUserBackend())
|
||||
self.assertTrue("test_backend" in self.manager.backends)
|
||||
|
||||
def test_get_backend(self):
|
||||
backend = TUserBackend()
|
||||
self.manager.register_user_backend(backend)
|
||||
self.assertEqual(self.manager.get_backend("test_backend"), backend)
|
||||
|
||||
def test_format_backends(self):
|
||||
self.manager.register_user_backend(TUserBackend())
|
||||
self.assertEqual(
|
||||
self.manager.get_backends_for_client(),
|
||||
{
|
||||
"test_backend": {
|
||||
"disallowedUpdateKeys": TUserBackend.disallowed_update_keys
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_register_backend_twice(self):
|
||||
self.manager.register_user_backend(TUserBackend())
|
||||
self.assertRaises(
|
||||
UserBackendException, self.manager.register_user_backend, TUserBackend()
|
||||
)
|
||||
|
||||
def test_get_unknown_backend(self):
|
||||
self.assertRaises(
|
||||
UserBackendException, self.manager.get_backend, "unknwon_backend"
|
||||
)
|
Loading…
Reference in New Issue
Block a user