changelog-webapp: Added signout

This CL adds the ability to signout and changes the login flow. Users will only receive a login prompt as a first-time/signed out user, or after a month of inactivity. These users will be prompted to select an a account and grant permissions to the app. Otherwise, if the token is only expired, the login flow is completed automatically to receive a new access token.

BUG=b/160901711
TEST=run local

Change-Id: Ic296ebab1b9a6bc60b8ce3593f9aa56c8168fbbc
diff --git a/src/cmd/changelog-webapp/controllers/authHandlers.go b/src/cmd/changelog-webapp/controllers/authHandlers.go
index aa159ee..34148bc 100644
--- a/src/cmd/changelog-webapp/controllers/authHandlers.go
+++ b/src/cmd/changelog-webapp/controllers/authHandlers.go
@@ -16,7 +16,6 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -35,7 +34,6 @@
 	// Session variables
 	sessionName      = "changelog"
 	sessionKeyLength = 32
-	sessionAge       = 84600
 
 	// Oauth state generation variables
 	oauthStateCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
@@ -54,10 +52,6 @@
 var clientSecretName = os.Getenv("COS_CHANGELOG_CLIENT_SECRET_NAME")
 var sessionSecretName = os.Getenv("COS_CHANGELOG_SESSION_SECRET_NAME")
 
-// ErrorSessionRetrieval indicates that a request has no session, or the
-// session was malformed.
-var ErrorSessionRetrieval = errors.New("No session found")
-
 func init() {
 	var err error
 	client, err := secretmanager.NewClient(context.Background())
@@ -74,7 +68,6 @@
 		log.Fatalf("Failed to retrieve secret :%s\n%v", sessionSecretName, err)
 	}
 	store = sessions.NewCookieStore([]byte(sessionSecret))
-	store.MaxAge(sessionAge)
 }
 
 // Retrieve secrets stored in Gcloud Secret Manager
@@ -102,27 +95,54 @@
 	return state[oauthStateLength:]
 }
 
