Merge pull request #5000 from FinnStutzenstein/saml

[WIP] saml integration
This commit is contained in:
Finn Stutzenstein 2019-10-21 14:09:49 +02:00 committed by GitHub
commit caf05a3e87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1282 additions and 143 deletions

View File

@ -85,7 +85,7 @@ matrix:
- "3.6"
script:
- mypy openslides/ tests/
- pytest --cov --cov-fail-under=75
- pytest --cov --cov-fail-under=73
- name: "Server: Tests Python 3.7"
language: python
@ -96,7 +96,7 @@ matrix:
- isort --check-only --diff --recursive openslides tests
- black --check --diff --target-version py36 openslides tests
- mypy openslides/ tests/
- pytest --cov --cov-fail-under=75
- pytest --cov --cov-fail-under=73
- name: "Client: Linting"
language: node_js

View File

@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import { environment } from 'environments/environment';
import { OperatorService, WhoAmI } from 'app/core/core-services/operator.service';
import { DEFAULT_AUTH_TYPE, UserAuthType } from 'app/shared/models/users/user';
import { DataStoreService } from './data-store.service';
import { HttpService } from './http.service';
import { OpenSlidesService } from './openslides.service';
@ -32,27 +33,33 @@ export class AuthService {
) {}
/**
* Try to log in a user.
* Try to log in a user with a given auth type
*
* Returns an observable with the correct login information or an error.
* errors will be forwarded to the parents error function.
*
* @param username
* @param password
* @returns The login response.
* - Type "default": username and password needed; the earlySuccessCallback will be called.
* - Type "saml": The windows location will be changed to the single-sign-on service initiator.
*/
public async login(username: string, password: string, earlySuccessCallback: () => void): Promise<WhoAmI> {
const user = {
username: username,
password: password
};
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', user);
earlySuccessCallback();
await this.OpenSlides.shutdown();
await this.operator.setWhoAmI(response);
await this.OpenSlides.afterLoginBootup(response.user_id);
await this.redirectUser(response.user_id);
return response;
public async login(
authType: UserAuthType,
username: string,
password: string,
earlySuccessCallback: () => void
): Promise<void> {
if (authType === 'default') {
const user = {
username: username,
password: password
};
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', user);
earlySuccessCallback();
await this.OpenSlides.shutdown();
await this.operator.setWhoAmI(response);
await this.OpenSlides.afterLoginBootup(response.user_id);
await this.redirectUser(response.user_id);
} else if (authType === 'saml') {
window.location.href = environment.urlPrefix + '/saml/?sso'; // Bye
} else {
throw new Error(`Unsupported auth type "${authType}"`);
}
}
/**
@ -87,15 +94,24 @@ export class AuthService {
* send a `post`-request to `/apps/users/logout/'`. Restarts OpenSlides.
*/
public async logout(): Promise<void> {
let response = null;
try {
response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/logout/', {});
} catch (e) {
// We do nothing on failures. Reboot OpenSlides anyway.
const authType = this.operator.authType.getValue();
if (authType === DEFAULT_AUTH_TYPE) {
let response = null;
try {
response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/logout/', {});
} catch (e) {
// We do nothing on failures. Reboot OpenSlides anyway.
}
this.router.navigate(['/']);
await this.DS.clear();
await this.operator.setWhoAmI(response);
await this.OpenSlides.reboot();
} else if (authType === 'saml') {
await this.DS.clear();
await this.operator.setWhoAmI(null);
window.location.href = environment.urlPrefix + '/saml/?slo'; // Bye
} else {
throw new Error(`Unsupported auth type "${authType}"`);
}
this.router.navigate(['/']);
await this.DS.clear();
await this.operator.setWhoAmI(response);
await this.OpenSlides.reboot();
}
}

View File

@ -14,7 +14,7 @@ import { OfflineService } from './offline.service';
import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
import { OpenSlidesStatusService } from './openslides-status.service';
import { StorageService } from './storage.service';
import { User } from '../../shared/models/users/user';
import { DEFAULT_AUTH_TYPE, User, UserAuthType } from '../../shared/models/users/user';
import { UserRepositoryService } from '../repositories/users/user-repository.service';
/**
@ -30,6 +30,7 @@ export interface WhoAmI {
user_id: number;
guest_enabled: boolean;
user: User;
auth_type: UserAuthType;
permissions: Permission[];
}
@ -42,7 +43,8 @@ function isWhoAmI(obj: any): obj is WhoAmI {
whoAmI.guest_enabled !== undefined &&
whoAmI.user !== undefined &&
whoAmI.user_id !== undefined &&
whoAmI.permissions !== undefined
whoAmI.permissions !== undefined &&
whoAmI.auth_type !== undefined
);
}
@ -89,6 +91,8 @@ export class OperatorService implements OnAfterAppsLoaded {
return this.isInGroupIdsNonAdminCheck(2);
}
public readonly authType: BehaviorSubject<UserAuthType> = new BehaviorSubject(DEFAULT_AUTH_TYPE);
/**
* Save, if guests are enabled.
*/
@ -117,10 +121,11 @@ export class OperatorService implements OnAfterAppsLoaded {
private userRepository: UserRepositoryService | null;
private _currentWhoAmI: WhoAmI | null = null;
private _defaultWhoAMI: WhoAmI = {
private _defaultWhoAmI: WhoAmI = {
user_id: null,
guest_enabled: false,
user: null,
auth_type: DEFAULT_AUTH_TYPE,
permissions: []
};
@ -128,7 +133,7 @@ export class OperatorService implements OnAfterAppsLoaded {
* The current WhoAmI response to extract the user (the operator) from.
*/
private get currentWhoAmI(): WhoAmI {
return this._currentWhoAmI || this._defaultWhoAMI;
return this._currentWhoAmI || this._defaultWhoAmI;
}
private set currentWhoAmI(value: WhoAmI | null) {
@ -137,7 +142,7 @@ export class OperatorService implements OnAfterAppsLoaded {
// Resetting the default whoami, when the current whoami isn't there. This
// is for a fresh restart and do not have (old) changed values in this.defaultWhoAmI
if (!value) {
this._defaultWhoAMI = this.getDefaultWhoAmIResponse();
this._defaultWhoAmI = this.getDefaultWhoAmIResponse();
}
}
@ -304,6 +309,7 @@ export class OperatorService implements OnAfterAppsLoaded {
}
this._user = whoami ? whoami.user : null;
this.authType.next(whoami ? whoami.auth_type : DEFAULT_AUTH_TYPE);
await this.updatePermissions();
this._loaded.resolve();
}
@ -418,6 +424,7 @@ export class OperatorService implements OnAfterAppsLoaded {
user_id: null,
guest_enabled: false,
user: null,
auth_type: DEFAULT_AUTH_TYPE,
permissions: []
};
}

View File

@ -107,7 +107,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
* read the constants, add them to an array of apps
*/
private sortPermsPerApp(): void {
this.constantsService.get<any>('permissions').subscribe(perms => {
this.constantsService.get<any>('Permissions').subscribe(perms => {
let pluginCounter = 0;
for (const perm of perms) {
// extract the apps name

View File

@ -154,30 +154,6 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
return viewModel;
}
/**
* Updates a the selected user with the form values.
* Since user should actually "delete" field, the unified update method
* cannot be used
*
* @param update the forms values
* @param viewUser
*/
public async update(update: Partial<User>, viewUser: ViewUser): Promise<void> {
// if the user deletes the username, reset
// prevents the server of generating '<firstname> <lastname> +1' as username
if (update.username === '') {
update.username = viewUser.username;
}
// if the update user does not have a gender-field, send gender as empty string.
// This allow to delete a previously selected gender
if (!update.gender) {
update.gender = '';
}
return super.update(update, viewUser);
}
/**
* Updates the password and sets the password without checking for the old one.
* Also resets the 'default password' to the newly created one.

View File

@ -5,10 +5,16 @@ import { BehaviorSubject, Observable } from 'rxjs';
import { auditTime } from 'rxjs/operators';
import { ConfigService } from './config.service';
import { ConstantsService } from '../core-services/constants.service';
import { HttpService } from '../core-services/http.service';
import { OpenSlidesStatusService } from '../core-services/openslides-status.service';
import { StorageService } from '../core-services/storage.service';
interface SamlSettings {
loginButtonText: string;
changePasswordUrl: string;
}
/**
* The login data send by the server.
*/
@ -21,6 +27,7 @@ export interface LoginData {
display_name: string;
};
login_info_text?: string;
saml_settings?: SamlSettings;
}
/**
@ -105,6 +112,11 @@ export class LoginDataService {
return this._logoWebHeader.asObservable();
}
private readonly _samlSettings = new BehaviorSubject<SamlSettings>(undefined);
public get samlSettings(): Observable<SamlSettings> {
return this._samlSettings.asObservable();
}
/**
* Emit this event, if the current login data should be stored. This
* is debounced to minimize requests to the storage service.
@ -131,7 +143,8 @@ export class LoginDataService {
private configService: ConfigService,
private storageService: StorageService,
private OSStatus: OpenSlidesStatusService,
private httpService: HttpService
private httpService: HttpService,
private constantsService: ConstantsService
) {
this.storeLoginDataRequests.pipe(auditTime(100)).subscribe(() => this.storeLoginData());
this.setup();
@ -167,6 +180,12 @@ export class LoginDataService {
this.storeLoginDataRequests.next();
}
});
this.constantsService.get<SamlSettings>('SamlSettings').subscribe(value => {
if (value !== undefined) {
this._samlSettings.next(value);
this.storeLoginDataRequests.next();
}
});
this.canRefresh = true;
if (this.markRefresh) {
this._refresh();
@ -219,6 +238,7 @@ export class LoginDataService {
this._theme.next(loginData.theme);
this._logoWebHeader.next(loginData.logo_web_header);
this._loginInfoText.next(loginData.login_info_text);
this._samlSettings.next(loginData.saml_settings);
}
/**
@ -234,7 +254,8 @@ export class LoginDataService {
privacy_policy: this._privacyPolicy.getValue(),
legal_notice: this._legalNotice.getValue(),
theme: this._theme.getValue(),
logo_web_header: this._logoWebHeader.getValue()
logo_web_header: this._logoWebHeader.getValue(),
samlSettings: this._samlSettings.getValue()
};
this.storageService.set(LOGIN_DATA_STORAGE_KEY, loginData);
}

View File

@ -6,6 +6,9 @@ import { BaseModel } from '../base/base-model';
*/
export const genders = [_('female'), _('male'), _('diverse')];
export const DEFAULT_AUTH_TYPE = 'default';
export type UserAuthType = 'default' | 'saml';
/**
* Representation of a user in contrast to the operator.
* @ignore
@ -30,6 +33,7 @@ export class User extends BaseModel<User> {
public comment?: string;
public is_active?: boolean;
public default_password?: string;
public auth_type?: UserAuthType;
public constructor(input?: Partial<User>) {
super(User.COLLECTIONSTRING, input);

View File

@ -6,7 +6,7 @@
</div>
<!-- login form -->
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin()">
<form [formGroup]="loginForm" class="login-container" (ngSubmit)="formLogin('default')">
<mat-form-field>
<input
matInput
@ -51,5 +51,14 @@
>
{{ 'Login as guest' | translate }}
</button>
<a
mat-stroked-button
*ngIf="samlLoginButtonText"
class="login-button"
type="button"
(click)="formLogin('saml')"
>
{{ samlLoginButtonText | translate }}
</a>
</form>
</div>

View File

@ -11,6 +11,7 @@ import { AuthService } from 'app/core/core-services/auth.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { LoginDataService } from 'app/core/ui-services/login-data.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { UserAuthType } from 'app/shared/models/users/user';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { BaseViewComponent } from 'app/site/base/base-view';
@ -52,6 +53,8 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
public operatorSubscription: Subscription | null;
public samlLoginButtonText: string | null = null;
/**
* The message, that should appear, when the user logs in.
*/
@ -97,6 +100,12 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
this.loginDataService.loginInfoText.subscribe(notice => (this.installationNotice = notice))
);
this.subscriptions.push(
this.loginDataService.samlSettings.subscribe(
samlSettings => (this.samlLoginButtonText = samlSettings ? samlSettings.loginButtonText : null)
)
);
// Maybe the operator changes and the user is logged in. If so, redirect him and boot OpenSlides.
this.operatorSubscription = this.operator.getUserObservable().subscribe(user => {
if (user) {
@ -138,12 +147,12 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
*
* Send username and password to the {@link AuthService}
*/
public async formLogin(): Promise<void> {
public async formLogin(authType: UserAuthType): Promise<void> {
this.loginErrorMsg = '';
try {
this.overlayService.logout(); // Ensures displaying spinner, if logging in
this.overlayService.showSpinner(this.translate.instant(this.loginMessage), true);
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
await this.authService.login(authType, this.loginForm.value.username, this.loginForm.value.password, () => {
this.clearOperatorSubscription(); // We take control, not the subscription.
});
} catch (e) {

View File

@ -29,7 +29,7 @@
<mat-expansion-panel class="user-menu mat-elevation-z0">
<mat-expansion-panel-header class="username">
<!-- Get the username from operator -->
<span *ngIf="username">{{ username }}</span>
{{ username }}
</mat-expansion-panel-header>
<mat-nav-list>
<a mat-list-item [matMenuTriggerFor]="languageMenu">
@ -45,16 +45,29 @@
<mat-icon>person</mat-icon>
<span translate>Show profile</span>
</a>
<a
*osPerms="'users.can_change_password'"
routerLink="/users/password"
(click)="mobileAutoCloseNav()"
mat-list-item
>
<mat-icon>vpn_key</mat-icon>
<span translate>Change password</span>
</a>
<a *ngIf="isLoggedIn" (click)="logout()" mat-list-item>
<ng-container *ngIf="authType === 'default'">
<a
*osPerms="'users.can_change_password'"
routerLink="/users/password"
(click)="mobileAutoCloseNav()"
mat-list-item
>
<mat-icon>vpn_key</mat-icon>
<span translate>Change password</span>
</a>
</ng-container>
<ng-container *ngIf="authType === 'saml'">
<a
*osPerms="'users.can_change_password'"
[href]="samlChangePasswordUrl"
mat-list-item
>
<mat-icon>vpn_key</mat-icon>
<span translate>Change password</span>
</a>
</ng-container>
<a (click)="logout()" mat-list-item>
<mat-icon>exit_to_app</mat-icon>
<span translate>Logout</span>
</a>

View File

@ -11,8 +11,10 @@ import { filter } from 'rxjs/operators';
import { navItemAnim } from '../shared/animations';
import { OfflineService } from 'app/core/core-services/offline.service';
import { LoginDataService } from 'app/core/ui-services/login-data.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { UpdateService } from 'app/core/ui-services/update.service';
import { DEFAULT_AUTH_TYPE } from 'app/shared/models/users/user';
import { langToLocale } from 'app/shared/utils/lang-to-locale';
import { AuthService } from '../core/core-services/auth.service';
import { BaseComponent } from '../base.component';
@ -46,7 +48,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
/**
* Get the username from the operator (should be known already)
*/
public username: string;
public username = '';
public authType = DEFAULT_AUTH_TYPE;
/**
* is the user logged in, or the anonymous is active.
@ -73,6 +77,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
*/
private delayedUpdateAvailable = false;
public samlChangePasswordUrl: string | null = null;
/**
* Constructor
*
@ -100,7 +106,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
public OSStatus: OpenSlidesStatusService,
public timeTravel: TimeTravelService,
private matSnackBar: MatSnackBar,
private overlayService: OverlayService
private overlayService: OverlayService,
private loginDataService: LoginDataService
) {
super(title, translate);
overlayService.showSpinner(translate.instant('Loading data. Please wait...'));
@ -114,11 +121,16 @@ export class SiteComponent extends BaseComponent implements OnInit {
this.isLoggedIn = false;
}
});
this.operator.authType.subscribe(authType => (this.authType = authType));
offlineService.isOffline().subscribe(offline => {
this.isOffline = offline;
});
this.loginDataService.samlSettings.subscribe(
samlSettings => (this.samlChangePasswordUrl = samlSettings ? samlSettings.changePasswordUrl : null)
);
this.searchform = new FormGroup({ query: new FormControl([]) });
// detect routing data such as base perm and noInterruption

View File

@ -320,7 +320,7 @@
</div>
</div>
<div *ngIf="isAllowed('seePersonal') && user.is_last_email_send">
<div *ngIf="isAllowed('seePersonal') && user.isLastEmailSend">
<div>
<h4 translate>Last email sent</h4>
<span>{{ getEmailSentTime() }}</span>

View File

@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
@ -18,6 +19,12 @@ import { UserPdfExportService } from '../../services/user-pdf-export.service';
import { ViewGroup } from '../../models/view-group';
import { ViewUser } from '../../models/view-user';
interface UserBackends {
[name: string]: {
disallowedUpdateKeys: string[];
};
}
/**
* Users detail component for both new and existing users
*/
@ -67,6 +74,8 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
*/
public genderList = genders;
private userBackends: UserBackends | null = null;
/**
* Constructor for user
*
@ -93,11 +102,14 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
private operator: OperatorService,
private promptService: PromptService,
private pdfService: UserPdfExportService,
private groupRepo: GroupRepositoryService
private groupRepo: GroupRepositoryService,
private constantsService: ConstantsService
) {
super(title, translate, matSnackBar);
this.createForm();
this.constantsService.get<UserBackends>('UserBackends').subscribe(backends => (this.userBackends = backends));
this.groupRepo
.getViewModelListObservable()
.subscribe(groups => this.groups.next(groups.filter(group => group.id !== 1)));
@ -236,6 +248,12 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
}
});
}
if (!this.newUser && this.user.auth_type && this.userBackends && this.userBackends[this.user.auth_type]) {
this.userBackends[this.user.auth_type].disallowedUpdateKeys.forEach(key => {
this.personalInfoForm.get(key).disable();
});
}
}
/**
@ -350,7 +368,7 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
* @returns a translated string with either the localized date/time; of 'No email sent'
*/
public getEmailSentTime(): string {
if (!this.user.is_last_email_send) {
if (!this.user.isLastEmailSend) {
return this.translate.instant('No email sent');
}
return this.repo.lastSentEmailTimeString(this.user);

View File

@ -78,7 +78,7 @@
<!-- Email-sent indicator -->
<mat-icon
inline
*ngIf="user.is_last_email_send"
*ngIf="user.isLastEmailSend"
matTooltip="{{ 'Email sent' | translate }} ({{ getEmailSentTime(user) }})"
>
mail
@ -88,6 +88,8 @@
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}">
comment
</mat-icon>
<os-icon-container *ngIf="user.isSamlUser" icon="device_hub"><span translate>Is SAML user</span></os-icon-container>
</div>
</div>

View File

@ -355,7 +355,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
* @returns a string representation about the last time an email was sent to a user
*/
public getEmailSentTime(user: ViewUser): string {
if (!user.is_last_email_send) {
if (!user.isLastEmailSend) {
return this.translate.instant('No email sent');
}
return this.repo.lastSentEmailTimeString(user);

View File

@ -21,8 +21,12 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
return this._model;
}
public get is_last_email_send(): boolean {
return this.user && !!this.user.last_email_send;
public get isSamlUser(): boolean {
return this.auth_type === 'saml';
}
public get isLastEmailSend(): boolean {
return !!this.user.last_email_send;
}
public get short_name(): string {

View File

@ -79,7 +79,7 @@ export class UserFilterListService extends BaseFilterListService<ViewUser> {
]
},
{
property: 'is_last_email_send',
property: 'isLastEmailSend',
label: this.translate.instant('Last email send'),
options: [
{ condition: true, label: this.translate.instant('Got an email') },

View File

@ -8,7 +8,6 @@ import django
from django.core.management import call_command, execute_from_command_line
import openslides
from openslides.core.apps import startup
from openslides.utils.arguments import arguments
from openslides.utils.main import (
ExceptionArgumentParser,
@ -20,6 +19,7 @@ from openslides.utils.main import (
setup_django_settings_module,
write_settings,
)
from openslides.utils.startup import run_startup_hooks
def main():
@ -227,7 +227,7 @@ def start(args):
if not args.no_browser:
open_browser(args.host, args.port)
startup()
run_startup_hooks()
# Start the built-in webserver
#

View File

@ -6,13 +6,13 @@ defined in the ASGI_APPLICATION setting.
import django
from channels.routing import get_default_application
from .core.apps import startup
from .utils.main import setup_django_settings_module
from .utils.startup import run_startup_hooks
# Loads the openslides setting. You can use your own settings by setting the
# environment variable DJANGO_SETTINGS_MODULE
setup_django_settings_module()
django.setup()
startup()
run_startup_hooks()
application = get_default_application()

View File

@ -1,4 +1,3 @@
import os
import sys
from collections import OrderedDict
from operator import attrgetter
@ -95,9 +94,6 @@ class CoreAppConfig(AppConfig):
self.get_model("Countdown").get_collection_string(), CountdownViewSet
)
if "runserver" in sys.argv or "changeconfig" in sys.argv:
startup()
# Register client messages
register_client_message(NotifyWebsocketClientMessage())
register_client_message(ConstantsWebsocketClientMessage())
@ -106,6 +102,22 @@ class CoreAppConfig(AppConfig):
register_client_message(ListenToProjectors())
register_client_message(PingPong())
if "runserver" in sys.argv or "changeconfig" in sys.argv:
from openslides.utils.startup import run_startup_hooks
run_startup_hooks()
def get_startup_hooks(self):
from openslides.utils.constants import set_constants_from_apps
from openslides.utils.cache import element_cache
from openslides.core.models import History
return {
10: element_cache.ensure_schema_version,
40: set_constants_from_apps,
90: History.objects.build_history,
}
def get_config_variables(self):
from .config_variables import get_config_variables
@ -193,21 +205,3 @@ def manage_config(**kwargs):
if altered:
config.increment_version()
logging.getLogger(__name__).info("Updated config variables")
def startup():
"""
Runs commands that are needed at startup.
Sets the cache, constants and startup history
"""
if os.environ.get("NO_STARTUP"):
return
from openslides.utils.constants import set_constants, get_constants_from_apps
from openslides.utils.cache import element_cache
from openslides.core.models import History
element_cache.ensure_schema_version()
set_constants(get_constants_from_apps())
History.objects.build_history()

81
openslides/saml/README.md Normal file
View 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.

View 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
View 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.")

View File

@ -0,0 +1,5 @@
from openslides.utils.exceptions import OpenSlidesError
class SamlException(OpenSlidesError):
pass

View File

View 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)

View 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
View 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
View 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),
]

View 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
View 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")

View File

@ -1,5 +1,6 @@
from django.conf.urls import include, url
from openslides.saml import SAML_ENABLED
from openslides.utils.plugins import get_all_plugin_urlpatterns
@ -9,3 +10,6 @@ urlpatterns += [
url(r"^core/", include("openslides.core.urls")),
url(r"^users/", include("openslides.users.urls")),
]
if SAML_ENABLED:
urlpatterns += [url(r"^saml/", include("openslides.saml.urls"))]

View File

@ -23,18 +23,26 @@ class UserAccessPermissions(BaseAccessPermissions):
USERCANSEEEXTRASERIALIZER_FIELDS,
)
def filtered_data(full_data, whitelist):
def filtered_data(full_data, whitelist, whitelist_operator=None):
"""
Returns a new dict like full_data but only with whitelisted keys.
If the whitelist_operator is given and the full_data-user is the
oeperator (the user with user_id), the whitelist_operator will
be used instead of the whitelist.
"""
return {key: full_data[key] for key in whitelist}
if whitelist_operator is not None and full_data["id"] == user_id:
return {key: full_data[key] for key in whitelist_operator}
else:
return {key: full_data[key] for key in whitelist}
# We have five sets of data to be sent:
# We have some sets of data to be sent:
# * full data i. e. all fields (including session_auth_hash),
# * all data i. e. all fields but not session_auth_hash,
# * many data i. e. all fields but not the default password and session_auth_hash,
# * little data i. e. all fields but not the default password, session_auth_hash,
# comments, gender, email, last_email_send and active status,
# comments, gender, email, last_email_send, active status and auth_type
# * own data i. e. all little data fields plus email and gender. This is applied
# to the own user, if he just can see little or no data.
# * no data.
# Prepare field set for users with "all" data, "many" data and with "little" data.
@ -44,9 +52,12 @@ class UserAccessPermissions(BaseAccessPermissions):
all_data_fields.add("default_password")
many_data_fields = all_data_fields.copy()
many_data_fields.discard("default_password")
litte_data_fields = set(USERCANSEESERIALIZER_FIELDS)
litte_data_fields.add("groups_id")
litte_data_fields.discard("groups")
little_data_fields = set(USERCANSEESERIALIZER_FIELDS)
little_data_fields.add("groups_id")
little_data_fields.discard("groups")
own_data_fields = set(little_data_fields)
own_data_fields.add("email")
own_data_fields.add("gender")
# Check user permissions.
if await async_has_perm(user_id, "users.can_see_name"):
@ -56,7 +67,10 @@ class UserAccessPermissions(BaseAccessPermissions):
else:
data = [filtered_data(full, many_data_fields) for full in full_data]
else:
data = [filtered_data(full, litte_data_fields) for full in full_data]
data = [
filtered_data(full, little_data_fields, own_data_fields)
for full in full_data
]
else:
# Build a list of users, that can be seen without any permissions (with little fields).
@ -84,7 +98,7 @@ class UserAccessPermissions(BaseAccessPermissions):
# Parse data.
data = [
filtered_data(full, litte_data_fields)
filtered_data(full, little_data_fields, own_data_fields)
for full in full_data
if full["id"] in user_ids
]

View File

@ -2,10 +2,13 @@ from django.apps import AppConfig
from django.conf import settings
from django.contrib.auth.signals import user_logged_in
from .user_backend import DefaultUserBackend, user_backend_manager
class UsersAppConfig(AppConfig):
name = "openslides.users"
verbose_name = "OpenSlides Users"
user_backend_class = DefaultUserBackend
def ready(self):
# Import all required stuff.
@ -39,6 +42,9 @@ class UsersAppConfig(AppConfig):
self.get_model("PersonalNote").get_collection_string(), PersonalNoteViewSet
)
def get_startup_hooks(self):
return {30: user_backend_manager.collect_backends_from_apps}
def get_config_variables(self):
from .config_variables import get_config_variables
@ -55,6 +61,7 @@ class UsersAppConfig(AppConfig):
def get_angular_constants(self):
from django.contrib.auth.models import Permission
# Permissions
permissions = []
for permission in Permission.objects.all():
permissions.append(
@ -65,4 +72,8 @@ class UsersAppConfig(AppConfig):
),
}
)
return {"permissions": permissions}
# Backends
backends = user_backend_manager.get_backends_for_client()
return {"Permissions": permissions, "UserBackends": backends}

View 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),
)
]

View File

@ -122,6 +122,8 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
username = models.CharField(max_length=255, unique=True, blank=True)
auth_type = models.CharField(max_length=64, default="default")
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)

View File

@ -33,6 +33,7 @@ USERCANSEEEXTRASERIALIZER_FIELDS = USERCANSEESERIALIZER_FIELDS + (
"last_email_send",
"comment",
"is_active",
"auth_type",
)
@ -60,7 +61,7 @@ class UserFullSerializer(ModelSerializer):
"default_password",
"session_auth_hash",
)
read_only_fields = ("last_email_send",)
read_only_fields = ("last_email_send", "auth_type")
def validate(self, data):
"""

View 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()

View File

@ -21,6 +21,8 @@ from django.http.request import QueryDict
from django.utils.encoding import force_bytes, force_text
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from openslides.saml import SAML_ENABLED
from ..core.config import config
from ..core.signals import permission_change
from ..utils.auth import (
@ -48,6 +50,7 @@ from .access_permissions import (
)
from .models import Group, PersonalNote, User
from .serializers import GroupSerializer, PermissionRelatedField
from .user_backend import user_backend_manager
# Viewsets for the REST API
@ -126,8 +129,24 @@ class UserViewSet(ModelViewSet):
# Remove fields that the user is not allowed to change.
# The list() is required because we want to use del inside the loop.
for key in list(request.data.keys()):
if key not in ("username", "about_me"):
if key not in ("username", "about_me", "email"):
del request.data[key]
user_backend = user_backend_manager.get_backend(user.auth_type)
if user_backend:
disallowed_keys = user_backend.get_disallowed_update_keys()
for key in list(request.data.keys()):
if key in disallowed_keys:
del request.data[key]
# Hack to make the serializers validation work again if no username, last- or firstname is given:
if (
"username" not in request.data
and "first_name" not in request.data
and "last_name" not in request.data
):
request.data["username"] = user.username
response = super().update(request, *args, **kwargs)
return response
@ -150,6 +169,13 @@ class UserViewSet(ModelViewSet):
Expected data: { pasword: <the new password> }
"""
user = self.get_object()
if user.auth_type != "default":
raise ValidationError(
{
"detail": "The user does not have the login information stored in OpenSlides"
}
)
password = request.data.get("password")
if not isinstance(password, str):
raise ValidationError({"detail": "Password has to be a string."})
@ -173,7 +199,7 @@ class UserViewSet(ModelViewSet):
self.assert_list_of_ints(ids)
# Exclude the request user
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
users = self.bulk_get_users(request, ids)
for user in users:
password = User.objects.make_random_password()
user.set_password(password)
@ -192,7 +218,7 @@ class UserViewSet(ModelViewSet):
self.assert_list_of_ints(ids)
# Exclude the request user
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
users = self.bulk_get_users(request, ids)
# Validate all default passwords
for user in users:
try:
@ -236,7 +262,7 @@ class UserViewSet(ModelViewSet):
if not isinstance(value, bool):
raise ValidationError({"detail": "value must be true or false"})
users = User.objects.filter(pk__in=ids)
users = User.objects.filter(auth_type="default").filter(pk__in=ids)
if field == "is_active":
users = users.exclude(pk=request.user.id)
for user in users:
@ -265,7 +291,7 @@ class UserViewSet(ModelViewSet):
if action not in ("add", "remove"):
raise ValidationError({"detail": "The action must be add or remove"})
users = User.objects.exclude(pk=request.user.id).filter(pk__in=user_ids)
users = self.bulk_get_users(request, user_ids)
groups = list(Group.objects.filter(pk__in=group_ids))
for user in users:
@ -287,12 +313,23 @@ class UserViewSet(ModelViewSet):
self.assert_list_of_ints(ids)
# Exclude the request user
users = User.objects.exclude(pk=request.user.id).filter(pk__in=ids)
users = self.bulk_get_users(request, ids)
for user in list(users):
user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def bulk_get_users(self, request, ids):
"""
Get all users for the given ids. Exludes the request user and all
users with a non-default auth_type.
"""
return (
User.objects.filter(auth_type="default")
.exclude(pk=request.user.id)
.filter(pk__in=ids)
)
@list_route(methods=["post"])
@transaction.atomic
def mass_import(self, request):
@ -673,14 +710,19 @@ class WhoAmIDataView(APIView):
"""
Appends the user id to the context. Uses None for the anonymous
user. Appends also a flag if guest users are enabled in the config.
Appends also the serialized user if available.
Appends also the serialized user if available and auth_type.
"""
user_id = self.request.user.pk or 0
guest_enabled = anonymous_is_enabled()
auth_type = "default"
if user_id:
user_data = async_to_sync(element_cache.get_element_data)(
self.request.user.get_collection_string(), user_id, user_id
user_full_data = async_to_sync(element_cache.get_element_data)(
self.request.user.get_collection_string(), user_id
)
auth_type = user_full_data["auth_type"]
user_data = async_to_sync(element_cache.restrict_element_data)(
user_full_data, self.request.user.get_collection_string(), user_id
)
group_ids = user_data["groups_id"] or [GROUP_DEFAULT_PK]
else:
@ -697,6 +739,7 @@ class WhoAmIDataView(APIView):
"user_id": user_id or None,
"guest_enabled": guest_enabled,
"user": user_data,
"auth_type": auth_type,
"permissions": list(permissions),
}
@ -722,6 +765,8 @@ class UserLoginView(WhoAmIDataView):
if not form.is_valid():
raise ValidationError({"detail": "Username or password is not correct."})
self.user = form.get_user()
if self.user.auth_type != "default":
raise ValidationError({"detail": "Please login via your identity provider"})
auth_login(self.request, self.user)
return super().post(*args, **kwargs)
@ -755,6 +800,11 @@ class UserLoginView(WhoAmIDataView):
# Add the theme, so the loginpage is themed correctly
context["theme"] = config["openslides_theme"]
context["logo_web_header"] = config["logo_web_header"]
if SAML_ENABLED:
from openslides.saml.settings import get_saml_settings
context["saml_settings"] = get_saml_settings().general_settings
else:
# self.request.method == 'POST'
context.update(self.get_whoami_data())
@ -795,6 +845,7 @@ class SetPasswordView(APIView):
if not (
has_perm(user, "users.can_change_password")
or has_perm(user, "users.can_manage")
or user.auth_type != "default"
):
self.permission_denied(request)
if user.check_password(request.data["old_password"]):
@ -900,7 +951,7 @@ class PasswordResetView(APIView):
resetting their password.
"""
active_users = User.objects.filter(
**{"email__iexact": email, "is_active": True}
**{"email__iexact": email, "is_active": True, "auth_type": "default"}
)
return [u for u in active_users if u.has_usable_password()]

View File

@ -258,11 +258,18 @@ class ElementCache:
element = json.loads(encoded_element.decode()) # type: ignore
if user_id is not None:
restricter = self.cachables[collection_string].restrict_elements
restricted_elements = await restricter(user_id, [element])
element = restricted_elements[0] if restricted_elements else None
element = await self.restrict_element_data(
element, collection_string, user_id
)
return element
async def restrict_element_data(
self, element: Dict[str, Any], collection_string: str, user_id: int
) -> Optional[Dict[str, Any]]:
restricter = self.cachables[collection_string].restrict_elements
restricted_elements = await restricter(user_id, [element])
return restricted_elements[0] if restricted_elements else None
async def get_data_since(
self, user_id: Optional[int] = None, change_id: int = 0, max_change_id: int = -1
) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]:

View File

@ -38,3 +38,7 @@ def set_constants(value: Dict[str, Any]) -> None:
"""
global constants
constants = value
def set_constants_from_apps() -> None:
set_constants(get_constants_from_apps())

View File

@ -118,6 +118,14 @@ if use_redis:
'socket_timeout': 2
}
# SAML integration
# Please read https://github.com/OpenSlides/OpenSlides/blob/master/openslides/saml/README.md
# for additional requirements.
ENABLE_SAML = False
if ENABLE_SAML:
INSTALLED_APPS += ['openslides.saml']
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
@ -174,3 +182,5 @@ LOGGING = {
}
},
}
SETTINGS_FILEPATH = __file__

View 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()

View File

@ -19,7 +19,7 @@ force_grid_wrap = 0
use_parentheses = true
line_length = 88
known_first_party = openslides
known_third_party = pytest
known_third_party = pytest,onelogin
[mypy]
ignore_missing_imports = true

View File

@ -86,3 +86,13 @@ def reset_cache(request):
# Set constant default change_id
cast(MemoryCacheProvider, element_cache.cache_provider).default_change_id = 1
@pytest.fixture(scope="session", autouse=True)
def set_default_user_backend(request):
"""
Sets the userbackend once.
"""
from openslides.users.user_backend import user_backend_manager
user_backend_manager.collect_backends_from_apps()

View File

@ -9,10 +9,10 @@ from django.utils.crypto import get_random_string
from openslides.agenda.models import Item, ListOfSpeakers
from openslides.assignments.models import Assignment
from openslides.core.apps import startup
from openslides.motions.models import Motion
from openslides.topics.models import Topic
from openslides.users.models import Group, User
from openslides.utils.startup import run_startup_hooks
MOTION_NUMBER_OF_PARAGRAPHS = 4
@ -118,7 +118,7 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
startup()
run_startup_hooks()
self.create_topics(options)
self.create_motions(options)
self.create_assignments(options)

View File

@ -15,7 +15,13 @@ class TestWhoAmIView(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(
json.loads(response.content.decode()),
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
{
"auth_type": "default",
"user_id": None,
"user": None,
"permissions": [],
"guest_enabled": False,
},
)
def test_get_authenticated_user(self):
@ -58,7 +64,13 @@ class TestUserLogoutView(TestCase):
self.assertFalse(hasattr(self.client.session, "test_key"))
self.assertEqual(
json.loads(response.content.decode()),
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
{
"auth_type": "default",
"user_id": None,
"user": None,
"permissions": [],
"guest_enabled": False,
},
)
@ -72,7 +84,12 @@ class TestUserLoginView(TestCase):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTrue(json.loads(response.content.decode()).get("login_info_text"))
content = json.loads(response.content.decode())
self.assertTrue("login_info_text" in content)
self.assertTrue("privacy_policy" in content)
self.assertTrue("legal_notice" in content)
self.assertTrue("theme" in content)
self.assertTrue("logo_web_header" in content)
def test_post_no_data(self):
response = self.client.post(self.url)
@ -85,7 +102,12 @@ class TestUserLoginView(TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content.decode()).get("user_id"), 1)
content = json.loads(response.content.decode())
self.assertEqual(content.get("user_id"), 1)
self.assertTrue(isinstance(content.get("user"), dict))
self.assertTrue(isinstance(content.get("permissions"), list))
self.assertFalse(content.get("guest_enabled", True))
self.assertEqual(content.get("auth_type"), "default")
def test_post_incorrect_data(self):
response = self.client.post(

View 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"
)