Skip to content

Commit

Permalink
Merge pull request #2928 from cloudfoundry-incubator/sso-endpoints
Browse files Browse the repository at this point in the history
SSO: Allow a Cloud Foundry endpoint to be connected with SSO login
  • Loading branch information
richard-cox authored Sep 10, 2018
2 parents f18bc8f + 8bc9bd5 commit 70a8458
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h2 mat-dialog-title>
<mat-dialog-content class="connection-dialog__content">
<mat-form-field *ngIf="authTypesForEndpoint.length > 1">
<mat-select (selectionChange)="authChanged($event)" id="authType" name="authType" formControlName="authType" placeholder="Auth Type">
<mat-option *ngFor="let auth of authTypesForEndpoint" [value]="auth">{{ auth.name }}</mat-option>
<mat-option *ngFor="let auth of authTypesForEndpoint" [value]="auth.value">{{ auth.name }}</mat-option>
</mat-select>
</mat-form-field>
<input type="hidden" *ngIf="authTypesForEndpoint.length === 1" name="authType" formControlName="authType">
Expand All @@ -22,6 +22,9 @@ <h2 mat-dialog-title>
<input matInput placeholder="Password" type="password" formControlName="password">
</mat-form-field>
</div>
<div *ngIf="endpointForm.value.authType === 'sso'" formGroupName="authValues" class="connection-dialog__auth">
<p>You will be redirected to the Single Sign-On UI for this endpoint and returned to Stratos upon completion.</p>
</div>
<mat-checkbox *ngIf="canShareEndpointToken" class="connection-dialog__shared" matInput name="systemShared" formControlName="systemShared">Share this endpoint connection with other users</mat-checkbox>
</mat-dialog-content>
<app-dialog-error message="Could not connect, please try again." [show]="connectingError$ | async"></app-dialog-error>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.connection-dialog {
$dialog-padding: 24px;
width: 300px;
&__content {
display: flex;
flex-direction: column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export class ConnectEndpointDialogComponent implements OnDestroy {
},
types: new Array<EndpointType>('cf', 'metrics')
},
{
name: 'Single sign-on',
value: 'sso',
form: {},
types: new Array<EndpointType>('cf')
},
];

private hasAttemptedConnect: boolean;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Component } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';

import { EndpointsService } from '../../../core/endpoints.service';
import {
EndpointsListConfigService,
} 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',
Expand All @@ -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<AppState> ) { }

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();
}
}
}


10 changes: 10 additions & 0 deletions src/frontend/app/store/effects/endpoint.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ export class EndpointsEffect {
@Effect() connectEndpoint$ = this.actions$.ofType<ConnectEndpoint>(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: {
Expand Down
87 changes: 80 additions & 7 deletions src/jetstream/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -584,15 +657,15 @@ 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{}
body.Set("grant_type", "authorization_code")
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)
}
Expand Down
11 changes: 8 additions & 3 deletions src/jetstream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/test-e2e/endpoints/endpoints-connect-e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
});
Expand Down

0 comments on commit 70a8458

Please sign in to comment.