Skip to content

Commit

Permalink
Google auth v1 🔒 (metabase#2818)
Browse files Browse the repository at this point in the history
* Google Auth 🔒 [wip] [ci skip]

* "No Metabase account exists for this Google account" screen 😋

* Single Sign On Settings Page [WIP] 😋

* Google Auth v1 🔒

* update login layout

* add setup instructions

* add google logo for google account signups

* clean up sso form layout

* Merge branch 'master' into google-auth [ci skip]

* Fix Google Auth button not showing up after initial load 🔒

* Fix bug when creating session for user with no last_login 🔒

* Organize google auth backend code a bit 🔒

* Lots of tests for Google Auth 🔏

* Add Save Button to Google Auth settings page 🔒

* Use onChange instead of onBlurChange 🔒

* Make "save changes" button say "changes saved" after save 🔏

* adjust user settings for managed accounts

* Ensure we clear GA creds if GA login fails

* tweak password reset email

* owned google sign in button

* use var

* Cleanup some random legacy Angular junk

* Keep Redux and AppState currentUser in sync, fixes login issue

* render() should have side-effects! Fixes $digest errors on logout

* Test fixes 🔧

* fix checkbox

* cleanup

* remove unused import
  • Loading branch information
camsaul authored and kdoh committed Jul 12, 2016
1 parent d400635 commit 8f1a287
Show file tree
Hide file tree
Showing 44 changed files with 733 additions and 155 deletions.
4 changes: 3 additions & 1 deletion frontend/src/metabase/Routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ForgotPasswordApp from "metabase/auth/containers/ForgotPasswordApp.jsx";
import LoginApp from "metabase/auth/containers/LoginApp.jsx";
import LogoutApp from "metabase/auth/containers/LogoutApp.jsx";
import PasswordResetApp from "metabase/auth/containers/PasswordResetApp.jsx";
import GoogleNoAccount from "metabase/auth/components/GoogleNoAccount.jsx";

// main app containers
import DashboardApp from "metabase/dashboard/containers/DashboardApp.jsx";
Expand Down Expand Up @@ -72,9 +73,10 @@ export default class Routes extends Component {
</Route>

<Route path="/auth/forgot_password" component={ForgotPasswordApp} />
<Route path="/auth/login" component={this._forwardProps(LoginApp, ["onChangeLocation"])} />
<Route path="/auth/login" component={this._forwardProps(LoginApp, ["onChangeLocation", "setSessionFn"])} />
<Route path="/auth/logout" component={this._forwardProps(LogoutApp, ["onChangeLocation"])} />
<Route path="/auth/reset_password/:token" component={this._forwardProps(PasswordResetApp, ["onChangeLocation"])} />
<Route path="/auth/google_no_mb_account" component={GoogleNoAccount} />

<Route path="/card/:cardId" component={this._forwardProps(QueryBuilder, ["onChangeLocation", "broadcastEventFn", "updateUrl"])} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default class DatabaseEditApp extends Component {

{ /* Sidebar Actions */ }
{ database && database.id != null &&
<div className="Grid-cell Cell--1of3" ng-if="database.id">
<div className="Grid-cell Cell--1of3">
<div className="Actions bordered rounded shadowed">
<h3>Actions</h3>
<div className="Actions-group">
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/metabase/admin/people/components/AdminPeople.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Modal from "metabase/components/Modal.jsx";
import ModalContent from "metabase/components/ModalContent.jsx";
import PasswordReveal from "metabase/components/PasswordReveal.jsx";
import UserAvatar from "metabase/components/UserAvatar.jsx";
import Icon from "metabase/components/Icon.jsx";
import Tooltip from "metabase/components/Tooltip.jsx";

import EditUserForm from "./EditUserForm.jsx";
import UserActionsSelect from "./UserActionsSelect.jsx";
Expand Down Expand Up @@ -364,6 +366,7 @@ export default class AdminPeople extends Component {
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Email</th>
<th>Role</th>
<th>Last Seen</th>
Expand All @@ -374,6 +377,12 @@ export default class AdminPeople extends Component {
{ users.map(user =>
<tr>
<td><span className="text-white inline-block"><UserAvatar background={(user.is_superuser) ? "bg-purple" : "bg-brand"} user={user} /></span> <span className="ml2 text-bold">{user.common_name}</span></td>
<td>
{user.google_auth ?
<Tooltip tooltip="Signed up via Google">
<Icon name='google' />
</Tooltip> : null}
</td>
<td>{user.email}</td>
<td>
<UserRoleSelect
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, { Component, PropTypes } from "react";

import cx from "classnames";
import _ from "underscore";

import Input from "metabase/components/Input.jsx";

export default class SettingsSingleSignOnForm extends Component {
constructor(props, context) {
super(props, context);
this.updateClientID = this.updateClientID.bind(this);
this.updateDomain = this.updateDomain.bind(this);
this.onCheckboxClicked = this.onCheckboxClicked.bind(this),
this.saveChanges = this.saveChanges.bind(this),
this.clientIDChanged = this.clientIDChanged.bind(this),
this.domainChanged = this.domainChanged.bind(this)
}

static propTypes = {
elements: PropTypes.array,
updateSetting: PropTypes.func.isRequired
};

componentWillMount() {
let { elements } = this.props,
clientID = _.findWhere(elements, {key: 'google-auth-client-id'}),
domain = _.findWhere(elements, {key: 'google-auth-auto-create-accounts-domain'});

this.setState({
clientID: clientID,
domain: domain,
clientIDValue: clientID.value,
domainValue: domain.value,
recentlySaved: false
});
}

updateClientID(newValue) {
if (newValue === this.state.clientIDValue) return;

this.setState({
clientIDValue: newValue && newValue.length ? newValue : null,
recentlySaved: false
});
}

updateDomain(newValue) {
if (newValue === this.state.domain.value) return;

this.setState({
domainValue: newValue && newValue.length ? newValue : null,
recentlySaved: false
});
}

clientIDChanged() {
return this.state.clientID.value !== this.state.clientIDValue;
}

domainChanged() {
return this.state.domain.value !== this.state.domainValue;
}

saveChanges() {
let { clientID, clientIDValue, domain, domainValue } = this.state;

if (this.clientIDChanged()) {
this.props.updateSetting(clientID, clientIDValue);
this.setState({
clientID: {
value: clientIDValue
},
recentlySaved: true
});
}

if (this.domainChanged()) {
this.props.updateSetting(domain, domainValue);
this.setState({
domain: {
value: domainValue
},
recentlySaved: true
});
}
}

onCheckboxClicked() {
// if domain is present, clear it out; otherwise if there's no domain try to set it back to what it was
this.setState({
domainValue: this.state.domainValue ? null : this.state.domain.value,
recentlySaved: false
});
}

render() {
let hasChanges = this.domainChanged() || this.clientIDChanged(),
hasClientID = this.state.clientIDValue;

return (
<form noValidate>
<div className="px2"
style={{maxWidth: "585px"}}>
<h2>Sign in with Google</h2>
<p className="text-grey-4">
Allows users with existing Metabase accounts to login with a Google account that matches their email address in addition to their Metabase username and password.
</p>
<p className="text-grey-4">
To allow users to sign in with Google you'll need to give Metabase a Google Developers console application client ID. It only takes a few steps and instructions on how to create a key can be found <a className="link" href="https://developers.google.com/identity/sign-in/web/devconsole-project" target="_blank">here.</a>
</p>
<Input
className="SettingsInput AdminInput bordered rounded h3"
type="text"
value={this.state.clientIDValue}
placeholder="Your Google client ID"
onChange={(event) => this.updateClientID(event.target.value)}
/>
<div className="py3">
<div className="flex align-center">
<p className="text-grey-4">Allow users to sign up on their own if their Google account email address is from:</p>
</div>
<div className="mt1 bordered rounded inline-block">
<div className="inline-block px2 h2">@</div>
<Input
className="SettingsInput inline-block AdminInput h3 border-left"
type="text"
value={this.state.domainValue}
onChange={(event) => this.updateDomain(event.target.value)}
disabled={!hasClientID}
/>
</div>
</div>

<button className={cx("Button mr2", {"Button--primary": hasChanges})}
disabled={!hasChanges}
onClick={this.saveChanges}>
{this.state.recentlySaved ? "Changes saved!" : "Save Changes"}
</button>
</div>
</form>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SettingsEmailForm from "../components/SettingsEmailForm.jsx";
import SettingsSlackForm from "../components/SettingsSlackForm.jsx";
import SettingsSetupList from "../components/SettingsSetupList.jsx";
import SettingsUpdatesForm from "../components/SettingsUpdatesForm.jsx";
import SettingsSingleSignOnForm from "../components/SettingsSingleSignOnForm.jsx";

import _ from "underscore";
import cx from 'classnames';
Expand Down Expand Up @@ -119,6 +120,15 @@ export default class SettingsEditorApp extends Component {
/>
</div>
);
} else if (section.name === "Single Sign On") {
return (
<div className="px2">
<SettingsSingleSignOnForm
elements={section.settings}
updateSetting={this.updateSetting}
/>
</div>
);
} else {
let settings = section.settings.map((setting, index) => {
return <SettingsSetting key={setting.key} setting={setting} updateSetting={this.updateSetting} handleChangeEvent={this.handleChangeEvent} autoFocus={index === 0}/>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/metabase/admin/settings/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ const SECTIONS = [
type: "boolean"
}
]
},
{
name: "Single Sign On",
settings: [
{
key: "google-auth-client-id"
},
{
key: "google-auth-auto-create-accounts-domain"
}
]
}
];

Expand Down
10 changes: 9 additions & 1 deletion frontend/src/metabase/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import * as setup from "metabase/setup/reducers";

/* user */
import * as user from "metabase/user/reducers";
import { currentUser } from "metabase/user";
import { currentUser, setUser } from "metabase/user";

import { registerAnalyticsClickListener } from "metabase/lib/analytics";
import { serializeCardForUrl, cleanCopyCard, urlForCardState } from "metabase/lib/card";
Expand Down Expand Up @@ -239,6 +239,14 @@ angular.module('metabase', [
};
$scope.store = createStoreWithAngularScope($scope, $location, reducers, {currentUser: AppState.model.currentUser});

// ANGULAR_HACK™: this seems like the easiest way to keep the redux store up to date with the currentUser :-/
let userSyncTimer = setInterval(() => {
if ($scope.store.getState().currentUser !== AppState.model.currentUser) {
$scope.store.dispatch(setUser(AppState.model.currentUser));
}
}, 250);
$scope.$on("$destroy", () => clearInterval(userSyncTimer));

// HACK: prevent reloading controllers as the URL changes
let route = $route.current;
$scope.$on('$locationChangeSuccess', function (event) {
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/metabase/auth/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { handleActions, combineReducers, AngularResourceProxy, createThunkAction
import MetabaseCookies from "metabase/lib/cookies";
import MetabaseUtils from "metabase/lib/utils";

import { clearGoogleAuthCredentials } from "metabase/lib/auth";


// resource wrappers
const SessionApi = new AngularResourceProxy("Session", ["create", "delete", "reset_password"]);
const SessionApi = new AngularResourceProxy("Session", ["create", "createWithGoogleAuth", "delete", "reset_password"]);


// login
Expand All @@ -33,6 +35,34 @@ export const login = createThunkAction("AUTH_LOGIN", function(credentials, onCha
};
});


// login Google
export const loginGoogle = createThunkAction("AUTH_LOGIN_GOOGLE", function(googleUser, onChangeLocation) {
return async function(dispatch, getState) {
try {
let newSession = await SessionApi.createWithGoogleAuth({
token: googleUser.getAuthResponse().id_token
});

// since we succeeded, lets set the session cookie
MetabaseCookies.setSessionCookie(newSession.id);

// TODO: redirect after login (carry user to intended destination)
// this is ridiculously stupid. we have to wait (300ms) for the cookie to actually be set in the browser :(
setTimeout(() => onChangeLocation("/"), 300);

} catch (error) {
clearGoogleAuthCredentials();
// If we see a 428 ("Precondition Required") that means we need to show the "No Metabase account exists for this Google Account" page
if (error.status === 428) {
onChangeLocation('/auth/google_no_mb_account');
} else {
return error;
}
}
};
});

// logout
export const logout = createThunkAction("AUTH_LOGOUT", function(onChangeLocation) {
return async function(dispatch, getState) {
Expand Down Expand Up @@ -84,7 +114,8 @@ export const passwordReset = createThunkAction("AUTH_PASSWORD_RESET", function(t
// reducers

const loginError = handleActions({
["AUTH_LOGIN"]: { next: (state, { payload }) => payload ? payload : null }
["AUTH_LOGIN"]: { next: (state, { payload }) => payload ? payload : null },
["AUTH_LOGIN_GOOGLE"]: { next: (state, { payload }) => payload ? payload : null }
}, null);

const resetSuccess = handleActions({
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/metabase/auth/components/AuthScene.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class AuthScene extends Component {

render() {
return (
<section className="brand-scene absolute bottom left right hide sm-show">
<section className="z1 brand-scene absolute bottom left right hide sm-show">
<div className="brand-boat-container">
<svg className="brand-boat" width="27px" height="28px" viewBox="0 0 27 28">
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" ref="HACK_fill_rule_1">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/metabase/auth/components/BackToLogin.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react'

const BackToLogin = () =>
<a className="link block" href="/auth/login">Back to login</a>

export default BackToLogin;
28 changes: 28 additions & 0 deletions frontend/src/metabase/auth/components/GoogleNoAccount.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from "react";

import AuthScene from "./AuthScene.jsx";
import LogoIcon from "metabase/components/LogoIcon.jsx";
import BackToLogin from "./BackToLogin.jsx"

const GoogleNoAccount = () =>
<div className="full-height bg-white flex flex-column flex-full md-layout-centered">
<div className="wrapper">
<div className="Login-wrapper Grid Grid--full md-Grid--1of2">
<div className="Grid-cell flex layout-centered text-brand">
<LogoIcon className="Logo my4 sm-my0" width={66} height={85} />
</div>
<div className="Grid-cell text-centered bordered rounded shadowed p4">
<h3 className="mt4 mb2">No Metabase account exists for this Google account.</h3>
<p className="mb4 ml-auto mr-auto" style={{maxWidth: 360}}>
You'll need an administrator to create a Metabase account before
you can use Google to log in.
</p>

<BackToLogin />
</div>
</div>
</div>
<AuthScene />
</div>

export default GoogleNoAccount;
26 changes: 26 additions & 0 deletions frontend/src/metabase/auth/components/SSOLoginButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { Component, PropTypes } from "react";
import Icon from "metabase/components/Icon";
import { capitalize } from 'humanize'

const propTypes = {
provider: PropTypes.string.isRequired
};

class SSOLoginButton extends Component {
render () {
const { provider } = this.props
return (
<div className="relative z2 bg-white p2 cursor-pointer shadow-hover text-centered sm-text-left rounded block sm-inline-block bordered shadowed">
<div className="flex align-center">
<Icon className="mr1" name={provider} />
<h4>{`Sign in with ${capitalize(provider)}`}</h4>
</div>
</div>
)
}
}


SSOLoginButton.proptypes = propTypes

export default SSOLoginButton;
Loading

0 comments on commit 8f1a287

Please sign in to comment.