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
This commit is contained in:
parent
4f196dd39e
commit
5cebbdee03
@ -20,15 +20,7 @@ func GenAccessGrant(satelliteNodeURL, apiKey, encryptionPassphrase, projectID st
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := uuid.FromString(projectID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
const concurrency = 8
|
||||
salt := sha256.Sum256(id[:])
|
||||
|
||||
key, err := encryption.DeriveRootKey([]byte(encryptionPassphrase), salt[:], "", concurrency)
|
||||
key, err := DeriveRootKey(encryptionPassphrase, projectID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -47,3 +39,16 @@ func GenAccessGrant(satelliteNodeURL, apiKey, encryptionPassphrase, projectID st
|
||||
}
|
||||
return accessString, nil
|
||||
}
|
||||
|
||||
// DeriveRootKey derives the root key portion of the access grant.
|
||||
func DeriveRootKey(encryptionPassphrase, projectID string) (*storj.Key, error) {
|
||||
id, err := uuid.FromString(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
const concurrency = 8
|
||||
salt := sha256.Sum256(id[:])
|
||||
|
||||
return encryption.DeriveRootKey([]byte(encryptionPassphrase), salt[:], "", concurrency)
|
||||
}
|
||||
|
@ -295,6 +295,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
|
||||
router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost)
|
||||
router.Handle("/oauth/v2/tokens", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.Tokens))).Methods(http.MethodPost)
|
||||
router.Handle("/oauth/v2/userinfo", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.UserInfo))).Methods(http.MethodGet)
|
||||
router.Handle("/oauth/v2/clients/{id}", server.withAuth(http.HandlerFunc(oidc.GetClient))).Methods(http.MethodGet)
|
||||
|
||||
fs := http.FileServer(http.Dir(server.config.StaticDir))
|
||||
router.PathPrefix("/static/").Handler(server.brotliMiddleware(http.StripPrefix("/static", fs)))
|
||||
|
@ -7,15 +7,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/zeebo/errs"
|
||||
|
||||
"storj.io/common/base58"
|
||||
console "storj.io/storj/satellite/console/consolewasm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("deriveAndEncryptRootKey", deriveAndEncryptRootKey())
|
||||
js.Global().Set("generateAccessGrant", generateAccessGrant())
|
||||
js.Global().Set("setAPIKeyPermission", setAPIKeyPermission())
|
||||
js.Global().Set("newPermission", newPermission())
|
||||
@ -23,6 +27,38 @@ func main() {
|
||||
<-make(chan bool)
|
||||
}
|
||||
|
||||
// deriveAndEncryptRootKey derives the root key portion of the access grant and then encrypts it using the provided aes
|
||||
// key. To ensure the key used in encryption is a proper length, we take a sha256 of the provided key and use it instead
|
||||
// of using the key directly. Then we base58 encode the result and return it to the caller.
|
||||
func deriveAndEncryptRootKey() js.Func {
|
||||
return js.FuncOf(responseHandler(func(this js.Value, args []js.Value) (interface{}, error) {
|
||||
if len(args) < 3 {
|
||||
return nil, errs.New("not enough arguments. Need 3, but only %d supplied. The order of arguments are: encryption passphrase, project ID, and AES Key.", len(args))
|
||||
}
|
||||
|
||||
encryptionPassphrase := args[0].String()
|
||||
projectSalt := args[1].String()
|
||||
aesKey := args[2].String()
|
||||
|
||||
rootKey, err := console.DeriveRootKey(encryptionPassphrase, projectSalt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := sha256.Sum256([]byte(aesKey))
|
||||
cipher, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
value := *(rootKey.Raw())
|
||||
out := make([]byte, len(value))
|
||||
cipher.Encrypt(out, value[:])
|
||||
|
||||
return base58.Encode(out), nil
|
||||
}))
|
||||
}
|
||||
|
||||
// generateAccessGrant creates a new access grant with the provided api key and encryption passphrase.
|
||||
func generateAccessGrant() js.Func {
|
||||
return js.FuncOf(responseHandler(func(this js.Value, args []js.Value) (interface{}, error) {
|
||||
|
@ -4,7 +4,9 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -12,6 +14,7 @@ import (
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/go-oauth2/oauth2/v4/manage"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@ -31,9 +34,10 @@ func NewEndpoint(
|
||||
) *Endpoint {
|
||||
manager := manage.NewManager()
|
||||
|
||||
clientStore := oidcService.ClientStore()
|
||||
tokenStore := oidcService.TokenStore()
|
||||
|
||||
manager.MapClientStorage(oidcService.ClientStore())
|
||||
manager.MapClientStorage(clientStore)
|
||||
manager.MapTokenStorage(tokenStore)
|
||||
|
||||
manager.MapAuthorizeGenerate(&UUIDAuthorizeGenerate{})
|
||||
@ -59,10 +63,11 @@ func NewEndpoint(
|
||||
|
||||
// externalAddress _should_ end with a '/' suffix based on the calling path
|
||||
return &Endpoint{
|
||||
tokenStore: tokenStore,
|
||||
service: service,
|
||||
server: svr,
|
||||
log: log,
|
||||
clientStore: clientStore,
|
||||
tokenStore: tokenStore,
|
||||
service: service,
|
||||
server: svr,
|
||||
log: log,
|
||||
config: ProviderConfig{
|
||||
Issuer: externalAddress,
|
||||
AuthURL: externalAddress + "oauth/v2/authorize",
|
||||
@ -77,11 +82,12 @@ func NewEndpoint(
|
||||
//
|
||||
// architecture: Endpoint
|
||||
type Endpoint struct {
|
||||
tokenStore oauth2.TokenStore
|
||||
service *console.Service
|
||||
server *server.Server
|
||||
log *zap.Logger
|
||||
config ProviderConfig
|
||||
clientStore oauth2.ClientStore
|
||||
tokenStore oauth2.TokenStore
|
||||
service *console.Service
|
||||
server *server.Server
|
||||
log *zap.Logger
|
||||
config ProviderConfig
|
||||
}
|
||||
|
||||
// WellKnownConfiguration renders the identity provider configuration that points clients to various endpoints.
|
||||
@ -178,6 +184,33 @@ func (e *Endpoint) UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetClient returns non-sensitive information about an OAuthClient. This information is used to initially verify client
|
||||
// applications who are requesting information on behalf of a user.
|
||||
func (e *Endpoint) GetClient(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
client, err := e.clientStore.GetByID(ctx, vars["id"])
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = json.NewEncoder(w).Encode(client)
|
||||
if err != nil {
|
||||
e.log.Error("failed to encode oauth client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderConfig defines a subset of elements used by OIDC to auto-discover endpoints.
|
||||
type ProviderConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
|
36
web/satellite/src/api/oauthClients.ts
Normal file
36
web/satellite/src/api/oauthClients.ts
Normal file
@ -0,0 +1,36 @@
|
||||
// Copyright (C) 2022 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
import {HttpClient} from "@/utils/httpClient";
|
||||
import {ErrorTooManyRequests} from "@/api/errors/ErrorTooManyRequests";
|
||||
|
||||
export interface OAuthClient {
|
||||
id: string;
|
||||
redirectURL: string;
|
||||
appName: string;
|
||||
appLogoURL: string;
|
||||
}
|
||||
|
||||
export class OAuthClientsAPI {
|
||||
private readonly http: HttpClient = new HttpClient();
|
||||
private readonly ROOT_PATH: string = '/oauth/v2/clients';
|
||||
|
||||
public async get(id: string): Promise<OAuthClient> {
|
||||
const path = `${this.ROOT_PATH}/${id}`;
|
||||
const response = await this.http.get(path);
|
||||
if (response.ok) {
|
||||
return response.json().then((body) => body as OAuthClient);
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
throw new Error('Invalid request for OAuth client');
|
||||
case 404:
|
||||
throw new Error('OAuth client was not found');
|
||||
case 429:
|
||||
throw new ErrorTooManyRequests('API rate limit exceeded');
|
||||
}
|
||||
|
||||
throw new Error('Failed to lookup oauth client');
|
||||
}
|
||||
}
|
@ -78,6 +78,7 @@ export default class HeaderlessInput extends Vue {
|
||||
private type: string = this.textType;
|
||||
private isPasswordShown = false;
|
||||
|
||||
@Prop({default: ''})
|
||||
protected value = '';
|
||||
|
||||
@Prop({default: ''})
|
||||
|
@ -52,6 +52,7 @@ import { NavigationLink } from '@/types/navigation';
|
||||
import { MetaUtils } from "@/utils/meta";
|
||||
|
||||
const ActivateAccount = () => import('@/views/ActivateAccount.vue');
|
||||
const AuthorizeArea = () => import('@/views/AuthorizeArea.vue');
|
||||
const DashboardArea = () => import('@/views/DashboardArea.vue');
|
||||
const ForgotPassword = () => import('@/views/ForgotPassword.vue');
|
||||
const LoginArea = () => import('@/views/LoginArea.vue');
|
||||
@ -72,6 +73,7 @@ export abstract class RouteConfig {
|
||||
public static Activate = new NavigationLink('/activate', 'Activate');
|
||||
public static ForgotPassword = new NavigationLink('/forgot-password', 'Forgot Password');
|
||||
public static ResetPassword = new NavigationLink('/password-recovery', 'Reset Password');
|
||||
public static Authorize = new NavigationLink('/oauth/v2/authorize', 'Authorize')
|
||||
public static Account = new NavigationLink('/account', 'Account');
|
||||
public static ProjectDashboard = new NavigationLink('/project-dashboard', 'Dashboard');
|
||||
public static NewProjectDashboard = new NavigationLink('/new-project-dashboard', 'Dashboard ');
|
||||
@ -135,6 +137,7 @@ export const notProjectRelatedRoutes = [
|
||||
RouteConfig.Activate.name,
|
||||
RouteConfig.ForgotPassword.name,
|
||||
RouteConfig.ResetPassword.name,
|
||||
RouteConfig.Authorize.name,
|
||||
RouteConfig.Billing.name,
|
||||
RouteConfig.BillingHistory.name,
|
||||
RouteConfig.DepositHistory.name,
|
||||
@ -175,6 +178,11 @@ export const router = new Router({
|
||||
name: RouteConfig.ResetPassword.name,
|
||||
component: ResetPassword,
|
||||
},
|
||||
{
|
||||
path: RouteConfig.Authorize.path,
|
||||
name: RouteConfig.Authorize.name,
|
||||
component: AuthorizeArea,
|
||||
},
|
||||
{
|
||||
path: RouteConfig.Root.path,
|
||||
meta: {
|
||||
|
694
web/satellite/src/views/AuthorizeArea.vue
Normal file
694
web/satellite/src/views/AuthorizeArea.vue
Normal file
@ -0,0 +1,694 @@
|
||||
// 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>
|
@ -147,6 +147,7 @@ export default class Login extends Vue {
|
||||
private readonly auth: AuthHttpApi = new AuthHttpApi();
|
||||
|
||||
public readonly forgotPasswordPath: string = RouteConfig.ForgotPassword.path;
|
||||
public returnURL: string = RouteConfig.ProjectDashboard.path;
|
||||
public isActivatedBannerShown = false;
|
||||
public isActivatedError = false;
|
||||
public isMFARequired = false;
|
||||
@ -178,6 +179,8 @@ export default class Login extends Vue {
|
||||
public mounted(): void {
|
||||
this.isActivatedBannerShown = !!this.$route.query.activated;
|
||||
this.isActivatedError = this.$route.query.activated === 'false';
|
||||
|
||||
this.returnURL = this.$route.query.return_url as string || this.returnURL;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -310,7 +313,7 @@ export default class Login extends Vue {
|
||||
await this.$store.dispatch(APP_STATE_ACTIONS.CHANGE_STATE, AppState.LOADING);
|
||||
this.isLoading = false;
|
||||
|
||||
await this.$router.push(RouteConfig.ProjectDashboard.path);
|
||||
await this.$router.push(this.returnURL);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -29,6 +29,16 @@ self.onmessage = async function (event) {
|
||||
self.postMessage(new Error(e.message))
|
||||
}
|
||||
|
||||
break;
|
||||
case 'DeriveAndEncryptRootKey':
|
||||
{
|
||||
const passphrase = data.passphrase;
|
||||
const projectID = data.projectID;
|
||||
const aesKey = data.aesKey;
|
||||
|
||||
result = self.deriveAndEncryptRootKey(passphrase, projectID, aesKey);
|
||||
self.postMessage(result);
|
||||
}
|
||||
break;
|
||||
case 'GenerateAccess':
|
||||
apiKey = data.apiKey;
|
||||
|
Loading…
Reference in New Issue
Block a user