Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSO: Allow a Cloud Foundry endpoint to be connected with SSO login #2928

Merged
merged 4 commits into from
Sep 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's Single Sign-On in the text below, while Single sign-on here

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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.Debug("ssoLoginToCNSI")

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