Merge pull request #4471 from FinnStutzenstein/permissionsInWhoAmI

rework login system (again)
This commit is contained in:
Finn Stutzenstein 2019-03-11 14:03:46 +01:00 committed by GitHub
commit 3ac7788fe8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 224 additions and 169 deletions

View File

@ -28,6 +28,10 @@ export class AuthGuard implements CanActivate, CanActivateChild {
public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
const basePerm: string | string[] = route.data.basePerm;
console.log('Auth guard');
console.log('motions.can_see:', this.operator.hasPerms('motions.can_see'));
console.log('motions.can_manage:', this.operator.hasPerms('motions.can_manage'));
if (!basePerm) {
return true;
} else if (basePerm instanceof Array) {

View File

@ -1,21 +1,12 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { OperatorService } from 'app/core/core-services/operator.service';
import { OperatorService, WhoAmI } from 'app/core/core-services/operator.service';
import { environment } from 'environments/environment';
import { User } from '../../shared/models/users/user';
import { OpenSlidesService } from './openslides.service';
import { HttpService } from './http.service';
import { DataStoreService } from './data-store.service';
/**
* The data returned by a post request to the login route.
*/
interface LoginResponse {
user_id: number;
user: User;
}
/**
* Authenticates an OpenSlides user with username and password
*/
@ -49,31 +40,57 @@ export class AuthService {
* @param password
* @returns The login response.
*/
public async login(username: string, password: string): Promise<LoginResponse> {
public async login(username: string, password: string, earlySuccessCallback: () => void): Promise<WhoAmI> {
const user = {
username: username,
password: password
};
const response = await this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user);
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', user);
earlySuccessCallback();
await this.operator.setWhoAmI(response);
await this.redirectUser(response.user_id);
return response;
}
/**
* Redirects the user to the page where he came from. Boots OpenSlides,
* if it wasn't done before.
*/
public async redirectUser(userId: number): Promise<void> {
if (!this.OpenSlides.booted) {
await this.OpenSlides.afterLoginBootup(userId);
}
let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/';
if (redirect.includes('login')) {
redirect = '/';
}
this.router.navigate([redirect]);
}
/**
* Login for guests.
*/
public async guestLogin(): Promise<void> {
this.redirectUser(null);
}
/**
* Logout function for both the client and the server.
*
* Will clear the current operator and datastore and
* Will clear the datastore, update the current operator and
* send a `post`-request to `/apps/users/logout/'`. Restarts OpenSlides.
*/
public async logout(): Promise<void> {
await this.operator.setUser(null);
let response = null;
try {
await this.http.post(environment.urlPrefix + '/users/logout/', {});
response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/logout/', {});
} catch (e) {
// We do nothing on failures. Reboot OpenSlides anyway.
}
// Clear the DataStore
this.DS.clear();
await this.DS.clear();
await this.operator.setWhoAmI(response);
await this.OpenSlides.reboot();
this.router.navigate(['/']);
this.OpenSlides.reboot();
}
}

View File

@ -1,12 +1,13 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { WebsocketService } from './websocket.service';
import { OperatorService, WhoAmIResponse } from './operator.service';
import { OperatorService } from './operator.service';
import { StorageService } from './storage.service';
import { AutoupdateService } from './autoupdate.service';
import { DataStoreService } from './data-store.service';
import { take } from 'rxjs/operators';
/**
* Handles the bootup/showdown of this application.
@ -16,10 +17,20 @@ import { take } from 'rxjs/operators';
})
export class OpenSlidesService {
/**
* if the user tries to access a certain URL without being authenticated, the URL will be stored here
* If the user tries to access a certain URL without being authenticated, the URL will be stored here
*/
public redirectUrl: string;
/**
* Saves, if OpenSlides is fully booted. This means, that a user must be logged in
* (Anonymous is also a user in this case). This is the case after `afterLoginBootup`.
*/
private _booted = false;
public get booted(): boolean {
return this._booted;
}
/**
* Constructor to create the OpenSlidesService. Registers itself to the WebsocketService.
* @param storageService
@ -53,10 +64,6 @@ export class OpenSlidesService {
public async bootup(): Promise<void> {
// start autoupdate if the user is logged in:
const response = await this.operator.whoAmIFromStorage();
await this.bootupWithWhoAmI(response);
}
private async bootupWithWhoAmI(response: WhoAmIResponse): Promise<void> {
if (!response.user && !response.guest_enabled) {
this.redirectUrl = location.pathname;
@ -85,9 +92,10 @@ export class OpenSlidesService {
* the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore
* and websocket. This "login" also may be the "login" of an anonymous when he is using
* OpenSlides as a guest.
* @param userId
* @param userId the id or null for guest
*/
public async afterLoginBootup(userId: number): Promise<void> {
public async afterLoginBootup(userId: number | null): Promise<void> {
console.log('user id', userId);
// Check, which user was logged in last time
const lastUserId = await this.storageService.get<number>('lastUserLoggedIn');
// if the user changed, reset the cache and save the new user.
@ -96,6 +104,8 @@ export class OpenSlidesService {
await this.storageService.set('lastUserLoggedIn', userId);
}
await this.setupDataStoreAndWebSocket();
// Now finally booted.
this._booted = true;
}
/**
@ -121,16 +131,16 @@ export class OpenSlidesService {
/**
* Shuts OpenSlides down. The websocket is closed and the operator is not set.
*/
public async shutdown(): Promise<void> {
public shutdown(): void {
this.websocketService.close();
await this.operator.setUser(null);
this._booted = false;
}
/**
* Shutdown and bootup.
*/
public async reboot(): Promise<void> {
await this.shutdown();
this.shutdown();
await this.bootup();
}

View File

@ -13,6 +13,7 @@ import { UserRepositoryService } from '../repositories/users/user-repository.ser
import { CollectionStringMapperService } from './collectionStringMapper.service';
import { StorageService } from './storage.service';
import { HttpService } from './http.service';
import { filter, auditTime } from 'rxjs/operators';
/**
* Permissions on the client are just strings. This makes clear, that
@ -23,18 +24,24 @@ export type Permission = string;
/**
* Response format of the WhoAmI request.
*/
export interface WhoAmIResponse {
export interface WhoAmI {
user_id: number;
guest_enabled: boolean;
user: User;
permissions: Permission[];
}
function isWhoAmIResponse(obj: any): obj is WhoAmIResponse {
function isWhoAmI(obj: any): obj is WhoAmI {
if (!obj) {
return false;
}
const whoAmI = obj as WhoAmIResponse;
return whoAmI.guest_enabled !== undefined && whoAmI.user !== undefined && whoAmI.user_id !== undefined;
const whoAmI = obj as WhoAmI;
return (
whoAmI.guest_enabled !== undefined &&
whoAmI.user !== undefined &&
whoAmI.user_id !== undefined &&
whoAmI.permissions !== undefined
);
}
const WHOAMI_STORAGE_KEY = 'whoami';
@ -80,7 +87,7 @@ export class OperatorService implements OnAfterAppsLoaded {
* Save, if guests are enabled.
*/
public get guestsEnabled(): boolean {
return this.currentWhoAmIResponse ? this.currentWhoAmIResponse.guest_enabled : false;
return this.currentWhoAmI ? this.currentWhoAmI.guest_enabled : false;
}
/**
@ -106,7 +113,7 @@ export class OperatorService implements OnAfterAppsLoaded {
/**
* The current WhoAmI response to extract the user (the operator) from.
*/
private currentWhoAmIResponse: WhoAmIResponse | null;
private currentWhoAmI: WhoAmI | null;
/**
* Sets up an observer for watching changes in the DS. If the operator user or groups are changed,
@ -124,43 +131,51 @@ export class OperatorService implements OnAfterAppsLoaded {
private storageService: StorageService
) {
this.DS.changeObservable.subscribe(newModel => {
if (this._user) {
if (newModel instanceof Group) {
this.updatePermissions();
}
if (newModel instanceof User && this._user.id === newModel.id) {
this.updateUser(newModel);
}
} else if (newModel instanceof Group && newModel.id === 1) {
// Group 1 (default) for anonymous changed
this.updatePermissions();
if (this._user && newModel instanceof User && this._user.id === newModel.id) {
this._user = newModel;
this.updateUserInCurrentWhoAmI();
}
});
this.DS.changeObservable
.pipe(
filter(
model =>
// Any group has changed if we have an operator or
// group 1 (default) for anonymous changed
model instanceof Group && (!!this._user || model.id === 1)
),
auditTime(10)
)
.subscribe(newModel => this.updatePermissions());
}
/**
* Gets the current WHoAmI response from the storage.
* Returns a default WhoAmI response
*/
public async whoAmIFromStorage(): Promise<WhoAmIResponse> {
const defaultResponse = {
private getDefaultWhoAmIResponse(): WhoAmI {
return {
user_id: null,
guest_enabled: false,
user: null
user: null,
permissions: []
};
let response: WhoAmIResponse;
}
/**
* Gets the current WhoAmI response from the storage.
*/
public async whoAmIFromStorage(): Promise<WhoAmI> {
let response: WhoAmI;
try {
response = await this.storageService.get<WhoAmIResponse>(WHOAMI_STORAGE_KEY);
if (response) {
this.processWhoAmIResponse(response);
} else {
response = defaultResponse;
response = await this.storageService.get<WhoAmI>(WHOAMI_STORAGE_KEY);
if (!response) {
response = this.getDefaultWhoAmIResponse();
}
} catch (e) {
response = defaultResponse;
response = this.getDefaultWhoAmIResponse();
}
this.currentWhoAmIResponse = response;
return this.currentWhoAmIResponse;
await this.updateCurrentWhoAmI(response);
return this.currentWhoAmI;
}
/**
@ -177,27 +192,11 @@ export class OperatorService implements OnAfterAppsLoaded {
* Sets the operator user. Will be saved to storage
* @param user The new operator.
*/
public async setUser(user: User): Promise<void> {
await this.updateUser(user, true);
}
/**
* Updates the user and update the permissions.
*
* @param user The user to set.
* @param saveToStoare Whether to save the user to the storage WhoAmI.
*/
private async updateUser(user: User | null, saveToStorage: boolean = false): Promise<void> {
this._user = user;
if (saveToStorage) {
await this.saveUserToStorate();
public async setWhoAmI(whoami: WhoAmI | null): Promise<void> {
if (whoami === null) {
whoami = this.getDefaultWhoAmIResponse();
}
if (user && this.userRepository) {
this._viewUser = this.userRepository.getViewModel(user.id);
} else {
this._viewUser = null;
}
this.updatePermissions();
await this.updateCurrentWhoAmI(whoami);
}
/**
@ -205,51 +204,56 @@ export class OperatorService implements OnAfterAppsLoaded {
*
* @returns The response of the WhoAmI request.
*/
public async whoAmI(): Promise<WhoAmIResponse> {
public async whoAmI(): Promise<WhoAmI> {
try {
const response = await this.http.get(environment.urlPrefix + '/users/whoami/');
if (isWhoAmIResponse(response)) {
this.processWhoAmIResponse(response);
await this.storageService.set(WHOAMI_STORAGE_KEY, response);
this.currentWhoAmIResponse = response;
if (isWhoAmI(response)) {
await this.updateCurrentWhoAmI(response);
} else {
this.offlineService.goOfflineBecauseFailedWhoAmI();
}
} catch (e) {
this.offlineService.goOfflineBecauseFailedWhoAmI();
}
return this.currentWhoAmIResponse;
return this.currentWhoAmI;
}
/**
* Saves the user to storage by wrapping it into a (maybe existing)
* WhoAMI response.
*/
private async saveUserToStorate(): Promise<void> {
if (!this.currentWhoAmIResponse) {
this.currentWhoAmIResponse = {
user_id: null,
guest_enabled: false,
user: null
};
private async updateUserInCurrentWhoAmI(): Promise<void> {
if (!this.currentWhoAmI) {
this.currentWhoAmI = this.getDefaultWhoAmIResponse();
}
if (this.user) {
this.currentWhoAmIResponse.user_id = this.user.id;
this.currentWhoAmIResponse.user = this.user;
if (this.isAnonymous) {
this.currentWhoAmI.user_id = null;
this.currentWhoAmI.user = null;
} else {
this.currentWhoAmIResponse.user_id = null;
this.currentWhoAmIResponse.user = null;
this.currentWhoAmI.user_id = this.user.id;
this.currentWhoAmI.user = this.user;
}
await this.storageService.set(WHOAMI_STORAGE_KEY, this.currentWhoAmIResponse);
this.currentWhoAmI.permissions = this.permissions;
await this.updateCurrentWhoAmI();
}
/**
* Processes a WhoAmI response and set the user appropriately.
*
* @param response The WhoAMI response
* Updates the user and update the permissions.
*/
private processWhoAmIResponse(response: WhoAmIResponse): void {
this.updateUser(response.user ? new User(response.user) : null);
private async updateCurrentWhoAmI(whoami?: WhoAmI): Promise<void> {
if (whoami) {
this.currentWhoAmI = whoami;
} else {
whoami = this.currentWhoAmI;
}
this._user = whoami ? whoami.user : null;
if (this._user && this.userRepository) {
this._viewUser = this.userRepository.getViewModel(this._user.id);
} else {
this._viewUser = null;
}
await this.updatePermissions();
}
/**
@ -306,24 +310,42 @@ export class OperatorService implements OnAfterAppsLoaded {
/**
* Update the operators permissions and publish the operator afterwards.
* Saves the current WhoAmI to storage with the updated permissions
*/
private updatePermissions(): void {
private async updatePermissions(): Promise<void> {
this.permissions = [];
// Anonymous or users in the default group.
if (!this.user || this.user.groups_id.length === 0) {
const defaultGroup = this.DS.get<Group>('users/group', 1);
if (defaultGroup && defaultGroup.permissions instanceof Array) {
this.permissions = defaultGroup.permissions;
// If we do not have any groups, take the permissions from the
// latest WhoAmI response.
if (this.DS.getAll(Group).length === 0) {
if (this.currentWhoAmI) {
this.permissions = this.currentWhoAmI.permissions;
}
} else {
const permissionSet = new Set();
this.DS.getMany(Group, this.user.groups_id).forEach(group => {
group.permissions.forEach(permission => {
permissionSet.add(permission);
// Anonymous or users in the default group.
if (!this.user || this.user.groups_id.length === 0) {
const defaultGroup = this.DS.get<Group>('users/group', 1);
if (defaultGroup && defaultGroup.permissions instanceof Array) {
this.permissions = defaultGroup.permissions;
}
} else {
const permissionSet = new Set();
this.DS.getMany(Group, this.user.groups_id).forEach(group => {
group.permissions.forEach(permission => {
permissionSet.add(permission);
});
});
});
this.permissions = Array.from(permissionSet.values());
this.permissions = Array.from(permissionSet.values());
}
}
// Save perms to current WhoAmI
if (!this.currentWhoAmI) {
this.currentWhoAmI = this.getDefaultWhoAmIResponse();
}
this.currentWhoAmI.permissions = this.permissions;
await this.storageService.set(WHOAMI_STORAGE_KEY, this.currentWhoAmI);
// publish changes in the operator.
this.operatorSubject.next(this.user);
this.viewOperatorSubject.next(this.viewUser);

View File

@ -9,11 +9,9 @@ import { BaseComponent } from 'app/base.component';
import { AuthService } from 'app/core/core-services/auth.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { environment } from 'environments/environment';
import { OpenSlidesService } from 'app/core/core-services/openslides.service';
import { LoginDataService, LoginData } from 'app/core/ui-services/login-data.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { HttpService } from 'app/core/core-services/http.service';
import { User } from 'app/shared/models/users/user';
interface LoginDataWithInfoText extends LoginData {
info_text?: string;
@ -81,7 +79,6 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private httpService: HttpService,
private OpenSlides: OpenSlidesService,
private loginDataService: LoginDataService
) {
super();
@ -112,8 +109,7 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
this.operatorSubscription = this.operator.getUserObservable().subscribe(user => {
if (user) {
this.clearOperatorSubscription();
this.redirectUser();
this.OpenSlides.afterLoginBootup(user.id);
this.authService.redirectUser(user.id);
}
});
}
@ -154,30 +150,16 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
this.loginErrorMsg = '';
this.inProcess = true;
try {
const response = await this.authService.login(this.loginForm.value.username, this.loginForm.value.password);
this.clearOperatorSubscription(); // We take control, not the subscription.
this.inProcess = false;
await this.operator.setUser(new User(response.user));
this.OpenSlides.afterLoginBootup(response.user_id);
this.redirectUser();
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
this.clearOperatorSubscription(); // We take control, not the subscription.
});
} catch (e) {
this.loginForm.setErrors({
notFound: true
});
this.loginErrorMsg = e;
this.inProcess = false;
}
}
/**
* Redirects the user to the page where he came from.
*/
private redirectUser(): void {
let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/';
if (redirect.includes('login')) {
redirect = '/';
}
this.router.navigate([redirect]);
this.inProcess = false;
}
/**
@ -198,6 +180,6 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* Guests (if enabled) can navigate directly to the main page.
*/
public guestLogin(): void {
this.router.navigate(['/']);
this.authService.guestLogin();
}
}

View File

@ -1,6 +1,6 @@
import smtplib
import textwrap
from typing import List
from typing import List, Set
from asgiref.sync import async_to_sync
from django.conf import settings
@ -471,7 +471,46 @@ class PersonalNoteViewSet(ModelViewSet):
# Special API views
class UserLoginView(APIView):
class WhoAmIDataView(APIView):
def get_whoami_data(self):
"""
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.
"""
user_id = self.request.user.pk or 0
guest_enabled = anonymous_is_enabled()
if user_id:
user_data = async_to_sync(element_cache.get_element_restricted_data)(
user_id, self.request.user.get_collection_string(), user_id
)
group_ids = user_data["groups_id"] or [GROUP_DEFAULT_PK]
else:
user_data = None
group_ids = [GROUP_DEFAULT_PK] if guest_enabled else []
# collect all permissions
permissions: Set[str] = set()
group_all_data = async_to_sync(element_cache.get_all_full_data_ordered)()[
"users/group"
]
for group_id in group_ids:
permissions.update(group_all_data[group_id]["permissions"])
return {
"user_id": user_id or None,
"guest_enabled": guest_enabled,
"user": user_data,
"permissions": list(permissions),
}
def get_context_data(self, **context):
context.update(self.get_whoami_data())
return super().get_context_data(**context)
class UserLoginView(WhoAmIDataView):
"""
Login the user.
"""
@ -525,14 +564,11 @@ class UserLoginView(APIView):
context["theme"] = config["openslides_theme"]
else:
# self.request.method == 'POST'
context["user_id"] = self.user.pk
context["user"] = async_to_sync(element_cache.get_element_restricted_data)(
self.user.pk or 0, self.user.get_collection_string(), self.user.pk
)
context.update(self.get_whoami_data())
return super().get_context_data(**context)
class UserLogoutView(APIView):
class UserLogoutView(WhoAmIDataView):
"""
Logout the user.
"""
@ -546,33 +582,13 @@ class UserLogoutView(APIView):
return super().post(*args, **kwargs)
class WhoAmIView(APIView):
class WhoAmIView(WhoAmIDataView):
"""
Returns the id of the requesting user.
"""
http_method_names = ["get"]
def get_context_data(self, **context):
"""
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.
"""
user_id = self.request.user.pk or 0
if user_id:
user_data = async_to_sync(element_cache.get_element_restricted_data)(
user_id, self.request.user.get_collection_string(), user_id
)
else:
user_data = None
return super().get_context_data(
user_id=user_id or None,
guest_enabled=anonymous_is_enabled(),
user=user_data,
**context,
)
class SetPasswordView(APIView):
"""

View File

@ -15,7 +15,7 @@ class TestWhoAmIView(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(
json.loads(response.content.decode()),
{"user_id": None, "user": None, "guest_enabled": False},
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
)
def test_get_authenticated_user(self):
@ -56,6 +56,10 @@ class TestUserLogoutView(TestCase):
self.assertEqual(response.status_code, 200)
self.assertFalse(hasattr(self.client.session, "test_key"))
self.assertEqual(
json.loads(response.content.decode()),
{"user_id": None, "user": None, "permissions": [], "guest_enabled": False},
)
class TestUserLoginView(TestCase):