-// HTTPClient creates an authorized HTTP Client using stored token credentials.
-// Returns error if no session or a malformed session is detected.
-// Otherwise returns an HTTP Client with the stored Oauth access token.
-// If the access token is expired, automatically refresh the token
-func HTTPClient(w http.ResponseWriter, r *http.Request, returnURL string) (*http.Client, error) {
+// tokenExpired indicates whether the Oauth token associated with a request is expired
+func tokenExpired(r *http.Request) bool {
 	var parsedExpiry time.Time
+	session, _ := store.Get(r, sessionName)
+	parsedExpiry, err := time.Parse(time.RFC3339, session.Values["expiry"].(string))
+	return err != nil || parsedExpiry.Before(time.Now())
+}
+
+// SignedIn returns a bool indicating if the current request is signed in
+func SignedIn(r *http.Request) bool {
 	session, err := store.Get(r, sessionName)
 	if err != nil || session.IsNew {
-		return nil, ErrorSessionRetrieval
+		return false
 	}
 	for _, key := range []string{"accessToken", "refreshToken", "tokenType", "expiry"} {
 		if val, ok := session.Values[key]; !ok || val == nil {
-			return nil, ErrorSessionRetrieval
+			return false
 		}
 	}
-	if parsedExpiry, err = time.Parse(time.RFC3339, session.Values["expiry"].(string)); err != nil {
-		return nil, ErrorSessionRetrieval
+	return true
+}
+
+// RequireToken will check if the user has a valid, unexpired Oauth token.
+// If not, it will initiate the Oauth flow.
+// Returns a bool indicating if the user was redirected
+func RequireToken(w http.ResponseWriter, r *http.Request, activePage string) bool {
+	if !SignedIn(r) {
+		err := promptLoginTemplate.Execute(w, &statusPage{ActivePage: activePage})
+		if err != nil {
+			log.Errorf("RequireToken: error executing promptLogin template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+		return true
 	}
-	if parsedExpiry.Before(time.Now()) {
-		log.Debug("HTTPClient: Token expired, calling Oauth flow")
-		HandleLogin(w, r, returnURL)
+	// If token is expired, auto refresh instead of prompting sign in
+	if tokenExpired(r) {
+		HandleLogin(w, r, activePage, false)
+		return true
+	}
+	return false
+}
+
+// HTTPClient creates an authorized HTTP Client using stored token credentials.
+func HTTPClient(w http.ResponseWriter, r *http.Request) (*http.Client, error) {
+	session, _ := store.Get(r, sessionName)
+	parsedExpiry, err := time.Parse(time.RFC3339, session.Values["expiry"].(string))
+	if err != nil {
+		return nil, err
 	}
 	token := &oauth2.Token{
 		AccessToken:  session.Values["accessToken"].(string),
@@ -134,9 +154,9 @@
 }
 
 // HandleLogin initiates the Oauth flow.
-func HandleLogin(w http.ResponseWriter, r *http.Request, returnURL string) {
+func HandleLogin(w http.ResponseWriter, r *http.Request, returnURL string, explicitApproval bool) {
 	state := randomString(oauthStateLength, returnURL)
-	// Ignore store.Get() errors in HandleLogin because an error indicates the
+	// Ignore store.Get() errors because an error indicates the
 	// old session could not be deciphered. It returns a new session
 	// regardless.
 	session, _ := store.Get(r, sessionName)
@@ -147,8 +167,12 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-
-	authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline)
+	var authURL string
+	if explicitApproval {
+		authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
+	} else {
+		authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline)
+	}
 	http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
 }
 
@@ -197,6 +221,30 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	http.Redirect(w, r, returnURLFromState(sessionState), http.StatusTemporaryRedirect)
+}
 
-	http.Redirect(w, r, returnURLFromState(sessionState), http.StatusPermanentRedirect)
+// HandleSignOut signs out the user by removing token information from the
+// session
+func HandleSignOut(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		log.Errorf("Could not parse request: %v\n", err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	redirect := r.FormValue("redirect")
+	if redirect == "" {
+		redirect = "/"
+	}
+	session, _ := store.Get(r, sessionName)
+	session.Values["accessToken"] = nil
+	session.Values["refreshToken"] = nil
+	session.Values["tokenType"] = nil
+	session.Values["expiry"] = nil
+	err := session.Save(r, w)
+	if err != nil {
+		log.Errorf("HandleCallback: Error saving session: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+	http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
 }
diff --git a/src/cmd/changelog-webapp/controllers/pageHandlers.go b/src/cmd/changelog-webapp/controllers/pageHandlers.go
index 909c057..911dd46 100644
--- a/src/cmd/changelog-webapp/controllers/pageHandlers.go
+++ b/src/cmd/changelog-webapp/controllers/pageHandlers.go
@@ -119,12 +119,14 @@
 
 type statusPage struct {
 	ActivePage string
+	SignedIn   bool
 }
 
 type basicTextPage struct {
 	Header     string
 	Body       string
 	ActivePage string
+	SignedIn   bool
 }
 
 type repoTable struct {
@@ -268,34 +270,39 @@
 }
 
 // handleError creates the error page for a given error
-func handleError(w http.ResponseWriter, err utils.ChangelogError, currPage string) {
-	basicTextTemplate.Execute(w, &basicTextPage{
-		Header:     err.HTTPStatus(),
-		Body:       err.Error(),
+func handleError(w http.ResponseWriter, r *http.Request, displayErr utils.ChangelogError, currPage string) {
+	err := basicTextTemplate.Execute(w, &basicTextPage{
+		Header:     displayErr.HTTPStatus(),
+		Body:       displayErr.Error(),
 		ActivePage: currPage,
+		SignedIn:   SignedIn(r),
 	})
+	if err != nil {
+		log.Error(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
 }
 
 // HandleIndex serves the home page
 func HandleIndex(w http.ResponseWriter, r *http.Request) {
-	indexTemplate.Execute(w, nil)
+	err := indexTemplate.Execute(w, &statusPage{SignedIn: SignedIn(r)})
+	if err != nil {
+		log.Error(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
 }
 
 // HandleChangelog serves the changelog page
 func HandleChangelog(w http.ResponseWriter, r *http.Request) {
-	httpClient, err := HTTPClient(w, r, "/changelog/")
-	if err != nil {
-		log.Debug(err)
-		err = promptLoginTemplate.Execute(w, &statusPage{ActivePage: "/changelog/"})
-		if err != nil {
-			log.Errorf("HandleChangelog: error executing promptLogin template: %v", err)
-		}
+	if RequireToken(w, r, "/changelog/") {
 		return
 	}
+	var err error
 	if err := r.ParseForm(); err != nil {
 		err = changelogTemplate.Execute(w, &changelogPage{QuerySize: envQuerySize})
 		if err != nil {
 			log.Errorf("HandleChangelog: error executing locatebuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -306,6 +313,7 @@
 		err = changelogTemplate.Execute(w, &changelogPage{QuerySize: envQuerySize, Internal: true})
 		if err != nil {
 			log.Errorf("HandleChangelog: error executing locatebuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -317,11 +325,16 @@
 	if r.FormValue("internal") == "true" {
 		internal, instance, manifestRepo = true, internalGoBInstance, internalManifestRepo
 	}
+	httpClient, err := HTTPClient(w, r)
+	if err != nil {
+		HandleLogin(w, r, "/changelog/", false)
+		return
+	}
 	added, removed, utilErr := changelog.Changelog(httpClient, source, target, instance, manifestRepo, querySize)
 	if utilErr != nil {
 		log.Errorf("HandleChangelog: error retrieving changelog between builds %s and %s on GoB instance: %s with manifest repository: %s\n%v\n",
 			source, target, externalGoBInstance, externalManifestRepo, utilErr)
-		handleError(w, utilErr, "/changelog/")
+		handleError(w, r, utilErr, "/changelog/")
 		return
 	}
 	page := createChangelogPage(changelogData{
@@ -335,25 +348,21 @@
 	err = changelogTemplate.Execute(w, page)
 	if err != nil {
 		log.Errorf("HandleChangelog: error executing changelog template: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
 
 // HandleLocateBuild serves the Locate CL page
 func HandleLocateBuild(w http.ResponseWriter, r *http.Request) {
-	httpClient, err := HTTPClient(w, r, "/locatebuild/")
-	// Require login to access if no session found
-	if err != nil {
-		log.Debug(err)
-		err = promptLoginTemplate.Execute(w, &statusPage{ActivePage: "/locatebuild/"})
-		if err != nil {
-			log.Errorf("HandleLocateBuild: error executing promptLogin template: %v", err)
-		}
+	if RequireToken(w, r, "/locatebuild/") {
 		return
 	}
-	if err := r.ParseForm(); err != nil {
+	var err error
+	if err = r.ParseForm(); err != nil {
 		err = locateBuildTemplate.Execute(w, &locateBuildPage{Internal: true})
 		if err != nil {
 			log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -363,6 +372,7 @@
 		err = locateBuildTemplate.Execute(w, &locateBuildPage{Internal: true})
 		if err != nil {
 			log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -370,10 +380,15 @@
 	if r.FormValue("internal") == "true" {
 		internal, gerrit, fallbackGerrit, gob, repo = true, internalGerritInstance, internalFallbackGerritInstance, internalGoBInstance, internalManifestRepo
 	}
+	httpClient, err := HTTPClient(w, r)
+	if err != nil {
+		HandleLogin(w, r, "/locatebuild/", false)
+		return
+	}
 	buildData, didFallback, utilErr := findBuildWithFallback(httpClient, gerrit, fallbackGerrit, gob, repo, cl, internal)
 	if utilErr != nil {
 		log.Errorf("HandleLocateBuild: error retrieving build for CL %s with internal set to %t\n%v", cl, internal, utilErr)
-		handleError(w, utilErr, "/locatebuild/")
+		handleError(w, r, utilErr, "/locatebuild/")
 		return
 	}
 	var gerritLink string
@@ -392,5 +407,6 @@
 	err = locateBuildTemplate.Execute(w, page)
 	if err != nil {
 		log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
diff --git a/src/cmd/changelog-webapp/main.go b/src/cmd/changelog-webapp/main.go
index 3813591..2fef7e0 100644
--- a/src/cmd/changelog-webapp/main.go
+++ b/src/cmd/changelog-webapp/main.go
@@ -41,9 +41,10 @@
 	http.HandleFunc("/changelog/", controllers.HandleChangelog)
 	http.HandleFunc("/locatebuild/", controllers.HandleLocateBuild)
 	http.HandleFunc("/login/", func(w http.ResponseWriter, r *http.Request) {
-		controllers.HandleLogin(w, r, "/")
+		controllers.HandleLogin(w, r, "/", true)
 	})
 	http.HandleFunc("/oauth2callback/", controllers.HandleCallback)
+	http.HandleFunc("/signout/", controllers.HandleSignOut)
 
 	if port == "" {
 		port = "8081"
diff --git a/src/cmd/changelog-webapp/static/css/base.css b/src/cmd/changelog-webapp/static/css/base.css
index 7d763c7..19411eb 100644
--- a/src/cmd/changelog-webapp/static/css/base.css
+++ b/src/cmd/changelog-webapp/static/css/base.css
@@ -44,7 +44,6 @@
   background-color: #4285f4;
   display: flex;
   height: 50px;
-  padding: 0px 25px;
   width: 100%;
 }
 
@@ -52,6 +51,17 @@
   color: #f8f8f8;
   font-size: 24;
   font-weight: 350;
+  padding-left: 25px;
+}
+
+a.login,
+a.signout {
+  color: #f8f8f8;
+  float: right;
+  font-size: 16;
+  font-weight: 16px;
+  margin-left: auto;
+  padding-right: 25px;
 }
 
 .sidenav {
diff --git a/src/cmd/changelog-webapp/static/templates/changelog.html b/src/cmd/changelog-webapp/static/templates/changelog.html
index a45b767..2a69afb 100644
--- a/src/cmd/changelog-webapp/static/templates/changelog.html
+++ b/src/cmd/changelog-webapp/static/templates/changelog.html
@@ -7,12 +7,12 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="signout" href="/signout/?redirect=/changelog/">Sign Out</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
     <a class="active" href="/changelog/">Changelog</a>
     <a href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <h1>Search Changelog</h1>
diff --git a/src/cmd/changelog-webapp/static/templates/error.html b/src/cmd/changelog-webapp/static/templates/error.html
index 21f8a40..68433e7 100644
--- a/src/cmd/changelog-webapp/static/templates/error.html
+++ b/src/cmd/changelog-webapp/static/templates/error.html
@@ -6,6 +6,11 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    {{if .SignedIn}}
+      <a class="signout" href="/signout/">Sign Out</a>
+    {{else}}
+      <a class="login" href="/login/">Login</a>
+    {{end}}
   </div>
   <div class="sidenav">
     {{if (eq .ActivePage "home")}}
@@ -23,7 +28,6 @@
     {{else}}
       <a href="/locatebuild/">Locate Build</a>
     {{end}}
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <div class="text-content">
diff --git a/src/cmd/changelog-webapp/static/templates/index.html b/src/cmd/changelog-webapp/static/templates/index.html
index 350a422..a256eeb 100644
--- a/src/cmd/changelog-webapp/static/templates/index.html
+++ b/src/cmd/changelog-webapp/static/templates/index.html
@@ -6,12 +6,16 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    {{if .SignedIn}}
+      <a class="signout" href="/signout/">Sign Out</a>
+    {{else}}
+      <a class="login" href="/login/">Login</a>
+    {{end}}
   </div>
   <div class="sidenav">
     <a class="active" href="/">Home</a>
     <a href="/changelog/">Changelog</a>
     <a href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <div class="text-content">
diff --git a/src/cmd/changelog-webapp/static/templates/locateBuild.html b/src/cmd/changelog-webapp/static/templates/locateBuild.html
index 8cf8fbb..fe57c76 100644
--- a/src/cmd/changelog-webapp/static/templates/locateBuild.html
+++ b/src/cmd/changelog-webapp/static/templates/locateBuild.html
@@ -7,12 +7,12 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="signout" href="/signout/?redirect=/locatebuild/">Sign Out</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
     <a href="/changelog/">Changelog</a>
     <a class="active" href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <h1>Locate Build with CL</h1>
diff --git a/src/cmd/changelog-webapp/static/templates/promptLogin.html b/src/cmd/changelog-webapp/static/templates/promptLogin.html
index 9ca8fb9..425cf87 100644
--- a/src/cmd/changelog-webapp/static/templates/promptLogin.html
+++ b/src/cmd/changelog-webapp/static/templates/promptLogin.html
@@ -6,6 +6,7 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="login" href="/login/">Login</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
@@ -19,7 +20,6 @@
     {{else}}
       <a href="/locatebuild/">Locate Build</a>
     {{end}}
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <div class="text-content">