storj/web/satellite/src/views/AuthorizeArea.vue
Mya 5cebbdee03 web/satellite: add consent screen for oauth
When an application wants to interact with resources on behalf of
an end-user, it needs to be granted access. In OAuth, this is done
when a user submits the consent screen.

Change-Id: Id838772f76999f63f5c9dbdda0995697b41c123a
2022-04-27 14:33:07 +00:00

695 lines
21 KiB
Vue

// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="authorize-area">
<div class="authorize-area__logo-wrapper">
<LogoIcon class="logo" @click="location.reload()" />
</div>
<div class="authorize-area__content-area">
<div v-if="requestErr" class="authorize-area__content-area__container">
<p>{{ requestErr }}</p>
</div>
<div v-else class="authorize-area__content-area__container">
<p class="authorize-area__content-area__client-app-logo">
<img :alt="client.appName" :src="client.appLogoURL">
</p>
<p class="authorize-area__content-area__client-app">
{{ client.appName }} would like permission to:
</p>
<div class="authorize-area__permissions-area">
<div class="authorize-area__permissions-area__container">
<p class="authorize-area__permissions-area__header">Verify your Storj Identity</p>
<p>Access and view your account info.</p>
</div>
<div class="authorize-area__permissions-area__container">
<p class="authorize-area__permissions-area__header">Sync data to Storj DCS</p>
<p>Automatically send updates to:</p>
<div class="authorize-area__input-wrapper">
<HeaderlessInput
label="Project"
role-description="project"
:error="projectErr"
:options-list="Object.keys(projects)"
@setData="setProject"
/>
</div>
<div class="authorize-area__input-wrapper">
<HeaderlessInput
label="Bucket"
role-description="bucket"
:error="bucketErr"
:value="selectedBucketName"
:options-list="buckets"
@setData="setBucket"
/>
<div v-if="!bucketExists" class="info-box">
<p class="info-box__message">
This bucket will be created.
</p>
</div>
</div>
<div class="authorize-area__input-wrapper">
<HeaderlessInput
label="Passphrase"
role-description="passphrase"
placeholder="Passphrase"
:error="passphraseErr"
is-password="true"
@setData="setPassphrase"
/>
</div>
</div>
<div class="authorize-area__permissions-area__container">
<p class="authorize-area__permissions-area__container__header">Perform the following actions</p>
<p>{{ actions }} objects.</p>
</div>
</div>
<form method="post">
<input v-model="oauthData['client_id']" type="hidden" name="client_id">
<input v-model="oauthData['redirect_uri']" type="hidden" name="redirect_uri">
<input v-model="oauthData['response_type']" type="hidden" name="response_type">
<input v-model="oauthData['state']" type="hidden" name="state">
<input v-model="scope" type="hidden" name="scope">
<input class="authorize-area__content-area__container__button" :class="{ 'disabled-button': !valid }" type="submit" :disabled="!valid" value="Authorize">
<p class="authorize-area__content-area__container__cancel" @click.prevent="onDeny">Cancel</p>
</form>
</div>
</div>
</div>
</template>
<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import HeaderlessInput from '@/components/common/HeaderlessInput.vue';
import LogoIcon from '@/../static/images/logo.svg';
import {Validator} from '@/utils/validation';
import {RouteConfig} from '@/router';
import querystring, {ParsedUrlQueryInput} from 'querystring';
import {BUCKET_ACTIONS} from '@/store/modules/buckets';
import {PROJECTS_ACTIONS} from '@/store/modules/projects';
import {USER_ACTIONS} from '@/store/modules/users';
import {Project} from '@/types/projects';
import {ErrorUnauthorized} from '@/api/errors/ErrorUnauthorized';
import {APP_STATE_ACTIONS} from '@/utils/constants/actionNames';
import {AppState} from '@/utils/constants/appStateEnum';
import {ACCESS_GRANTS_ACTIONS} from '@/store/modules/accessGrants';
import {OAuthClient, OAuthClientsAPI} from '@/api/oauthClients';
const oauthClientsAPI = new OAuthClientsAPI();
// @vue/component
@Component({
components: {
HeaderlessInput,
LogoIcon,
},
})
export default class Authorize extends Vue {
private requestErr = '';
private oauthData: ParsedUrlQueryInput = {};
private clientKey = '';
private client: OAuthClient = {
id: '',
redirectURL: '',
appName: '',
appLogoURL: '',
};
private projects: Record<string, Project> = {};
private buckets: Array<string> = [];
private selectedProjectID = '';
private selectedBucketName = '';
private providedPassphrase = '';
private scope = '';
private valid = false;
private projectErr = '';
private bucketErr = '';
private passphraseErr = '';
private actions = '';
private bucketExists = false;
private worker: Worker;
private async ensureLogin(): Promise<void> {
try {
await this.$store.dispatch(USER_ACTIONS.GET);
} catch (error) {
if (!(error instanceof ErrorUnauthorized)) {
await this.$store.dispatch(APP_STATE_ACTIONS.CHANGE_STATE, AppState.ERROR);
await this.$notify.error(error.message);
}
const query = querystring.stringify(this.oauthData) as string;
const path = `${RouteConfig.Authorize.path}?${query}#${this.clientKey}`;
await this.$router.push(`${RouteConfig.Login.path}?return_url=${encodeURIComponent(path)}`);
return;
}
}
private async ensureWorker(): Promise<void> {
try {
await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.STOP_ACCESS_GRANTS_WEB_WORKER);
await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.SET_ACCESS_GRANTS_WEB_WORKER);
} catch (error) {
await this.$notify.error(`Unable to set access grants wizard. ${error.message}`);
return;
}
this.worker = this.$store.state.accessGrantsModule.accessGrantsWebWorker;
this.worker.onerror = (error: ErrorEvent) => this.$notify.error(error.message);
}
private async verifyClientConfiguration(): Promise<void> {
const clientID = 'client_id' in this.oauthData ? `${this.oauthData['client_id']}` : '';
const redirectURL = 'redirect_uri' in this.oauthData ? `${this.oauthData['redirect_uri']}` : '';
const state = 'state' in this.oauthData ? `${this.oauthData['state']}` : '';
const responseType = 'response_type' in this.oauthData ? `${this.oauthData['response_type']}` : '';
const scope = 'scope' in this.oauthData ? `${this.oauthData['scope']}` : '';
if (!clientID || !redirectURL) {
this.requestErr = 'Both client_id and redirect_uri must be provided.';
return;
}
let client: OAuthClient;
try {
client = await oauthClientsAPI.get(clientID);
} catch (e) {
this.requestErr = e.message;
return;
}
let err: { [key: string]: string } | null = null;
if (!state || !responseType || !scope) {
err = {
error_description: 'The request is missing a required parameter (state, response_type, or scope).',
};
} else if (!this.clientKey) {
err = {
error_description: 'An encryption key must be provided in the fragment of the request.',
};
} else if (!redirectURL.startsWith(client.redirectURL)) {
err = {
error_description: 'The provided redirect url does not match the one in our system.',
};
}
if (err) {
location.href = `${redirectURL}?${querystring.stringify(err)}`;
return
}
this.client = client;
// initialize the form
this.setBucket(slugify(this.client.appName));
this.actions = formatObjectPermissions(scope);
}
private async loadProjects(): Promise<void> {
await this.$store.dispatch(PROJECTS_ACTIONS.FETCH);
const projects = {};
for (const project of this.$store.getters.projects) {
projects[project.name] = project;
}
this.projects = projects;
}
/**
* Lifecycle hook after initial render.
* Makes activated banner visible on successful account activation.
*/
public async mounted(): Promise<void> {
this.oauthData = this.$route.query as ParsedUrlQueryInput;
this.clientKey = this.$route.hash ? this.$route.hash.substr(1) : "";
await this.ensureLogin();
await this.ensureWorker();
await this.verifyClientConfiguration();
if (this.requestErr) {
return
}
await this.loadProjects();
}
public async setProject(value: string): Promise<void> {
if (!this.projects[value]) {
this.projectErr = 'project does not exist';
return
}
await this.$store.dispatch(PROJECTS_ACTIONS.SELECT, this.projects[value].id);
await this.$store.dispatch(BUCKET_ACTIONS.FETCH_ALL_BUCKET_NAMES);
this.selectedProjectID = this.$store.getters.selectedProject.id;
this.buckets = this.$store.state.bucketUsageModule.allBucketNames.sort();
this.setBucket(this.selectedBucketName);
}
public setBucket(value: string): void {
this.selectedBucketName = value;
this.bucketExists = this.selectedProjectID.length == 0 || value.length == 0 || this.buckets.includes(value);
this.setScope();
}
public setPassphrase(value: string): void {
this.providedPassphrase = value;
this.setScope();
}
public async setScope(): Promise<void> {
if (!this.validate()) {
return;
}
this.worker.postMessage({
'type': 'DeriveAndEncryptRootKey',
'passphrase': this.providedPassphrase,
'projectID': this.selectedProjectID,
'aesKey': this.clientKey,
});
const event: MessageEvent = await new Promise(resolve => this.worker.onmessage = resolve);
if (event.data.error) {
await this.$notify.error(event.data.error);
return;
}
const scope = this.oauthData['scope'],
project = this.selectedProjectID,
bucket = this.selectedBucketName,
cubbyhole = event.data.value;
this.scope = `${scope} project:${project} bucket:${bucket} cubbyhole:${cubbyhole}`;
}
public async onDeny(): Promise<void> {
location.href = `${this.oauthData['redirect_uri']}?${querystring.stringify({
error_description: 'The resource owner or authorization server denied the request',
})}`;
}
private validate(): boolean {
this.projectErr = '';
this.bucketErr = '';
this.passphraseErr = '';
if (this.selectedProjectID == '') {
this.projectErr = 'Missing project.';
}
if (!Validator.bucketName(this.selectedBucketName)) {
this.bucketErr = 'Name must contain only lowercase latin characters, numbers, a hyphen or a period';
}
if (this.providedPassphrase == '') {
this.passphraseErr = 'A passphrase must be provided.';
}
this.valid = this.projectErr == '' &&
this.bucketErr == '' &&
this.passphraseErr == '';
return this.valid;
}
}
const validPerms = {
'list': true,
'read': true,
'write': true,
'delete': true,
};
function slugify(name: string): string {
name = name.toLowerCase();
name = name.replace(/\s+/g, "-");
return name;
}
function formatObjectPermissions(scope: string): string {
const scopes = scope.split(" ");
const perms: string[] = [];
for (const scope of scopes) {
if (scope.startsWith("object:")) {
const perm = scope.substr("object:".length);
if (validPerms[perm]) {
perms.push(perm);
}
}
}
perms.sort();
if (perms.length == 0) {
return "";
} else if (perms.length == 1) {
return perms[0];
} else if (perms.length == 2) {
return `${perms[0]} and ${perms[1]}`;
}
return `${perms.slice(0, perms.length - 1).join(", ")}, and ${perms[perms.length - 1]}`;
}
</script>
<style scoped lang="scss">
.authorize-area {
display: flex;
flex-direction: column;
font-family: 'font_regular', sans-serif;
background-color: #f5f6fa;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 100%;
overflow-y: scroll;
.info-box {
background-color: #e9f3ff;
border-radius: 6px;
padding: 20px;
margin-top: 25px;
width: 100%;
box-sizing: border-box;
&.error {
background-color: #fff9f7;
border: 1px solid #f84b00;
}
&__header {
display: flex;
align-items: center;
&__label {
font-family: 'font_bold', sans-serif;
font-size: 16px;
color: #1b2533;
margin-left: 15px;
}
}
&__message {
font-size: 16px;
color: #1b2533;
}
}
&__permissions-area {
background-color: #fafafb;
border: 1px solid #d8dee3;
padding: 10px 9px 10px 24px;
border-radius: 8px;
&__header {
font-size: 18px;
margin-bottom: 4px;
}
&__container {
padding: 24px 0;
border-bottom: 1px solid #d8dee3;
p {
line-height: 24px;
vertical-align: middle;
}
&:last-of-type {
border-bottom: none;
}
}
}
&__logo-wrapper {
text-align: center;
margin: 70px 0;
}
&__divider {
margin: 0 20px;
height: 22px;
width: 2px;
background-color: #acbace;
}
&__input-wrapper {
margin-top: 20px;
padding-right: 24px;
width: 100%;
box-sizing: border-box;
}
&__expand {
display: flex;
align-items: center;
cursor: pointer;
position: relative;
&__value {
font-size: 16px;
line-height: 21px;
color: #acbace;
margin-right: 10px;
font-family: 'font_regular', sans-serif;
font-weight: 700;
}
&__dropdown {
position: absolute;
top: 35px;
left: 0;
background-color: #fff;
z-index: 1000;
border: 1px solid #c5cbdb;
box-shadow: 0 8px 34px rgba(161, 173, 185, 0.41);
border-radius: 6px;
min-width: 250px;
&__item {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 12px 25px;
font-size: 14px;
line-height: 20px;
color: #7e8b9c;
cursor: pointer;
text-decoration: none;
&__name {
font-family: 'font_bold', sans-serif;
margin-left: 15px;
font-size: 14px;
line-height: 20px;
color: #7e8b9c;
}
&:hover {
background-color: #f2f2f6;
}
}
}
}
&__content-area {
background-color: #f5f6fa;
padding: 0 20px;
margin-bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
border-radius: 20px;
box-sizing: border-box;
&__activation-banner {
padding: 20px;
background-color: rgba(39, 174, 96, 0.1);
border: 1px solid #27ae60;
color: #27ae60;
border-radius: 6px;
width: 570px;
margin-bottom: 30px;
&__message {
font-size: 16px;
line-height: 21px;
margin: 0;
}
}
&__client-app-logo {
text-align: center;
margin-bottom: 60px;
}
&__client-app {
text-align: center;
font-size: 22px;
font-weight: bold;
margin-bottom: 16px;
}
&__container {
display: flex;
flex-direction: column;
padding: 60px 80px;
background-color: #fff;
width: 610px;
border-radius: 20px;
box-sizing: border-box;
margin-bottom: 20px;
&__title-area {
display: flex;
justify-content: space-between;
align-items: center;
&__title {
font-size: 24px;
line-height: 49px;
letter-spacing: -0.100741px;
color: #252525;
font-family: 'font_bold', sans-serif;
font-weight: 800;
}
&__satellite {
font-size: 16px;
line-height: 21px;
color: #848484;
}
}
&__button {
font-family: 'font_regular', sans-serif;
font-weight: 700;
margin-top: 40px;
display: flex;
justify-content: center;
align-items: center;
background-color: #376fff;
border-radius: 50px;
color: #fff;
cursor: pointer;
width: 100%;
height: 48px;
&:hover {
background-color: #0059d0;
}
}
&__cancel {
align-self: center;
font-size: 16px;
line-height: 21px;
color: #0068dc;
text-align: center;
margin-top: 30px;
cursor: pointer;
}
&__recovery {
font-size: 16px;
line-height: 19px;
color: #0068dc;
cursor: pointer;
margin-top: 20px;
text-align: center;
width: 100%;
}
}
&__footer-item {
margin-top: 30px;
font-size: 14px;
}
}
}
.logo {
cursor: pointer;
}
.disabled,
.disabled-button {
pointer-events: none;
color: #acb0bc;
}
.disabled-button {
background-color: #dadde5;
border-color: #dadde5;
}
@media screen and (max-width: 750px) {
.authorize-area {
&__content-area {
&__container {
width: 100%;
padding: 60px;
}
}
&__expand {
&__dropdown {
left: -200px;
}
}
}
}
@media screen and (max-width: 414px) {
.authorize-area {
&__logo-wrapper {
margin: 40px;
}
&__content-area {
padding: 0;
&__container {
padding: 0 20px 20px 20px;
background: transparent;
}
}
}
}
</style>