Skip to content

Commit

Permalink
Grant access to scoped applications
Browse files Browse the repository at this point in the history
  • Loading branch information
dyakovri committed Apr 7, 2024
1 parent 17484d5 commit 8463384
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 21 deletions.
13 changes: 11 additions & 2 deletions src/api/auth/UserSessionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AuthBaseApi } from './AuthBaseApi';

interface CreateBody {
scopes: string[];
expires: string;
expires?: string;
}

export enum MySessionInfo {
Expand Down Expand Up @@ -33,6 +33,15 @@ interface SessionResponse {
last_activity: string;
}

interface SessionCreateResponse {
id: number;
user_id: number;
session_name: string;
last_activity: string;
token: string;
expires: string;
}

export enum SessionInfo {
SessionScopes = 'session_scopes',
Token = 'token',
Expand Down Expand Up @@ -75,7 +84,7 @@ class UserSessionApi extends AuthBaseApi {
>('/session', { info });
}
public async createSession(body: CreateBody) {
return this.post<SessionResponse, CreateBody>('/session', body);
return this.post<SessionCreateResponse, CreateBody>('/session', body);
}
public async deleteSessions(delete_current?: boolean) {
return this.delete<string, { delete_current?: boolean }>('/session', { delete_current });
Expand Down
14 changes: 14 additions & 0 deletions src/api/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ButtonType, ButtonView } from './index';
export interface Entity {
id: number;
}
Expand Down Expand Up @@ -45,13 +46,22 @@ export enum ButtonType {
Disabled = 'disabled',
}

export enum ButtonView {
Active = 'active',
Blocked = 'blocked',
}

export interface AppButton {
id: number;
order: number;
icon: string;
name: string;
link: string;
type: ButtonType;
view: ButtonView;
required_scopes?: string[];
optional_scopes?: string[];
scopes?: string[];
}

export interface ServiceData {
Expand All @@ -61,6 +71,10 @@ export interface ServiceData {
name: string;
link: string;
type: ButtonType;
view: ButtonView;
required_scopes?: string[];
optional_scopes?: string[];
scopes?: string[];
}

export interface AppButtonCategory {
Expand Down
1 change: 1 addition & 0 deletions src/models/LocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum LocalStorageItem {
Token = 'token',
TokenScopes = 'token-scopes',
MarketingId = 'marketing-id',
SuperappAuth = 'superapp-auth',
}

export class LocalStorage {
Expand Down
6 changes: 6 additions & 0 deletions src/models/SuperappData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface SuperappAuthItem {
service_id: number;
current_scopes?: string[];
token?: string;
expires?: string;
}
220 changes: 201 additions & 19 deletions src/views/apps/ApplicationFrame.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,175 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Ref, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToolbar } from '@/store/toolbar';
import { authScopeApi } from '@/api/auth';
import { userSessionApi } from '@/api/auth/UserSessionApi';
import { servicesApi } from '@/api/services/ServicesApi';
import { ButtonType } from '@/api/models';
import { ServiceData, ButtonType, ButtonView } from '@/api/models';
import { useProfileStore } from '@/store/profile';
import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage';
import { SuperappAuthItem } from '@/models/SuperappData';
import FullscreenLoader from '@/components/FullscreenLoader.vue';
import { AuthApi } from '@/api/controllers/auth/AuthApi';
const route = useRoute();
const toolbar = useToolbar();
const router = useRouter();
const profileStore = useProfileStore();
enum AppState {
Wait = 1,
WaitLoad = 1,
Show = 2,
Error = 3,
WaitApprove = 4,
Blocked = 5,
}
const url = ref();
const appState = ref(AppState.Wait);
const appId = +route.params.id;
const url: Ref<URL | undefined> = ref();
const appState = ref(AppState.WaitLoad);
const scopes: Ref<string[]> = ref([]);
const scopeNamesToRequest: Ref<string[]> = ref([]);
const userScopeApproved: Ref<boolean | undefined> = ref();
toolbar.setup({
backUrl: '/apps',
});
const composeUrl = async (url: URL, token: string, scopes: string[]) => {
url.searchParams.set('token', token);
url.searchParams.set('scopes', scopes.join(','));
if (!profileStore.id) {
await AuthApi.getMe();
}
if (profileStore.id) {
url.searchParams.set('user_id', profileStore.id.toString());
}
return url;
};
const compareLists = (array1: string[], array2: string[]) => {
const array2Sorted = array2.slice().sort();
return (
array1.length === array2.length &&
array1
.slice()
.sort()
.every(function (value, index) {
return value === array2Sorted[index];
})
);
};
function showApproveScopesScreen() {
appState.value = AppState.WaitApprove;
// immediately return a Promise
return new Promise(resolve => {
watch(userScopeApproved, value => resolve(value));
});
}
const getToken = async () => {
const appsData = LocalStorage.getObject<SuperappAuthItem[]>(LocalStorageItem.SuperappAuth) || [];
const authItemIndex = appsData.findIndex(value => value.service_id == appId);
const authItem: SuperappAuthItem =
authItemIndex != -1 ? appsData[authItemIndex] : { service_id: appId };
// Если раньше уже получали токен с нужными правами – возвращаем его
if (authItem && authItem.current_scopes && compareLists(authItem.current_scopes, scopes.value)) {
return authItem.token;
}
// Если токена с нужными правами нет, то нужно запросить токен. Для этого
// 1. Получаем весь список скоупов для получения оттуда названий на русском
authItem.current_scopes = scopes.value;
const allScopes = (await authScopeApi.getScopes()).data;
if (!allScopes) {
appState.value = AppState.Error;
return;
}
console.log(scopes.value);
const valuesToSearch = new Set(scopes.value);
console.log(valuesToSearch);
allScopes.forEach(item => {
console.log(item);
if (valuesToSearch.has(item.name)) {
console.log(' found');
scopeNamesToRequest.value.push(item.comment);
}
});
// 2. Показываем пользователю список прав, которые приложение запрашивает, и кнопки "разрешить"/"запретить"
const scopesApproved = await showApproveScopesScreen();
// 3. Если пользователь не разрешает – возваращаем undefined
if (!scopesApproved) return undefined;
// 4. Если пользователь разрешает – запрашиваем токен на Auth api и возвращаем его
const session = (await userSessionApi.createSession({ scopes: scopes.value })).data;
if (!session) {
appState.value = AppState.Error;
return;
}
authItem.token = session.token;
authItem.expires = session.expires;
profileStore.id = session.user_id;
if (authItemIndex != -1) {
appsData[authItemIndex] = authItem;
} else {
appsData.push(authItem);
}
LocalStorage.set(LocalStorageItem.SuperappAuth, appsData);
return session.token;
};
const openApp = async (data: ServiceData) => {
// Приложения открываем только Internal типа
if (data.type !== ButtonType.Internal) {
appState.value = AppState.Error;
return;
}
// Приложения открываем только по https
if (!data.link.startsWith('https://')) {
appState.value = AppState.Error;
return;
}
url.value = new URL(data.link);
toolbar.title = data.name;
scopes.value = data.scopes ? data.scopes : [];
// Не хватает скоупов => Кнопка заблокирована => Показываем ошибку
if (data.view == ButtonView.Blocked) {
appState.value = AppState.Blocked;
return;
}
// Не нужны скоупы => Кнопка разблокирована и не требует авторизации => Показываем приложение
if (data.view == ButtonView.Active && scopes.value.length == 0) {
appState.value = AppState.Show;
return;
}
// Приложение требует доступа к данным и есть возможность их получить
const token = await getToken();
if (token === undefined) {
// Пользователь не дал доступ к данным
router.push('/apps');
return;
}
// Пользователь дал доступ – открываем приложение
url.value = await composeUrl(url.value, token, scopes.value);
appState.value = AppState.Show;
};
onMounted(async () => {
const appId = +route.params.id;
servicesApi
.getService(appId)
.then(async response => {
console.log(response);
if (
response.status == 200 &&
response.data.type == ButtonType.Internal &&
response.data.link
) {
appState.value = AppState.Show;
url.value = response.data.link;
toolbar.title = response.data.name;
if (response.status == 200) {
openApp(response.data);
} else {
appState.value = AppState.Error;
}
Expand All @@ -50,13 +183,26 @@ onMounted(async () => {
<template>
<v-main>
<iframe
v-if="appState == AppState.Show"
:src="url"
v-if="appState == AppState.Show && url"
:src="url.toString()"
frameborder="0"
class="iframe"
allow="camera"
/>
<FullscreenLoader v-else-if="appState == AppState.Wait" />
<FullscreenLoader v-else-if="appState == AppState.WaitLoad" />
<div v-else-if="appState == AppState.WaitApprove" class="deligate-container">
<h2>Приложение запрашивает права на доступ к вашему аккаунту</h2>
<p>Для работы будут делегированы следующие права:</p>
<ul>
<li v-for="(scope, i) in scopeNamesToRequest" :key="i">{{ scope }}</li>
</ul>
<v-btn color="primary" @click="userScopeApproved = true">Разрешить</v-btn>
<v-btn variant="plain" @click="userScopeApproved = false">Запретить</v-btn>
</div>
<div v-else-if="appState == AppState.Blocked" class="exception-container">
<h2>У вас недостаточно прав для использования этого приложения</h2>
<v-btn @click="router.push('/apps')">Вернуться к списку приложений</v-btn>
</div>
<div v-else class="exception-container">
<h2>Не удалось загрузить приложение</h2>
<v-btn @click="router.push('/apps')">Вернуться к списку приложений</v-btn>
Expand Down Expand Up @@ -84,4 +230,40 @@ v-main,
.exception-container > * {
margin: 16px auto;
}
.deligate-container {
display: flex;
flex-direction: column;
flex: 1;
place-content: space-around flex-start;
max-width: 600px;
width: 100%;
height: 100%;
margin: 32px auto;
padding: 0 32px;
& > * {
margin: 8px 0;
}
& ul {
list-style: none;
display: flex;
flex-flow: column nowrap;
gap: 8px 2.5%;
margin: 16px 0;
}
& ul li {
display: block;
align-items: center;
width: 100%;
padding: 16px;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 5px;
background-color: rgb(var(--v-theme-surface-variant));
white-space: nowrap;
}
}
</style>

0 comments on commit 8463384

Please sign in to comment.