diff --git a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.html b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.html index 53e4802fd5..bac7f84c63 100644 --- a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.html +++ b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.html @@ -10,7 +10,7 @@

- {{ auth.name }} + {{ auth.name }} @@ -22,6 +22,9 @@

+
+

You will be redirected to the Single Sign-On UI for this endpoint and returned to Stratos upon completion.

+
Share this endpoint connection with other users diff --git a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.scss b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.scss index aa87805676..51c77b3a11 100644 --- a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.scss +++ b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.scss @@ -1,5 +1,6 @@ .connection-dialog { $dialog-padding: 24px; + width: 300px; &__content { display: flex; flex-direction: column; diff --git a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.ts b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.ts index 4025ae0396..90717bf2bc 100644 --- a/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.ts +++ b/src/frontend/app/features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component.ts @@ -52,6 +52,12 @@ export class ConnectEndpointDialogComponent implements OnDestroy { }, types: new Array('cf', 'metrics') }, + { + name: 'Single sign-on', + value: 'sso', + form: {}, + types: new Array('cf') + }, ]; private hasAttemptedConnect: boolean; @@ -201,6 +207,7 @@ export class ConnectEndpointDialogComponent implements OnDestroy { submit() { this.hasAttemptedConnect = true; const { guid, authType, authValues, systemShared } = this.endpointForm.value; + this.store.dispatch(new ConnectEndpoint( this.data.guid, this.data.type, diff --git a/src/frontend/app/features/endpoints/endpoints-page/endpoints-page.component.ts b/src/frontend/app/features/endpoints/endpoints-page/endpoints-page.component.ts index 779a4528b8..86d5a0c5ef 100644 --- a/src/frontend/app/features/endpoints/endpoints-page/endpoints-page.component.ts +++ b/src/frontend/app/features/endpoints/endpoints-page/endpoints-page.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { EndpointsService } from '../../../core/endpoints.service'; import { @@ -6,8 +6,12 @@ import { } from '../../../shared/components/list/list-types/endpoint/endpoints-list-config.service'; import { ListConfig } from '../../../shared/components/list/list.component.types'; import { CurrentUserPermissions } from '../../../core/current-user-permissions.config'; -import { of } from 'rxjs'; - +import { Subscription, } from 'rxjs'; +import { queryParamMap } from '../../../core/auth-guard.service'; +import { delay, first, map, filter } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../store/app-state'; +import { ShowSnackBar } from '../../../store/actions/snackBar.actions'; @Component({ selector: 'app-endpoints-page', @@ -19,7 +23,38 @@ import { of } from 'rxjs'; }] }) -export class EndpointsPageComponent { +export class EndpointsPageComponent implements OnDestroy, OnInit { public canRegisterEndpoint = CurrentUserPermissions.ENDPOINT_REGISTER; - constructor(public endpointsService: EndpointsService) { } + constructor(public endpointsService: EndpointsService, public store: Store ) { } + + sub: Subscription; + + ngOnInit(): void { + const params = queryParamMap(); + if (params['cnsi_guid']) { + const guid = params['cnsi_guid']; + window.history.pushState({}, '', '/endpoints'); + this.sub = this.endpointsService.endpoints$.pipe( + delay(0), + filter(ep => !!ep[guid]), + map(ep => { + const endpoint = ep[guid]; + if (endpoint.connectionStatus === 'connected') { + this.store.dispatch(new ShowSnackBar(`Connected ${endpoint.name}`)); + } else { + this.store.dispatch(new ShowSnackBar(`A problem occurred connecting endpoint ${endpoint.name}`)); + } + }), + first(), + ).subscribe(); + } + } + + ngOnDestroy() { + if (this.sub) { + this.sub.unsubscribe(); + } + } } + + diff --git a/src/frontend/app/store/effects/endpoint.effects.ts b/src/frontend/app/store/effects/endpoint.effects.ts index 9ec9827b8a..f5efaa7d6f 100644 --- a/src/frontend/app/store/effects/endpoint.effects.ts +++ b/src/frontend/app/store/effects/endpoint.effects.ts @@ -89,6 +89,16 @@ export class EndpointsEffect { @Effect() connectEndpoint$ = this.actions$.ofType(CONNECT_ENDPOINTS).pipe( mergeMap(action => { const actionType = 'update'; + + // Special-case SSO login - redirect to the back-end + if (action.authType === 'sso') { + const loc = window.location.protocol + '//' + window.location.hostname + + (window.location.port ? ':' + window.location.port : ''); + const ssoUrl = '/pp/v1/auth/login/cnsi?guid=' + action.guid + '&state=' + encodeURIComponent(loc); + window.location.assign(ssoUrl); + return []; + } + const apiAction = this.getEndpointUpdateAction(action.guid, action.type, EndpointsEffect.connectingKey); const params: HttpParams = new HttpParams({ fromObject: { diff --git a/src/jetstream/auth.go b/src/jetstream/auth.go index 34d864b8d0..773d9ea13a 100644 --- a/src/jetstream/auth.go +++ b/src/jetstream/auth.go @@ -98,17 +98,22 @@ func (p *portalProxy) initSSOlogin(c echo.Context) error { return err } - redirectURL := fmt.Sprintf("%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s", p.Config.ConsoleConfig.UAAEndpoint, p.Config.ConsoleConfig.ConsoleClient, url.QueryEscape(getSSORedirectURI(state, state))) + redirectURL := fmt.Sprintf("%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s", p.Config.ConsoleConfig.UAAEndpoint, p.Config.ConsoleConfig.ConsoleClient, url.QueryEscape(getSSORedirectURI(state, state, ""))) c.Redirect(http.StatusTemporaryRedirect, redirectURL) return nil } -func getSSORedirectURI(base string, state string) string { +func getSSORedirectURI(base string, state string, endpointGUID string) string { baseURL, _ := url.Parse(base) baseURL.Path = "" baseURL.RawQuery = "" baseURLString := strings.TrimRight(baseURL.String(), "?") - return fmt.Sprintf("%s/pp/v1/auth/sso_login_callback?state=%s", baseURLString, url.QueryEscape(state)) + + returnURL := fmt.Sprintf("%s/pp/v1/auth/sso_login_callback?state=%s", baseURLString, url.QueryEscape(state)) + if len(endpointGUID) > 0 { + returnURL = fmt.Sprintf("%s&guid=%s", returnURL, endpointGUID) + } + return returnURL } // Logout of the UAA @@ -125,7 +130,7 @@ func (p *portalProxy) ssoLogoutOfUAA(c echo.Context) error { // Redirect to the UAA to logout of the UAA session as well (if configured to do so), otherwise redirect back to the UI login page var redirectURL string if p.hasSSOOption("logout") { - redirectURL = fmt.Sprintf("%s/logout.do?client_id=%s&redirect=%s", p.Config.ConsoleConfig.UAAEndpoint, p.Config.ConsoleConfig.ConsoleClient, url.QueryEscape(getSSORedirectURI(state, "logout"))) + redirectURL = fmt.Sprintf("%s/logout.do?client_id=%s&redirect=%s", p.Config.ConsoleConfig.UAAEndpoint, p.Config.ConsoleConfig.ConsoleClient, url.QueryEscape(getSSORedirectURI(state, "logout", ""))) } else { redirectURL = "/login?SSO_Message=You+have+been+logged+out" } @@ -153,6 +158,13 @@ func (p *portalProxy) ssoLoginToUAA(c echo.Context) error { return err } + // We use the same callback URL for both UAA and endpoint login + // Check if it is an endpoint login and dens to the right handler + endpointGUID := c.QueryParam("guid") + if len(endpointGUID) > 0 { + return p.ssoLoginToCNSI(c) + } + if state == "logout" { return c.Redirect(http.StatusTemporaryRedirect, "/login?SSO_Message=You+have+been+logged+out") } @@ -242,6 +254,65 @@ func (p *portalProxy) doLoginToUAA(c echo.Context) (*interfaces.LoginRes, error) return resp, nil } +// Start SSO flow for an Endpoint +func (p *portalProxy) ssoLoginToCNSI(c echo.Context) error { + log.Debug("loginToCNSI") + endpointGUID := c.QueryParam("guid") + if len(endpointGUID) == 0 { + return interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Missing target endpoint", + "Need Endpoint GUID passed as form param") + } + + _, err := p.GetSessionStringValue(c, "user_id") + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Could not find correct session value") + } + + state := c.QueryParam("state") + if len(state) == 0 { + err := interfaces.NewHTTPShadowError( + http.StatusUnauthorized, + "SSO Login: State parameter missing", + "SSO Login: State parameter missing") + return err + } + + cnsiRecord, err := p.GetCNSIRecord(endpointGUID) + if err != nil { + return interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Requested endpoint not registered", + "No Endpoint registered with GUID %s: %s", endpointGUID, err) + } + + // Check if this is first time in the flow, or via the callback + code := c.QueryParam("code") + + if len(code) == 0 { + // First time around + // Use the standard SSO Login Callback endpoint, so this can be whitelisted for Stratos and Endpoint login + returnURL := getSSORedirectURI(state, state, endpointGUID) + redirectURL := fmt.Sprintf("%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s", + cnsiRecord.AuthorizationEndpoint, cnsiRecord.ClientId, url.QueryEscape(returnURL)) + c.Redirect(http.StatusTemporaryRedirect, redirectURL) + return nil + } + + // Callback + _, err = p.DoLoginToCNSI(c, endpointGUID, false) + status := "ok" + if err != nil { + status = "fail" + } + + // Take the user back to Stratos on the endpoints page + redirect := fmt.Sprintf("/endpoints?cnsi_guid=%s&status=%s", endpointGUID, status) + c.Redirect(http.StatusTemporaryRedirect, redirect) + return nil +} + // Connect to the given Endpoint // Note, an admin user can connect an endpoint as a system endpoint to share it with others func (p *portalProxy) loginToCNSI(c echo.Context) error { @@ -525,7 +596,9 @@ func (p *portalProxy) login(c echo.Context, skipSSLValidation bool, client strin if c.Request().Method() == http.MethodGet { code := c.QueryParam("code") state := c.QueryParam("state") - uaaRes, err = p.getUAATokenWithAuthorizationCode(skipSSLValidation, code, client, clientSecret, endpoint, state) + // If this is login for a CNSI, then the redirect URL is slightly different + cnsiGUID := c.QueryParam("guid") + uaaRes, err = p.getUAATokenWithAuthorizationCode(skipSSLValidation, code, client, clientSecret, endpoint, state, cnsiGUID) } else { username := c.FormValue("username") password := c.FormValue("password") @@ -584,7 +657,7 @@ func (p *portalProxy) logout(c echo.Context) error { return c.JSON(http.StatusOK, resp) } -func (p *portalProxy) getUAATokenWithAuthorizationCode(skipSSLValidation bool, code, client, clientSecret, authEndpoint string, state string) (*UAAResponse, error) { +func (p *portalProxy) getUAATokenWithAuthorizationCode(skipSSLValidation bool, code, client, clientSecret, authEndpoint string, state string, cnsiGUID string) (*UAAResponse, error) { log.Debug("getUAATokenWithCreds") body := url.Values{} @@ -592,7 +665,7 @@ func (p *portalProxy) getUAATokenWithAuthorizationCode(skipSSLValidation bool, c body.Set("code", code) body.Set("client_id", client) body.Set("client_secret", clientSecret) - body.Set("redirect_uri", getSSORedirectURI(state, state)) + body.Set("redirect_uri", getSSORedirectURI(state, state, cnsiGUID)) return p.getUAAToken(body, skipSSLValidation, client, clientSecret, authEndpoint) } diff --git a/src/jetstream/main.go b/src/jetstream/main.go index 615d985fa1..7667b998de 100644 --- a/src/jetstream/main.go +++ b/src/jetstream/main.go @@ -677,10 +677,12 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, addSetupMiddleware *setupMidd // Only add SSO routes if SSO Login is enabled if p.Config.SSOLogin { pp.GET("/v1/auth/sso_login", p.initSSOlogin) - pp.GET("/v1/auth/sso_login_callback", p.ssoLoginToUAA) pp.GET("/v1/auth/sso_logout", p.ssoLogoutOfUAA) } + // Callback is use dby both login to Stratos and login to an Endpoint + pp.GET("/v1/auth/sso_login_callback", p.ssoLoginToUAA) + // Version info pp.GET("/v1/version", p.getVersions) @@ -698,10 +700,13 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, addSetupMiddleware *setupMidd e.Use(middlewarePlugin.SessionEchoMiddleware) } - // Connect to CF cluster + // Connect to endpoint sessionGroup.POST("/auth/login/cnsi", p.loginToCNSI) - // Disconnect CF cluster + // Connect to Enpoint (SSO) + sessionGroup.GET("/auth/login/cnsi", p.ssoLoginToCNSI) + + // Disconnect endpoint sessionGroup.POST("/auth/logout/cnsi", p.logoutOfCNSI) // Verify Session diff --git a/src/test-e2e/endpoints/endpoints-connect-e2e.spec.ts b/src/test-e2e/endpoints/endpoints-connect-e2e.spec.ts index bec29b1ff8..8bea0da95b 100644 --- a/src/test-e2e/endpoints/endpoints-connect-e2e.spec.ts +++ b/src/test-e2e/endpoints/endpoints-connect-e2e.spec.ts @@ -54,7 +54,7 @@ describe('Endpoints', () => { expect(ctrls['authtype']).toBeDefined(); expect(ctrls['username']).toBeDefined(); expect(ctrls['password']).toBeDefined(); - expect(ctrls['authtype'].value).toEqual('creds'); + expect(ctrls['authtype'].text).toEqual('Username and Password'); expect(ctrls['username'].text).toEqual(''); expect(ctrls['password'].text).toEqual(''); });