Во многих проектах существуют процессы аутентификации (в большей или меньшей степени). Было написано множество «лучших практик» по всем известным технологиям и так далее, и тому подобное.
Но что происходит после того, как пользователь выполнил вход? Ведь он далеко не все может сделать. Как определить, что он может видеть, а что нет. На какие кнопки он имеет право нажимать, что изменять, создавать и удалять.
В этой статье я хочу рассмотреть подход, используемый для решения этих проблем в веб-приложении.
Начнем с того, что настоящая/эффективная авторизация может происходить только на сервере. На front-end мы можем просто улучшить UI и UX. Мы можем скрыть кнопки, на которые пользователь не имеет права нажимать, или не дать ему попасть на страницы, или показать сообщение о том, что у него нет прав на выполнение определенного действия.
И здесь возникает вопрос, как сделать это максимально корректно? Давайте начнем с определения проблемы.
Мы создали приложение Todo App и в нем есть разные типы пользователей:
-
USER — может видеть и обновлять все задачи (ставить/снимать галочки), но не может удалять, создавать и видеть страницу статистики.
-
ADMIN — может видеть все задачи и создавать новые, но не может видеть статистику.
-
SUPER_ADMIN — может видеть все задачи, создавать новые и удалять их, а также видеть статистику.
В этой ситуации мы можем использовать «роли». Однако ситуация может сильно измениться. Представьте себе пользователя ADMIN с правами на удаление задач. Или пользователя USER с правами на просмотр статистики. Простое решение — создать новые роли.
Но в больших приложениях, с массивной системой ролей пользователей, мы быстро запутаемся в огромном количестве ролей…
И здесь мы вспоминаем о разрешениях «прав пользователя». Для более удобного управления мы можем создавать группы из нескольких разрешений и прикреплять их к пользователю. Всегда есть возможность добавить конкретное разрешение конкретному пользователю.
Подобные решения можно встретить во многих крупных сервисах: AWS, Google Cloud, SalesForce и так далее. Также подобные решения уже реализованы во многих фреймворках, например, Django (Python).
Я же хочу привести пример реализации для приложений Angular. (На примере того же ToDo App).
Для начала определим все возможные разрешения.
- Делим на функции.У нас есть задачи и статистика.
- Определяем возможные действия с каждой из них.Задача: создание, чтение, обновление, удаление.Статистика: чтение (в нашем примере только просмотр).
- Создаем карту ролей и разрешений.
export const permissionsMap = {
todos:{
create:'*',
read:'*',
update:'*',
delete:'*'
},
stats:'*'
}
На мой взгляд, это лучший вариант, но в большинстве случаев сервер вернет что-то вроде этого:
export permissions = [
'todos_create',
'todos_read',
'todos_update',
'todos_delete',
'stats',
]
Менее читабельно, но тоже неплохо.
Вот как выглядит наше приложение, когда пользователь уже аутентифицирован, но UI/UX еще не адаптирован в соответствии с правами пользователя:
Давайте посмотрим на полномочия пользователя:
export const USERpermissionsMap = {
todos:{
read:'*',
update:'*',
}
}
Навигация:
Пользователь не может видеть статистику, это означает, что он в принципе не может перемещаться по этой странице.
Для таких ситуаций в Angular существуют Guards, которые используются на уровне Routes (документация).
@Injectable({
providedIn: 'root'
})
export class PermissionsGuardService implements CanActivate {
constructor() {
}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
const userPerms = getPermissions();
const required = route.data.permission;
const isPermitted = checkPermissions(required, userPerms);
if (!isPermitted) {
alert('ROUTE GUARD SAYS: n You don't have permissions to see this page');
}
return isPermitted;
}
}
Обратите внимание на объект в
data.permissions = 'stats'
, это именно те разрешения, которые должны быть у пользователя, чтобы иметь доступ к этой странице.
На основании требуемых data.permissions
и permissions
пользователя PermissionsGuardService решит, разрешить или нет доступ к странице ‘/stats’.
getPermissions();
вспомогательная функция, которая возвращает объект с разрешениями пользователя.
export function getPermissions() {
let userPerms;
// In our example we using stor as a single source of truth.
// Full implementation can be found here https://github.com/danduh/ngx-to-do-permissions.
const store: Store<any> = AppInjector.get(Store);
store
.pipe(
select(userPermissionsState),
take(1)
)
.subscribe((_p) => {
userPerms = _p ? _p : {};
});
return userPerms;
}
export function checkPermissions(required: string, userPerms) {
// 1) Separate feature and action
const [feature, action] = required.split('_');
// 2) Check if user have any type of access to the feature
if (!userPerms.hasOwnProperty(feature)) {
return false;
}
// 3) Check if user have permission for required action
if (!userPerms[feature].hasOwnProperty(action)) {
return false;
}
return true;
}
Скрытие элементов
Теперь наш пользователь не может перейти на страницу статистики. Однако он все еще может создавать задачи и удалять их.
Чтобы пользователь не мог удалять задания, достаточно убрать красный символ (X) из строки задания.
Для этого мы создадим пользовательскую Структурную директиву.
@Directive({
selector: '[appPermissions]'
})
export class PermissionsDirective {
private _required: string;
private _viewRef: EmbeddedViewRef<any> | null = null;
private _templateRef: TemplateRef<any> | null = null;
@Input()
set appPermissions(permission: string) {
this._required = permission;
this._viewRef = null;
this.init();
}
constructor(private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef) {
this._templateRef = templateRef;
}
init() {
const userPerms = getPermmisions();
const isPermitted = checkPermissions(this._required, userPerms);
if (isPermitted) {
this._viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
} else {
console.log('PERMISSIONS DIRECTIVE says n You don't have permissions to see it');
}
}
}
<!--
Similar to other Structural Directives in angular
(*ngIF, *ngFor..) we have '*' in directive name
'todos_delete' -> is an Input() for directive
as a required permission to see this btn.
-->
<button
*appPermissions="'todos_delete'"
class="delete-button"
(click)="removeSingleTodo()"> X
</button>
Теперь пользователь не видит кнопку DELETE, но может добавлять новые задачи.
Примите состояние элемента:
Если мы уберем поле ввода, весь внешний вид нашего приложения испортится. Правильным решением в этой ситуации будет отключить поле ввода.
Давайте попробуем сделать это с помощью трубы.
<!--
as a value `permissions` pipe will get required permission
`permissions` pipe return true or false, that's why we have !('todos_create' | permissions)
to set disable=true if pipe returns false
-->
<input class="centered-block"
[disabled]="!('todos_create' | permissions)"
placeholder="What needs to be done?" autofocus/>
@Pipe({
name: 'permissions'
})
export class PermissionsPipe implements PipeTransform {
constructor() {
}
transform(required: any, args?: any): any {
const userPerms = getPermissions();
const isPermitted = checkPermissions(required, userPerms);
if (isPermitted) {
return true;
} else {
console.log('[PERMISSIONS PIPE] You don't have permissions');
return false;
}
}
}
Теперь пользователь может только видеть задачи и изменять их (отмечать/отменять отметки). Тем не менее, у нас все еще есть кнопка «Очистить выполненное», полностью функциональная и доступная для пользователя.
Декораторы:
Предположим, что у нас есть следующие требования от нашего менеджера продукта:
-
Кнопка «Очистить завершенное» должна быть видна всем и всегда.
-
Она также должна быть кликабельной.
-
В случае, если USER без соответствующих прав нажимает на кнопку, должно появиться сообщение об ошибке.
Конструктивная директива нам не поможет, как и трубы.
Также не очень удобно регистрировать проверку прав доступа в функциях компонента. Все, что нам нужно, это проверка прав доступа между нажатием кнопки и выполнением связанной функции.
На мой взгляд, здесь стоит использовать декораторы.
@Component({
selector: 'app-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.css']
})
export class ActionsComponent implements OnInit {
@Output() deleteCompleted = new EventEmitter();
constructor() {
}
// Our custom decorator, as a param we pass required permission
@Permissions('todos_delete')
public deleteCompleted() {
this.deleteCompleted.emit();
}
}
export function Permissions(required) {
return (classProto, propertyKey, descriptor) => {
const originalFunction = descriptor.value;
descriptor.value = function (...args: any[]) {
const userPerms = getPermissions();
const isPermitted = checkPermissions(required, userPerms);
if (isPermitted) {
originalFunction.apply(this, args);
} else {
// Should throw/log error, but for better visibility will use alert()
alert('PERMISSIONS DECORATOR says n you have no permissions');
}
};
return descriptor;
};
}
И наш конечный результат:
Заключение
Возможно, это не самые лучшие решения. Но этот подход позволяет нам легко и динамично адаптировать наш UX в соответствии с разрешениями, которые есть у пользователя.
Ссылка на репо
Спасибо Петру Пшеничному