Merge pull request #5000 from FinnStutzenstein/saml
[WIP] saml integration
This commit is contained in:
commit
caf05a3e87
@ -85,7 +85,7 @@ matrix:
|
|||||||
- "3.6"
|
- "3.6"
|
||||||
script:
|
script:
|
||||||
- mypy openslides/ tests/
|
- mypy openslides/ tests/
|
||||||
- pytest --cov --cov-fail-under=75
|
- pytest --cov --cov-fail-under=73
|
||||||
|
|
||||||
- name: "Server: Tests Python 3.7"
|
- name: "Server: Tests Python 3.7"
|
||||||
language: python
|
language: python
|
||||||
@ -96,7 +96,7 @@ matrix:
|
|||||||
- isort --check-only --diff --recursive openslides tests
|
- isort --check-only --diff --recursive openslides tests
|
||||||
- black --check --diff --target-version py36 openslides tests
|
- black --check --diff --target-version py36 openslides tests
|
||||||
- mypy openslides/ tests/
|
- mypy openslides/ tests/
|
||||||
- pytest --cov --cov-fail-under=75
|
- pytest --cov --cov-fail-under=73
|
||||||
|
|
||||||
- name: "Client: Linting"
|
- name: "Client: Linting"
|
||||||
language: node_js
|
language: node_js
|
||||||
|
@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { environment } from 'environments/environment';
|
import { environment } from 'environments/environment';
|
||||||
|
|
||||||
import { OperatorService, WhoAmI } from 'app/core/core-services/operator.service';
|
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 { DataStoreService } from './data-store.service';
|
||||||
import { HttpService } from './http.service';
|
import { HttpService } from './http.service';
|
||||||
import { OpenSlidesService } from './openslides.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.
|
* - Type "default": username and password needed; the earlySuccessCallback will be called.
|
||||||
* errors will be forwarded to the parents error function.
|
* - Type "saml": The windows location will be changed to the single-sign-on service initiator.
|
||||||
*
|
|
||||||
* @param username
|
|
||||||
* @param password
|
|
||||||
* @returns The login response.
|
|
||||||
*/
|
*/
|
||||||
public async login(username: string, password: string, earlySuccessCallback: () => void): Promise<WhoAmI> {
|
public async login(
|
||||||
const user = {
|
authType: UserAuthType,
|
||||||
username: username,
|
username: string,
|
||||||
password: password
|
password: string,
|
||||||
};
|
earlySuccessCallback: () => void
|
||||||
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', user);
|
): Promise<void> {
|
||||||
earlySuccessCallback();
|
if (authType === 'default') {
|
||||||
await this.OpenSlides.shutdown();
|
const user = {
|
||||||
await this.operator.setWhoAmI(response);
|
username: username,
|
||||||
await this.OpenSlides.afterLoginBootup(response.user_id);
|
password: password
|
||||||
await this.redirectUser(response.user_id);
|
};
|
||||||
return response;
|
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.
|
* send a `post`-request to `/apps/users/logout/'`. Restarts OpenSlides.
|
||||||
*/
|
*/
|
||||||
public async logout(): Promise<void> {
|
public async logout(): Promise<void> {
|
||||||
let response = null;
|
const authType = this.operator.authType.getValue();
|
||||||
try {
|
if (authType === DEFAULT_AUTH_TYPE) {
|
||||||
response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/logout/', {});
|
let response = null;
|
||||||
} catch (e) {
|
try {
|
||||||
// We do nothing on failures. Reboot OpenSlides anyway.
|
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 { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
|
||||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||||
import { StorageService } from './storage.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';
|
import { UserRepositoryService } from '../repositories/users/user-repository.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +30,7 @@ export interface WhoAmI {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
guest_enabled: boolean;
|
guest_enabled: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
|
auth_type: UserAuthType;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,7 +43,8 @@ function isWhoAmI(obj: any): obj is WhoAmI {
|
|||||||
whoAmI.guest_enabled !== undefined &&
|
whoAmI.guest_enabled !== undefined &&
|
||||||
whoAmI.user !== undefined &&
|
whoAmI.user !== undefined &&
|
||||||
whoAmI.user_id !== 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);
|
return this.isInGroupIdsNonAdminCheck(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly authType: BehaviorSubject<UserAuthType> = new BehaviorSubject(DEFAULT_AUTH_TYPE);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save, if guests are enabled.
|
* Save, if guests are enabled.
|
||||||
*/
|
*/
|
||||||
@ -117,10 +121,11 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||||||
private userRepository: UserRepositoryService | null;
|
private userRepository: UserRepositoryService | null;
|
||||||
|
|
||||||
private _currentWhoAmI: WhoAmI | null = null;
|
private _currentWhoAmI: WhoAmI | null = null;
|
||||||
private _defaultWhoAMI: WhoAmI = {
|
private _defaultWhoAmI: WhoAmI = {
|
||||||
user_id: null,
|
user_id: null,
|
||||||
guest_enabled: false,
|
guest_enabled: false,
|
||||||
user: null,
|
user: null,
|
||||||
|
auth_type: DEFAULT_AUTH_TYPE,
|
||||||
permissions: []
|
permissions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,7 +133,7 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||||||
* The current WhoAmI response to extract the user (the operator) from.
|
* The current WhoAmI response to extract the user (the operator) from.
|
||||||
*/
|
*/
|
||||||
private get currentWhoAmI(): WhoAmI {
|
private get currentWhoAmI(): WhoAmI {
|
||||||
return this._currentWhoAmI || this._defaultWhoAMI;
|
return this._currentWhoAmI || this._defaultWhoAmI;
|
||||||
}
|
}
|
||||||
|
|
||||||
private set currentWhoAmI(value: WhoAmI | null) {
|
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
|
// 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
|
// is for a fresh restart and do not have (old) changed values in this.defaultWhoAmI
|
||||||
if (!value) {
|
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._user = whoami ? whoami.user : null;
|
||||||
|
this.authType.next(whoami ? whoami.auth_type : DEFAULT_AUTH_TYPE);
|
||||||
await this.updatePermissions();
|
await this.updatePermissions();
|
||||||
this._loaded.resolve();
|
this._loaded.resolve();
|
||||||
}
|
}
|
||||||
@ -418,6 +424,7 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||||||
user_id: null,
|
user_id: null,
|
||||||
guest_enabled: false,
|
guest_enabled: false,
|
||||||
user: null,
|
user: null,
|
||||||
|
auth_type: DEFAULT_AUTH_TYPE,
|
||||||
permissions: []
|
permissions: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -107,7 +107,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
|||||||
* read the constants, add them to an array of apps
|
* read the constants, add them to an array of apps
|
||||||
*/
|
*/
|
||||||
private sortPermsPerApp(): void {
|
private sortPermsPerApp(): void {
|
||||||
this.constantsService.get<any>('permissions').subscribe(perms => {
|
this.constantsService.get<any>('Permissions').subscribe(perms => {
|
||||||
let pluginCounter = 0;
|
let pluginCounter = 0;
|
||||||
for (const perm of perms) {
|
for (const perm of perms) {
|
||||||
// extract the apps name
|
// extract the apps name
|
||||||
|
@ -154,30 +154,6 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
|
|||||||
return viewModel;
|
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.
|
* Updates the password and sets the password without checking for the old one.
|
||||||
* Also resets the 'default password' to the newly created 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 { auditTime } from 'rxjs/operators';
|
||||||
|
|
||||||
import { ConfigService } from './config.service';
|
import { ConfigService } from './config.service';
|
||||||
|
import { ConstantsService } from '../core-services/constants.service';
|
||||||
import { HttpService } from '../core-services/http.service';
|
import { HttpService } from '../core-services/http.service';
|
||||||
import { OpenSlidesStatusService } from '../core-services/openslides-status.service';
|
import { OpenSlidesStatusService } from '../core-services/openslides-status.service';
|
||||||
import { StorageService } from '../core-services/storage.service';
|
import { StorageService } from '../core-services/storage.service';
|
||||||
|
|
||||||
|
interface SamlSettings {
|
||||||
|
loginButtonText: string;
|
||||||
|
changePasswordUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The login data send by the server.
|
* The login data send by the server.
|
||||||
*/
|
*/
|
||||||
@ -21,6 +27,7 @@ export interface LoginData {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
};
|
};
|
||||||
login_info_text?: string;
|
login_info_text?: string;
|
||||||
|
saml_settings?: SamlSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -105,6 +112,11 @@ export class LoginDataService {
|
|||||||
return this._logoWebHeader.asObservable();
|
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
|
* Emit this event, if the current login data should be stored. This
|
||||||
* is debounced to minimize requests to the storage service.
|
* is debounced to minimize requests to the storage service.
|
||||||
@ -131,7 +143,8 @@ export class LoginDataService {
|
|||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private OSStatus: OpenSlidesStatusService,
|
private OSStatus: OpenSlidesStatusService,
|
||||||
private httpService: HttpService
|
private httpService: HttpService,
|
||||||
|
private constantsService: ConstantsService
|
||||||
) {
|
) {
|
||||||
this.storeLoginDataRequests.pipe(auditTime(100)).subscribe(() => this.storeLoginData());
|
this.storeLoginDataRequests.pipe(auditTime(100)).subscribe(() => this.storeLoginData());
|
||||||
this.setup();
|
this.setup();
|
||||||
@ -167,6 +180,12 @@ export class LoginDataService {
|
|||||||
this.storeLoginDataRequests.next();
|
this.storeLoginDataRequests.next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.constantsService.get<SamlSettings>('SamlSettings').subscribe(value => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
this._samlSettings.next(value);
|
||||||
|
this.storeLoginDataRequests.next();
|
||||||
|
}
|
||||||
|
});
|
||||||
this.canRefresh = true;
|
this.canRefresh = true;
|
||||||
if (this.markRefresh) {
|
if (this.markRefresh) {
|
||||||
this._refresh();
|
this._refresh();
|
||||||
@ -219,6 +238,7 @@ export class LoginDataService {
|
|||||||
this._theme.next(loginData.theme);
|
this._theme.next(loginData.theme);
|
||||||
this._logoWebHeader.next(loginData.logo_web_header);
|
this._logoWebHeader.next(loginData.logo_web_header);
|
||||||
this._loginInfoText.next(loginData.login_info_text);
|
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(),
|
privacy_policy: this._privacyPolicy.getValue(),
|
||||||
legal_notice: this._legalNotice.getValue(),
|
legal_notice: this._legalNotice.getValue(),
|
||||||
theme: this._theme.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);
|
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 genders = [_('female'), _('male'), _('diverse')];
|
||||||
|
|
||||||
|
export const DEFAULT_AUTH_TYPE = 'default';
|
||||||
|
export type UserAuthType = 'default' | 'saml';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Representation of a user in contrast to the operator.
|
* Representation of a user in contrast to the operator.
|
||||||
* @ignore
|
* @ignore
|
||||||
@ -30,6 +33,7 @@ export class User extends BaseModel<User> {
|
|||||||
public comment?: string;
|
public comment?: string;
|
||||||
public is_active?: boolean;
|
public is_active?: boolean;
|
||||||
public default_password?: string;
|
public default_password?: string;
|
||||||
|
public auth_type?: UserAuthType;
|
||||||
|
|
||||||
public constructor(input?: Partial<User>) {
|
public constructor(input?: Partial<User>) {
|
||||||
super(User.COLLECTIONSTRING, input);
|
super(User.COLLECTIONSTRING, input);
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- login form -->
|
<!-- login form -->
|
||||||
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin()">
|
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin('default')">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@ -51,5 +51,14 @@
|
|||||||
>
|
>
|
||||||
{{ 'Login as guest' | translate }}
|
{{ 'Login as guest' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
mat-stroked-button
|
||||||
|
*ngIf="samlLoginButtonText"
|
||||||
|
class="login-button"
|
||||||
|
type="button"
|
||||||
|
(click)="formLogin('saml')"
|
||||||
|
>
|
||||||
|
{{ samlLoginButtonText | translate }}
|
||||||
|
</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,6 +11,7 @@ import { AuthService } from 'app/core/core-services/auth.service';
|
|||||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||||
import { LoginDataService } from 'app/core/ui-services/login-data.service';
|
import { LoginDataService } from 'app/core/ui-services/login-data.service';
|
||||||
import { OverlayService } from 'app/core/ui-services/overlay.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 { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
|
||||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
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 operatorSubscription: Subscription | null;
|
||||||
|
|
||||||
|
public samlLoginButtonText: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The message, that should appear, when the user logs in.
|
* 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.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.
|
// Maybe the operator changes and the user is logged in. If so, redirect him and boot OpenSlides.
|
||||||
this.operatorSubscription = this.operator.getUserObservable().subscribe(user => {
|
this.operatorSubscription = this.operator.getUserObservable().subscribe(user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -138,12 +147,12 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
|
|||||||
*
|
*
|
||||||
* Send username and password to the {@link AuthService}
|
* Send username and password to the {@link AuthService}
|
||||||
*/
|
*/
|
||||||
public async formLogin(): Promise<void> {
|
public async formLogin(authType: UserAuthType): Promise<void> {
|
||||||
this.loginErrorMsg = '';
|
this.loginErrorMsg = '';
|
||||||
try {
|
try {
|
||||||
this.overlayService.logout(); // Ensures displaying spinner, if logging in
|
this.overlayService.logout(); // Ensures displaying spinner, if logging in
|
||||||
this.overlayService.showSpinner(this.translate.instant(this.loginMessage), true);
|
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.
|
this.clearOperatorSubscription(); // We take control, not the subscription.
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
<mat-expansion-panel class="user-menu mat-elevation-z0">
|
<mat-expansion-panel class="user-menu mat-elevation-z0">
|
||||||
<mat-expansion-panel-header class="username">
|
<mat-expansion-panel-header class="username">
|
||||||
<!-- Get the username from operator -->
|
<!-- Get the username from operator -->
|
||||||
<span *ngIf="username">{{ username }}</span>
|
{{ username }}
|
||||||
</mat-expansion-panel-header>
|
</mat-expansion-panel-header>
|
||||||
<mat-nav-list>
|
<mat-nav-list>
|
||||||
<a mat-list-item [matMenuTriggerFor]="languageMenu">
|
<a mat-list-item [matMenuTriggerFor]="languageMenu">
|
||||||
@ -45,16 +45,29 @@
|
|||||||
<mat-icon>person</mat-icon>
|
<mat-icon>person</mat-icon>
|
||||||
<span translate>Show profile</span>
|
<span translate>Show profile</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<ng-container *ngIf="authType === 'default'">
|
||||||
*osPerms="'users.can_change_password'"
|
<a
|
||||||
routerLink="/users/password"
|
*osPerms="'users.can_change_password'"
|
||||||
(click)="mobileAutoCloseNav()"
|
routerLink="/users/password"
|
||||||
mat-list-item
|
(click)="mobileAutoCloseNav()"
|
||||||
>
|
mat-list-item
|
||||||
<mat-icon>vpn_key</mat-icon>
|
>
|
||||||
<span translate>Change password</span>
|
<mat-icon>vpn_key</mat-icon>
|
||||||
</a>
|
<span translate>Change password</span>
|
||||||
<a *ngIf="isLoggedIn" (click)="logout()" mat-list-item>
|
</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>
|
<mat-icon>exit_to_app</mat-icon>
|
||||||
<span translate>Logout</span>
|
<span translate>Logout</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -11,8 +11,10 @@ import { filter } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { navItemAnim } from '../shared/animations';
|
import { navItemAnim } from '../shared/animations';
|
||||||
import { OfflineService } from 'app/core/core-services/offline.service';
|
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 { OverlayService } from 'app/core/ui-services/overlay.service';
|
||||||
import { UpdateService } from 'app/core/ui-services/update.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 { langToLocale } from 'app/shared/utils/lang-to-locale';
|
||||||
import { AuthService } from '../core/core-services/auth.service';
|
import { AuthService } from '../core/core-services/auth.service';
|
||||||
import { BaseComponent } from '../base.component';
|
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)
|
* 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.
|
* is the user logged in, or the anonymous is active.
|
||||||
@ -73,6 +77,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
private delayedUpdateAvailable = false;
|
private delayedUpdateAvailable = false;
|
||||||
|
|
||||||
|
public samlChangePasswordUrl: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
@ -100,7 +106,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
public OSStatus: OpenSlidesStatusService,
|
public OSStatus: OpenSlidesStatusService,
|
||||||
public timeTravel: TimeTravelService,
|
public timeTravel: TimeTravelService,
|
||||||
private matSnackBar: MatSnackBar,
|
private matSnackBar: MatSnackBar,
|
||||||
private overlayService: OverlayService
|
private overlayService: OverlayService,
|
||||||
|
private loginDataService: LoginDataService
|
||||||
) {
|
) {
|
||||||
super(title, translate);
|
super(title, translate);
|
||||||
overlayService.showSpinner(translate.instant('Loading data. Please wait...'));
|
overlayService.showSpinner(translate.instant('Loading data. Please wait...'));
|
||||||
@ -114,11 +121,16 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
this.isLoggedIn = false;
|
this.isLoggedIn = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.operator.authType.subscribe(authType => (this.authType = authType));
|
||||||
|
|
||||||
offlineService.isOffline().subscribe(offline => {
|
offlineService.isOffline().subscribe(offline => {
|
||||||
this.isOffline = offline;
|
this.isOffline = offline;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.loginDataService.samlSettings.subscribe(
|
||||||
|
samlSettings => (this.samlChangePasswordUrl = samlSettings ? samlSettings.changePasswordUrl : null)
|
||||||
|
);
|
||||||
|
|
||||||
this.searchform = new FormGroup({ query: new FormControl([]) });
|
this.searchform = new FormGroup({ query: new FormControl([]) });
|
||||||
|
|
||||||
// detect routing data such as base perm and noInterruption
|
// detect routing data such as base perm and noInterruption
|
||||||
|
@ -320,7 +320,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="isAllowed('seePersonal') && user.is_last_email_send">
|
<div *ngIf="isAllowed('seePersonal') && user.isLastEmailSend">
|
||||||
<div>
|
<div>
|
||||||
<h4 translate>Last email sent</h4>
|
<h4 translate>Last email sent</h4>
|
||||||
<span>{{ getEmailSentTime() }}</span>
|
<span>{{ getEmailSentTime() }}</span>
|
||||||
|
@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
import { ConstantsService } from 'app/core/core-services/constants.service';
|
||||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||||
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
|
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
|
||||||
import { UserRepositoryService } from 'app/core/repositories/users/user-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 { ViewGroup } from '../../models/view-group';
|
||||||
import { ViewUser } from '../../models/view-user';
|
import { ViewUser } from '../../models/view-user';
|
||||||
|
|
||||||
|
interface UserBackends {
|
||||||
|
[name: string]: {
|
||||||
|
disallowedUpdateKeys: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users detail component for both new and existing users
|
* Users detail component for both new and existing users
|
||||||
*/
|
*/
|
||||||
@ -67,6 +74,8 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public genderList = genders;
|
public genderList = genders;
|
||||||
|
|
||||||
|
private userBackends: UserBackends | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for user
|
* Constructor for user
|
||||||
*
|
*
|
||||||
@ -93,11 +102,14 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
|
|||||||
private operator: OperatorService,
|
private operator: OperatorService,
|
||||||
private promptService: PromptService,
|
private promptService: PromptService,
|
||||||
private pdfService: UserPdfExportService,
|
private pdfService: UserPdfExportService,
|
||||||
private groupRepo: GroupRepositoryService
|
private groupRepo: GroupRepositoryService,
|
||||||
|
private constantsService: ConstantsService
|
||||||
) {
|
) {
|
||||||
super(title, translate, matSnackBar);
|
super(title, translate, matSnackBar);
|
||||||
this.createForm();
|
this.createForm();
|
||||||
|
|
||||||
|
this.constantsService.get<UserBackends>('UserBackends').subscribe(backends => (this.userBackends = backends));
|
||||||
|
|
||||||
this.groupRepo
|
this.groupRepo
|
||||||
.getViewModelListObservable()
|
.getViewModelListObservable()
|
||||||
.subscribe(groups => this.groups.next(groups.filter(group => group.id !== 1)));
|
.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'
|
* @returns a translated string with either the localized date/time; of 'No email sent'
|
||||||
*/
|
*/
|
||||||
public getEmailSentTime(): string {
|
public getEmailSentTime(): string {
|
||||||
if (!this.user.is_last_email_send) {
|
if (!this.user.isLastEmailSend) {
|
||||||
return this.translate.instant('No email sent');
|
return this.translate.instant('No email sent');
|
||||||
}
|
}
|
||||||
return this.repo.lastSentEmailTimeString(this.user);
|
return this.repo.lastSentEmailTimeString(this.user);
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
<!-- Email-sent indicator -->
|
<!-- Email-sent indicator -->
|
||||||
<mat-icon
|
<mat-icon
|
||||||
inline
|
inline
|
||||||
*ngIf="user.is_last_email_send"
|
*ngIf="user.isLastEmailSend"
|
||||||
matTooltip="{{ 'Email sent' | translate }} ({{ getEmailSentTime(user) }})"
|
matTooltip="{{ 'Email sent' | translate }} ({{ getEmailSentTime(user) }})"
|
||||||
>
|
>
|
||||||
mail
|
mail
|
||||||
@ -88,6 +88,8 @@
|
|||||||
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}">
|
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}">
|
||||||
comment
|
comment
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
|
|
||||||
|
<os-icon-container *ngIf="user.isSamlUser" icon="device_hub"><span translate>Is SAML user</span></os-icon-container>
|
||||||
</div>
|
</div>
|
||||||
</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
|
* @returns a string representation about the last time an email was sent to a user
|
||||||
*/
|
*/
|
||||||
public getEmailSentTime(user: ViewUser): string {
|
public getEmailSentTime(user: ViewUser): string {
|
||||||
if (!user.is_last_email_send) {
|
if (!user.isLastEmailSend) {
|
||||||
return this.translate.instant('No email sent');
|
return this.translate.instant('No email sent');
|
||||||
}
|
}
|
||||||
return this.repo.lastSentEmailTimeString(user);
|
return this.repo.lastSentEmailTimeString(user);
|
||||||
|
@ -21,8 +21,12 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
|
|||||||
return this._model;
|
return this._model;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get is_last_email_send(): boolean {
|
public get isSamlUser(): boolean {
|
||||||
return this.user && !!this.user.last_email_send;
|
return this.auth_type === 'saml';
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isLastEmailSend(): boolean {
|
||||||
|
return !!this.user.last_email_send;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get short_name(): string {
|
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'),
|
label: this.translate.instant('Last email send'),
|
||||||
options: [
|
options: [
|
||||||
{ condition: true, label: this.translate.instant('Got an email') },
|
{ 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
|
from django.core.management import call_command, execute_from_command_line
|
||||||
|
|
||||||
import openslides
|
import openslides
|
||||||
from openslides.core.apps import startup
|
|
||||||
from openslides.utils.arguments import arguments
|
from openslides.utils.arguments import arguments
|
||||||
from openslides.utils.main import (
|
from openslides.utils.main import (
|
||||||
ExceptionArgumentParser,
|
ExceptionArgumentParser,
|
||||||
@ -20,6 +19,7 @@ from openslides.utils.main import (
|
|||||||
setup_django_settings_module,
|
setup_django_settings_module,
|
||||||
write_settings,
|
write_settings,
|
||||||
)
|
)
|
||||||
|
from openslides.utils.startup import run_startup_hooks
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -227,7 +227,7 @@ def start(args):
|
|||||||
if not args.no_browser:
|
if not args.no_browser:
|
||||||
open_browser(args.host, args.port)
|
open_browser(args.host, args.port)
|
||||||
|
|
||||||
startup()
|
run_startup_hooks()
|
||||||
|
|
||||||
# Start the built-in webserver
|
# Start the built-in webserver
|
||||||
#
|
#
|
||||||
|
@ -6,13 +6,13 @@ defined in the ASGI_APPLICATION setting.
|
|||||||
import django
|
import django
|
||||||
from channels.routing import get_default_application
|
from channels.routing import get_default_application
|
||||||
|
|
||||||
from .core.apps import startup
|
|
||||||
from .utils.main import setup_django_settings_module
|
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
|
# Loads the openslides setting. You can use your own settings by setting the
|
||||||
# environment variable DJANGO_SETTINGS_MODULE
|
# environment variable DJANGO_SETTINGS_MODULE
|
||||||
setup_django_settings_module()
|
setup_django_settings_module()
|
||||||
django.setup()
|
django.setup()
|
||||||
startup()
|
run_startup_hooks()
|
||||||
application = get_default_application()
|
application = get_default_application()
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
@ -95,9 +94,6 @@ class CoreAppConfig(AppConfig):
|
|||||||
self.get_model("Countdown").get_collection_string(), CountdownViewSet
|
self.get_model("Countdown").get_collection_string(), CountdownViewSet
|
||||||
)
|
)
|
||||||
|
|
||||||
if "runserver" in sys.argv or "changeconfig" in sys.argv:
|
|
||||||
startup()
|
|
||||||
|
|
||||||
# Register client messages
|
# Register client messages
|
||||||
register_client_message(NotifyWebsocketClientMessage())
|
register_client_message(NotifyWebsocketClientMessage())
|
||||||
register_client_message(ConstantsWebsocketClientMessage())
|
register_client_message(ConstantsWebsocketClientMessage())
|
||||||
@ -106,6 +102,22 @@ class CoreAppConfig(AppConfig):
|
|||||||
register_client_message(ListenToProjectors())
|
register_client_message(ListenToProjectors())
|
||||||
register_client_message(PingPong())
|
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):
|
def get_config_variables(self):
|
||||||
from .config_variables import get_config_variables
|
from .config_variables import get_config_variables
|
||||||
|
|
||||||
@ -193,21 +205,3 @@ def manage_config(**kwargs):
|
|||||||
if altered:
|
if altered:
|
||||||
config.increment_version()
|
config.increment_version()
|
||||||
logging.getLogger(__name__).info("Updated config variables")
|
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 django.conf.urls import include, url
|
||||||
|
|
||||||
|
from openslides.saml import SAML_ENABLED
|
||||||
from openslides.utils.plugins import get_all_plugin_urlpatterns
|
from openslides.utils.plugins import get_all_plugin_urlpatterns
|
||||||
|
|
||||||
|
|
||||||
@ -9,3 +10,6 @@ urlpatterns += [
|
|||||||
url(r"^core/", include("openslides.core.urls")),
|
url(r"^core/", include("openslides.core.urls")),
|
||||||
url(r"^users/", include("openslides.users.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,
|
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.
|
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),
|
# * full data i. e. all fields (including session_auth_hash),
|
||||||
# * all data i. e. all fields but not 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,
|
# * 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,
|
# * 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.
|
# * no data.
|
||||||
|
|
||||||
# Prepare field set for users with "all" data, "many" data and with "little" 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")
|
all_data_fields.add("default_password")
|
||||||
many_data_fields = all_data_fields.copy()
|
many_data_fields = all_data_fields.copy()
|
||||||
many_data_fields.discard("default_password")
|
many_data_fields.discard("default_password")
|
||||||
litte_data_fields = set(USERCANSEESERIALIZER_FIELDS)
|
little_data_fields = set(USERCANSEESERIALIZER_FIELDS)
|
||||||
litte_data_fields.add("groups_id")
|
little_data_fields.add("groups_id")
|
||||||
litte_data_fields.discard("groups")
|
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.
|
# Check user permissions.
|
||||||
if await async_has_perm(user_id, "users.can_see_name"):
|
if await async_has_perm(user_id, "users.can_see_name"):
|
||||||
@ -56,7 +67,10 @@ class UserAccessPermissions(BaseAccessPermissions):
|
|||||||
else:
|
else:
|
||||||
data = [filtered_data(full, many_data_fields) for full in full_data]
|
data = [filtered_data(full, many_data_fields) for full in full_data]
|
||||||
else:
|
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:
|
else:
|
||||||
# Build a list of users, that can be seen without any permissions (with little fields).
|
# Build a list of users, that can be seen without any permissions (with little fields).
|
||||||
|
|
||||||
@ -84,7 +98,7 @@ class UserAccessPermissions(BaseAccessPermissions):
|
|||||||
|
|
||||||
# Parse data.
|
# Parse data.
|
||||||
data = [
|
data = [
|
||||||
filtered_data(full, litte_data_fields)
|
filtered_data(full, little_data_fields, own_data_fields)
|
||||||
for full in full_data
|
for full in full_data
|
||||||
if full["id"] in user_ids
|
if full["id"] in user_ids
|
||||||
]
|
]
|
||||||
|
@ -2,10 +2,13 @@ from django.apps import AppConfig
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.signals import user_logged_in
|
from django.contrib.auth.signals import user_logged_in
|
||||||
|
|
||||||
|
from .user_backend import DefaultUserBackend, user_backend_manager
|
||||||
|
|
||||||
|
|
||||||
class UsersAppConfig(AppConfig):
|
class UsersAppConfig(AppConfig):
|
||||||
name = "openslides.users"
|
name = "openslides.users"
|
||||||
verbose_name = "OpenSlides Users"
|
verbose_name = "OpenSlides Users"
|
||||||
|
user_backend_class = DefaultUserBackend
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# Import all required stuff.
|
# Import all required stuff.
|
||||||
@ -39,6 +42,9 @@ class UsersAppConfig(AppConfig):
|
|||||||
self.get_model("PersonalNote").get_collection_string(), PersonalNoteViewSet
|
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):
|
def get_config_variables(self):
|
||||||
from .config_variables import get_config_variables
|
from .config_variables import get_config_variables
|
||||||
|
|
||||||
@ -55,6 +61,7 @@ class UsersAppConfig(AppConfig):
|
|||||||
def get_angular_constants(self):
|
def get_angular_constants(self):
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
|
# Permissions
|
||||||
permissions = []
|
permissions = []
|
||||||
for permission in Permission.objects.all():
|
for permission in Permission.objects.all():
|
||||||
permissions.append(
|
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)
|
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)
|
first_name = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
last_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",
|
"last_email_send",
|
||||||
"comment",
|
"comment",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"auth_type",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -60,7 +61,7 @@ class UserFullSerializer(ModelSerializer):
|
|||||||
"default_password",
|
"default_password",
|
||||||
"session_auth_hash",
|
"session_auth_hash",
|
||||||
)
|
)
|
||||||
read_only_fields = ("last_email_send",)
|
read_only_fields = ("last_email_send", "auth_type")
|
||||||
|
|
||||||
def validate(self, data):
|
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.encoding import force_bytes, force_text
|
||||||
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
||||||
|
|
||||||
|
from openslides.saml import SAML_ENABLED
|
||||||
|
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from ..core.signals import permission_change
|
from ..core.signals import permission_change
|
||||||
from ..utils.auth import (
|
from ..utils.auth import (
|
||||||
@ -48,6 +50,7 @@ from .access_permissions import (
|
|||||||
)
|
)
|
||||||
from .models import Group, PersonalNote, User
|
from .models import Group, PersonalNote, User
|
||||||
from .serializers import GroupSerializer, PermissionRelatedField
|
from .serializers import GroupSerializer, PermissionRelatedField
|
||||||
|
from .user_backend import user_backend_manager
|
||||||
|
|
||||||
|
|
||||||
# Viewsets for the REST API
|
# Viewsets for the REST API
|
||||||
@ -126,8 +129,24 @@ class UserViewSet(ModelViewSet):
|
|||||||
# Remove fields that the user is not allowed to change.
|
# Remove fields that the user is not allowed to change.
|
||||||
# The list() is required because we want to use del inside the loop.
|
# The list() is required because we want to use del inside the loop.
|
||||||
for key in list(request.data.keys()):
|
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]
|
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)
|
response = super().update(request, *args, **kwargs)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@ -150,6 +169,13 @@ class UserViewSet(ModelViewSet):
|
|||||||
Expected data: { pasword: <the new password> }
|
Expected data: { pasword: <the new password> }
|
||||||
"""
|
"""
|
||||||
user = self.get_object()
|
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")
|
password = request.data.get("password")
|
||||||
if not isinstance(password, str):
|
if not isinstance(password, str):
|
||||||
raise ValidationError({"detail": "Password has to be a string."})
|
raise ValidationError({"detail": "Password has to be a string."})
|
||||||
@ -173,7 +199,7 @@ class UserViewSet(ModelViewSet):
|
|||||||
self.assert_list_of_ints(ids)
|
self.assert_list_of_ints(ids)
|
||||||
|
|
||||||
# Exclude the request user
|
# 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:
|
for user in users:
|
||||||
password = User.objects.make_random_password()
|
password = User.objects.make_random_password()
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
@ -192,7 +218,7 @@ class UserViewSet(ModelViewSet):
|
|||||||
self.assert_list_of_ints(ids)
|
self.assert_list_of_ints(ids)
|
||||||
|
|
||||||
# Exclude the request user
|
# 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
|
# Validate all default passwords
|
||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
@ -236,7 +262,7 @@ class UserViewSet(ModelViewSet):
|
|||||||
if not isinstance(value, bool):
|
if not isinstance(value, bool):
|
||||||
raise ValidationError({"detail": "value must be true or false"})
|
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":
|
if field == "is_active":
|
||||||
users = users.exclude(pk=request.user.id)
|
users = users.exclude(pk=request.user.id)
|
||||||
for user in users:
|
for user in users:
|
||||||
@ -265,7 +291,7 @@ class UserViewSet(ModelViewSet):
|
|||||||
if action not in ("add", "remove"):
|
if action not in ("add", "remove"):
|
||||||
raise ValidationError({"detail": "The action must be add or 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))
|
groups = list(Group.objects.filter(pk__in=group_ids))
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
@ -287,12 +313,23 @@ class UserViewSet(ModelViewSet):
|
|||||||
self.assert_list_of_ints(ids)
|
self.assert_list_of_ints(ids)
|
||||||
|
|
||||||
# Exclude the request user
|
# 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):
|
for user in list(users):
|
||||||
user.delete()
|
user.delete()
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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"])
|
@list_route(methods=["post"])
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def mass_import(self, request):
|
def mass_import(self, request):
|
||||||
@ -673,14 +710,19 @@ class WhoAmIDataView(APIView):
|
|||||||
"""
|
"""
|
||||||
Appends the user id to the context. Uses None for the anonymous
|
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.
|
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
|
user_id = self.request.user.pk or 0
|
||||||
guest_enabled = anonymous_is_enabled()
|
guest_enabled = anonymous_is_enabled()
|
||||||
|
|
||||||
|
auth_type = "default"
|
||||||
if user_id:
|
if user_id:
|
||||||
user_data = async_to_sync(element_cache.get_element_data)(
|
user_full_data = async_to_sync(element_cache.get_element_data)(
|
||||||
self.request.user.get_collection_string(), user_id, user_id
|
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]
|
group_ids = user_data["groups_id"] or [GROUP_DEFAULT_PK]
|
||||||
else:
|
else:
|
||||||
@ -697,6 +739,7 @@ class WhoAmIDataView(APIView):
|
|||||||
"user_id": user_id or None,
|
"user_id": user_id or None,
|
||||||
"guest_enabled": guest_enabled,
|
"guest_enabled": guest_enabled,
|
||||||
"user": user_data,
|
"user": user_data,
|
||||||
|
"auth_type": auth_type,
|
||||||
"permissions": list(permissions),
|
"permissions": list(permissions),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -722,6 +765,8 @@ class UserLoginView(WhoAmIDataView):
|
|||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
raise ValidationError({"detail": "Username or password is not correct."})
|
raise ValidationError({"detail": "Username or password is not correct."})
|
||||||
self.user = form.get_user()
|
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)
|
auth_login(self.request, self.user)
|
||||||
return super().post(*args, **kwargs)
|
return super().post(*args, **kwargs)
|
||||||
|
|
||||||
@ -755,6 +800,11 @@ class UserLoginView(WhoAmIDataView):
|
|||||||
# Add the theme, so the loginpage is themed correctly
|
# Add the theme, so the loginpage is themed correctly
|
||||||
context["theme"] = config["openslides_theme"]
|
context["theme"] = config["openslides_theme"]
|
||||||
context["logo_web_header"] = config["logo_web_header"]
|
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:
|
else:
|
||||||
# self.request.method == 'POST'
|
# self.request.method == 'POST'
|
||||||
context.update(self.get_whoami_data())
|
context.update(self.get_whoami_data())
|
||||||
@ -795,6 +845,7 @@ class SetPasswordView(APIView):
|
|||||||
if not (
|
if not (
|
||||||
has_perm(user, "users.can_change_password")
|
has_perm(user, "users.can_change_password")
|
||||||
or has_perm(user, "users.can_manage")
|
or has_perm(user, "users.can_manage")
|
||||||
|
or user.auth_type != "default"
|
||||||
):
|
):
|
||||||
self.permission_denied(request)
|
self.permission_denied(request)
|
||||||
if user.check_password(request.data["old_password"]):
|
if user.check_password(request.data["old_password"]):
|
||||||
@ -900,7 +951,7 @@ class PasswordResetView(APIView):
|
|||||||
resetting their password.
|
resetting their password.
|
||||||
"""
|
"""
|
||||||
active_users = User.objects.filter(
|
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()]
|
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
|
element = json.loads(encoded_element.decode()) # type: ignore
|
||||||
|
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
restricter = self.cachables[collection_string].restrict_elements
|
element = await self.restrict_element_data(
|
||||||
restricted_elements = await restricter(user_id, [element])
|
element, collection_string, user_id
|
||||||
element = restricted_elements[0] if restricted_elements else None
|
)
|
||||||
return element
|
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(
|
async def get_data_since(
|
||||||
self, user_id: Optional[int] = None, change_id: int = 0, max_change_id: int = -1
|
self, user_id: Optional[int] = None, change_id: int = 0, max_change_id: int = -1
|
||||||
) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]:
|
) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]:
|
||||||
|
@ -38,3 +38,7 @@ def set_constants(value: Dict[str, Any]) -> None:
|
|||||||
"""
|
"""
|
||||||
global constants
|
global constants
|
||||||
constants = value
|
constants = value
|
||||||
|
|
||||||
|
|
||||||
|
def set_constants_from_apps() -> None:
|
||||||
|
set_constants(get_constants_from_apps())
|
||||||
|
@ -118,6 +118,14 @@ if use_redis:
|
|||||||
'socket_timeout': 2
|
'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
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
# 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
|
use_parentheses = true
|
||||||
line_length = 88
|
line_length = 88
|
||||||
known_first_party = openslides
|
known_first_party = openslides
|
||||||
known_third_party = pytest
|
known_third_party = pytest,onelogin
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
@ -86,3 +86,13 @@ def reset_cache(request):
|
|||||||
|
|
||||||
# Set constant default change_id
|
# Set constant default change_id
|
||||||
cast(MemoryCacheProvider, element_cache.cache_provider).default_change_id = 1
|
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.agenda.models import Item, ListOfSpeakers
|
||||||
from openslides.assignments.models import Assignment
|
from openslides.assignments.models import Assignment
|
||||||
from openslides.core.apps import startup
|
|
||||||
from openslides.motions.models import Motion
|
from openslides.motions.models import Motion
|
||||||
from openslides.topics.models import Topic
|
from openslides.topics.models import Topic
|
||||||
from openslides.users.models import Group, User
|
from openslides.users.models import Group, User
|
||||||
|
from openslides.utils.startup import run_startup_hooks
|
||||||
|
|
||||||
|
|
||||||
MOTION_NUMBER_OF_PARAGRAPHS = 4
|
MOTION_NUMBER_OF_PARAGRAPHS = 4
|
||||||
@ -118,7 +118,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
startup()
|
run_startup_hooks()
|
||||||
self.create_topics(options)
|
self.create_topics(options)
|
||||||
self.create_motions(options)
|
self.create_motions(options)
|
||||||
self.create_assignments(options)
|
self.create_assignments(options)
|
||||||
|
@ -15,7 +15,13 @@ class TestWhoAmIView(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(response.content.decode()),
|
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):
|
def test_get_authenticated_user(self):
|
||||||
@ -58,7 +64,13 @@ class TestUserLogoutView(TestCase):
|
|||||||
self.assertFalse(hasattr(self.client.session, "test_key"))
|
self.assertFalse(hasattr(self.client.session, "test_key"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(response.content.decode()),
|
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)
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_post_no_data(self):
|
||||||
response = self.client.post(self.url)
|
response = self.client.post(self.url)
|
||||||
@ -85,7 +102,12 @@ class TestUserLoginView(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_post_incorrect_data(self):
|
||||||
response = self.client.post(
|
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