From 5cebbdee03e0df6290a81d883683cae4ab41a0a1 Mon Sep 17 00:00:00 2001 From: Mya Date: Mon, 14 Feb 2022 14:06:35 -0600 Subject: [PATCH] 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 --- satellite/console/consolewasm/access.go | 23 +- satellite/console/consoleweb/server.go | 1 + satellite/console/wasm/main.go | 36 + satellite/oidc/endpoint.go | 53 +- web/satellite/src/api/oauthClients.ts | 36 + .../src/components/common/HeaderlessInput.vue | 1 + web/satellite/src/router/index.ts | 8 + web/satellite/src/views/AuthorizeArea.vue | 694 ++++++++++++++++++ web/satellite/src/views/LoginArea.vue | 5 +- .../static/wasm/accessGrant.worker.js | 10 + 10 files changed, 847 insertions(+), 20 deletions(-) create mode 100644 web/satellite/src/api/oauthClients.ts create mode 100644 web/satellite/src/views/AuthorizeArea.vue diff --git a/satellite/console/consolewasm/access.go b/satellite/console/consolewasm/access.go index 6781c9588..a09a704c1 100644 --- a/satellite/console/consolewasm/access.go +++ b/satellite/console/consolewasm/access.go @@ -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) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 665539a14..595e53f25 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -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))) diff --git a/satellite/console/wasm/main.go b/satellite/console/wasm/main.go index 296e6e9dc..abe13425f 100644 --- a/satellite/console/wasm/main.go +++ b/satellite/console/wasm/main.go @@ -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) { diff --git a/satellite/oidc/endpoint.go b/satellite/oidc/endpoint.go index c5db9c94e..67d721dc4 100644 --- a/satellite/oidc/endpoint.go +++ b/satellite/oidc/endpoint.go @@ -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"` diff --git a/web/satellite/src/api/oauthClients.ts b/web/satellite/src/api/oauthClients.ts new file mode 100644 index 000000000..7f43e3bab --- /dev/null +++ b/web/satellite/src/api/oauthClients.ts @@ -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 { + 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'); + } +} diff --git a/web/satellite/src/components/common/HeaderlessInput.vue b/web/satellite/src/components/common/HeaderlessInput.vue index 4e3a74fde..2baaa5058 100644 --- a/web/satellite/src/components/common/HeaderlessInput.vue +++ b/web/satellite/src/components/common/HeaderlessInput.vue @@ -78,6 +78,7 @@ export default class HeaderlessInput extends Vue { private type: string = this.textType; private isPasswordShown = false; + @Prop({default: ''}) protected value = ''; @Prop({default: ''}) diff --git a/web/satellite/src/router/index.ts b/web/satellite/src/router/index.ts index 620cc851a..e182ed51e 100644 --- a/web/satellite/src/router/index.ts +++ b/web/satellite/src/router/index.ts @@ -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: { diff --git a/web/satellite/src/views/AuthorizeArea.vue b/web/satellite/src/views/AuthorizeArea.vue new file mode 100644 index 000000000..e2ee32c62 --- /dev/null +++ b/web/satellite/src/views/AuthorizeArea.vue @@ -0,0 +1,694 @@ +// Copyright (C) 2022 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/views/LoginArea.vue b/web/satellite/src/views/LoginArea.vue index 141006268..0d54ee9fa 100644 --- a/web/satellite/src/views/LoginArea.vue +++ b/web/satellite/src/views/LoginArea.vue @@ -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); } /** diff --git a/web/satellite/static/wasm/accessGrant.worker.js b/web/satellite/static/wasm/accessGrant.worker.js index 2ea9fb83d..87e782cc2 100644 --- a/web/satellite/static/wasm/accessGrant.worker.js +++ b/web/satellite/static/wasm/accessGrant.worker.js @@ -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;