Во многих проектах существует процессы аутентификации (в той или иной степени). Написано много “бест практис” во всех известных технологиях и т.д. и т.п.
Но вот пользователь сделал логин и? Ведь он далеко не всё может сделать. Как определить что он может видеть, а что нет. На какие кнопки имеет право нажимать, что может менять, что создавать или удалять.
В этой статье я хочу рассмотреть подход к решению этих проблем на веб аппликации.
Начнем с того, что настоящая/действенная авторизация может происходить только на сервере. На Front End мы можем только улучшить пользовательский интерфейс (хм, Русский язык могуч, но не всегда...), он же User Experience дальше UX. Мы можем скрыть кнопки на которые у пользователя нет прав нажимать, или не допустить его до страницы, или показать сообщение о том, что у него нет прав на то или иное действие.
И вот тут возникает вопрос, как сделать это максимально правильно.
Начнем с определения проблемы.
Мы создали Todo App и у нее есть разные виды пользователей:
USER — может видеть все таски и изменять их (ставить и убирать V), но не может удалять или создавать и не видит статистику.
ADMIN — может видеть все таски и создавать новые, но не видит статистику.
SUPER_ADMIN — может видеть все таски, создавать новые и удалять, также может видеть статистику.
view task | Create task | check/uncheck task (update) | delete task | view stats | |
---|---|---|---|---|---|
USER | V | X | V | X | X |
ADMIN | V | V | V | X | X |
SUPER_ADMIN | V | V | V | V | V |
В такой ситуации мы можем легко обойтись ролями “roles”. Однако, ситуация может сильно измениться. Представим, что есть пользователь, который должен иметь те же права что и ADMIN плюс удаление тасков. Или же просто USER с возможностью видеть статистику.
Простое решение, это создать новые роли.
Но на больших системах, с относительно большим количеством пользователей мы быстро потеряемся в огромном количестве ролей… И тут мы вспомним о “правах пользователя” permissions. Для более упрощенного управления мы можем создавать группы из нескольких permissions и присоединять их к пользователю. Всегда остается возможность добавить специфический permission конкретному пользователю.
Подобные решения можно встретить во многих больших сервисах. AWS, Google Cloud, SalesForce и т.д.
Также подобные решения уже имплементированы во многих фреймворках, например Django (python).
Я хочу привести пример имплементации для Angular аппликаций. (На примере все той же ToDo App).
Для начала необходимо определить все возможные permissions.
Разделим на фичеры
- У нас есть таски и статистика.
- Определим возможные действия с каждым из них.
- Таск: create, read, update, delete
- Статистика: read (в нашем примере только просмотр)
- Создадим карту (MAP) ролей и пермишнс
export const permissionsMap = {
todos:{
create:'*',
read:'*',
update:'*',
delete:'*'
},
stats:'*'
}
На мой взгляд это оптимальный вариант, однако в большинстве случаев сервер будет возвращать нечто вроде этого:
export permissions = [
'todos_create',
'todos_read',
'todos_update',
'todos_delete',
'stas'
]
Менее читабельно, но тоже очень неплохо.
Вот так выглядит наша аппликация при наличии у пользователя всех возможных permissions.
Начнем с USER, так выглядит его permissions:
export const USERpermissionsMap = {
todos:{
read:'*',
update:'*',
}
}
1) USER не может видеть статистику, то есть он в принципе не может перейти страницу статистики.
Для подобных ситуаций в Angular есть Guards, которые прописываются на уровне Routes (документация).
В нашем случае это выглядит так:
const routes: Routes = [
// {...Other routes},
{
path: 'stats',
component: TodoStatisticsComponent,
canActivate: [
PermissionsGuardService
],
data: {
permission: 'stats'
},
}
];
Следует обратить внимание на объект в data.
peremissions = ‘stats’, это именно те permissions которые необходимо иметь пользователю, чтобы иметь доступ к этой странице.
Основываясь на требуемых data.permissions и тех permissions которые нам передал сервер PermissionsGuardService будет решать пускать или нет USER на страницу ‘/stats’.
@Injectable({
providedIn: 'root'
})
export class PermissionsGuardService implements CanActivate {
constructor(
private store: Store<any>) {
}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
// Required permission to continue navigation
const required = route.data.permission;
// User permissions that we got from server
const userPerms = this.getPermissions();
// verification
const isPermitted = checkPermissions(required, userPerms);
if (!isPermitted) {
alert('ROUTE GUARD SAYS: \n You don\'t have permissions to see this page');
}
return isPermitted;
}
getPermissions() {
return localStorage.get('userPermissions')
}
}
Эта функция содержит логику принятия решения, — сущестует ли неободимый permission (required) среди всех permissions находящихся у USER (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;
}
И так. Наш USER не может попасть на страницу статистики. Однако он все еще может создавать таски и удалять их.
Для того чтобы USER не мог удалить таск, достаточно будет убрать красный (Х) из строки таска.
Для этой цели воспользуемся Structural Directive.
<!--
Similar to other Structural Directives in angular (*ngIF, *ngFor..) we have '*' in directive name
Input for directive is a required permission to see this btn.
-->
<button
*appPermissions="'todos_delete'"
class="delete-button"
(click)="removeSingleTodo()"> X
</button>
@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 isPermitted = checkPermissions(this._required, this.getPermissions());
if (isPermitted) {
this._viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
} else {
console.log('PERMISSIONS DIRECTIVE says \n You don\'t have permissions to see it');
}
}
getPermissions() {
localStorage.get('userPermissions')
}
}
Теперь USER не видит кнопки DELETE но все еще может добавлять новые таски.
Убрать поле ввода — испортит весь вид нашей аппликации. Правильным решением в данной ситуации будет disable
поля ввода.
Воспользуемся pipe.
<!--
as a value `permissions` pipe will get required permissions
`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(private store: Store<any>) {
}
transform(required: any, args?: any): any {
const isPermitted = checkPermissions(required, this.getPermissions());
if (isPermitted) {
return true;
} else {
console.log('[PERMISSIONS PIPE] You don\'t have permissions');
return false;
}
}
getPermissions() {
return localStorage.get('userPermissions')
}
}
И вот теперь USER может только видеть таски и изменять их (V/X). Однако у нас осталась еще одна кнопка ‘Clear completed’.
Предположим, что у нас следующие требования Продук Менеджера:
- Кнопка ‘Clear Completed’ должна быть видна всем и всегда.
- Oна также должна быть кликабельна.
- В случае если USER без соответствующих пермишн нажимает на кнопку, должно появится сообщение.
Constructional directive нам не поможет, также как и pipe.
Прописывать в функции компоненты проверку на permissions тоже не очень удобно.
Все что нам необходимо, — это между кликом и выполнением привязанной функции произвести проверку permissions.
На мой взгляд здесь стоит воспользоваться декораторами.
export function Permissions(required) {
return (classProto, propertyKey, descriptor) => {
const originalFunction = descriptor.value;
descriptor.value = function (...args: any[]) {
const userPerms = localStorage.get('userPermissions')
const isPermitted = checkPermissions(required, userPerms);
if (isPermitted) {
originalFunction.apply(this, args);
} else {
alert('you have no permissions \n [PERMISSIONS DECORATOR]');
}
};
return descriptor;
};
}
@Component({
selector: 'app-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.css']
})
export class ActionsComponent implements OnInit {
@Output() deleteCompleted = new EventEmitter();
constructor() {
}
@Permissions('todos_delete')
public deleteCompleted() {
this.deleteCompleted.emit();
}
}
Дикораторы заслуживают отдельной статьи.
Наш финальный результат:
Итог:
Данный подход позволяет легко и динамично адаптировать наш UX в соответствии с permissions которые есть у пользователя.
А главное, нам для этого не надо пихать сервисы во все компоненты или забивать темплейты ‘*ngIf’.
Буду рад комментариям.