diff --git a/app/main.go b/app/main.go index bab7deea2a..781b0d25b5 100644 --- a/app/main.go +++ b/app/main.go @@ -20,6 +20,7 @@ import ( "google.golang.org/appengine" + "google.golang.org/appengine/datastore" "google.golang.org/appengine/log" "google.golang.org/appengine/user" ) @@ -37,6 +38,11 @@ func init() { // IMPORTANT: This is the ONLY path that does NOT require authentication. Do // not copy this pattern. Use registerRPC instead. http.HandleFunc("/api/get-authentication-status", getAuthenticationStatus) + + // IMPORTANT: Do not expose the handlers below in production. + if appengine.IsDevAppServer() { + http.HandleFunc("/api/whitelist-account", whitelistAccount) + } } func registerRPC(path string, handler func(cocoon *db.Cocoon, inputJSON []byte) (interface{}, error)) { @@ -120,7 +126,12 @@ func getAuthenticatedContext(ctx context.Context, r *http.Request) (context.Cont } if !strings.HasSuffix(user.Email, "@google.com") { - return nil, fmt.Errorf("Only @google.com users are authorized") + cocoon := db.NewCocoon(ctx) + err := cocoon.IsWhitelisted(user.Email) + + if err != nil { + return nil, err + } } return ctx, nil @@ -134,19 +145,52 @@ func getAuthenticationStatus(w http.ResponseWriter, r *http.Request) { // errors. _, err := getAuthenticatedContext(ctx, r) - var response map[string]interface{} - + var status string if err == nil { - response = map[string]interface{}{ - "Status": "OK", - } + status = "OK" } else { - loginURL, _ := user.LoginURL(ctx, "/") - response = map[string]interface{}{ - "LoginURL": loginURL, - } + status = "Unauthorized" + } + + loginURL, _ := user.LoginURL(ctx, "/build.html") + logoutURL, _ := user.LogoutURL(ctx, "/build.html") + + response := map[string]interface{}{ + "Status": status, + "LoginURL": loginURL, + "LogoutURL": logoutURL, } outputData, _ := json.Marshal(response) w.Write(outputData) } + +// Adds the provided email address to the authorized Google account whitelist. +// +// Available only on the local dev server. +func whitelistAccount(w http.ResponseWriter, r *http.Request) { + if !appengine.IsDevAppServer() { + panic("whitelistAccount is only available on the local dev server") + } + + ctx := appengine.NewContext(r) + email := strings.TrimSpace(r.URL.Query().Get("email")) + + if len(email) == 0 { + serveError(ctx, w, r, fmt.Errorf("Bad email address: %v", email)) + return + } + + account := &db.WhitelistedAccount{ + Email: email, + } + + key := datastore.NewIncompleteKey(ctx, "WhitelistedAccount", nil) + _, err := datastore.Put(ctx, key, account) + if err != nil { + serveError(ctx, w, r, err) + return + } + + w.Write([]byte("OK")) +} diff --git a/app/web/build.html b/app/web/build.html index d560df72f1..27a58a584a 100644 --- a/app/web/build.html +++ b/app/web/build.html @@ -29,6 +29,11 @@ + + diff --git a/app/web/buildStyles.css b/app/web/buildStyles.css index 96d0b47491..14211a67d8 100644 --- a/app/web/buildStyles.css +++ b/app/web/buildStyles.css @@ -86,9 +86,9 @@ footer { bottom: 0; left: 0; right: 0; - padding: 2px 19px 10px 19px; + padding: 2px 19px; z-index: 2; - background: inherit; + background-color: #917FFF; display: flex; } diff --git a/app/web/buildbot.js b/app/web/buildbot.js index f2d41bf01d..66db859b03 100644 --- a/app/web/buildbot.js +++ b/app/web/buildbot.js @@ -1,3 +1,5 @@ +"use strict"; + // Globally visible list of current build statuses encoded as: // // { diff --git a/app/web/firebase.js b/app/web/firebase.js index 57afad938a..05766a20c5 100644 --- a/app/web/firebase.js +++ b/app/web/firebase.js @@ -1,3 +1,5 @@ +"use strict"; + var ref = new Firebase("https://purple-butterfly-3000.firebaseio.com/"); var whenFirebaseReady = new Promise(function(resolve, reject) { diff --git a/app/web/github.js b/app/web/github.js index b2855839a6..1a127d90d1 100644 --- a/app/web/github.js +++ b/app/web/github.js @@ -1,3 +1,5 @@ +"use strict"; + (function() { var ACCESS_TOKEN = '6a93c73f10808b9a92d4ec8a91b9e823c192d204'; @@ -36,4 +38,4 @@ } displayMilestone(); -})(); \ No newline at end of file +})(); diff --git a/app/web/main.dart b/app/web/main.dart index 65d34cb908..ede103dd67 100644 --- a/app/web/main.dart +++ b/app/web/main.dart @@ -20,6 +20,9 @@ main() async { logger = new HtmlLogger(); http.Client httpClient = await _getAuthenticatedClientOrRedirectToSignIn(); + if (httpClient == null) + return; + // Start the angular app ComponentRef ref = await bootstrap(StatusTable, [ provide(http.Client, useValue: httpClient), @@ -33,12 +36,22 @@ Future _getAuthenticatedClientOrRedirectToSignIn() async { http.Client client = new browser_http.BrowserClient(); Map status = JSON.decode((await client.get('/api/get-authentication-status')).body); + document.querySelector('#logout-button').on['click'].listen((_) { + window.open(status['LogoutURL'], '_self'); + }); + + document.querySelector('#login-button').on['click'].listen((_) { + window.open(status['LoginURL'], '_self'); + }); + if (status['Status'] == 'OK') { return client; - } else { - window.open(status['LoginURL'], '_self'); - return null; } + + document.body.append(new DivElement() + ..text = 'You are not signed in, or signed in under an unauthorized account. ' + 'Use the buttons at the bottom of this page to sign in.'); + return null; } class HtmlLogger implements Logger { diff --git a/db/db.go b/db/db.go index 751f8c4ab6..4017018120 100644 --- a/db/db.go +++ b/db/db.go @@ -433,6 +433,21 @@ func (c *Cocoon) RefreshAgentAuthToken(agentID string) (*Agent, string, error) { return agent, authToken, nil } +// IsWhitelisted verifies that the given email address is whitelisted for access. +func (c *Cocoon) IsWhitelisted(email string) error { + query := datastore.NewQuery("WhitelistedAccount"). + Filter("Email =", email). + Limit(20) + iter := query.Run(c.Ctx) + _, err := iter.Next(&WhitelistedAccount{}) + + if err == datastore.Done { + return fmt.Errorf("%v is not authorized to access the dashboard", email) + } + + return nil +} + // allTaskStatuses contains all possible task statuses. var allTaskStatuses = [...]TaskStatus{ TaskNew, diff --git a/db/schema.go b/db/schema.go index b97fb94362..374a2c1ff3 100644 --- a/db/schema.go +++ b/db/schema.go @@ -107,3 +107,17 @@ type Agent struct { AuthTokenHash []byte Capabilities []string } + +// WhitelistedAccount gives permission to access the dashboard to a specific +// Google account. +// +// In production an account can be added by an administrator using the +// Datastore web UI. +// +// The Datastore UI on the dev server is limited. To add an account make a +// HTTP GET call to: +// +// http://localhost:8080/api/whitelist-account?email=ACCOUNT_EMAIL +type WhitelistedAccount struct { + Email string +}