From 39b385d289517c363c83895d6e2d0e3c7cda00f9 Mon Sep 17 00:00:00 2001 From: Alexander Bezzubov Date: Fri, 6 Dec 2019 17:37:36 +0100 Subject: [PATCH] server: initial implementation, multitenant auth + label fetch Signed-off-by: Alexander Bezzubov --- cmd/server/server.go | 110 ++++++++++++++++++++++++++++++++++++++ gmailutils/gmail.go | 35 +++++++++++- gmailutils/token/token.go | 63 ++++++++++++++++++++++ main.go | 14 ++--- 4 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 cmd/server/server.go diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..d177109 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,110 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/bzz/scholar-alert-digest/gmailutils" + "github.com/bzz/scholar-alert-digest/gmailutils/token" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/gmail/v1" +) + +var ( + addr = "localhost:8080" + oauthCfg = &oauth2.Config{ + // from https://console.developers.google.com/project//apiui/credential + ClientID: os.Getenv("SAL_GOOGLE_ID"), + ClientSecret: os.Getenv("SAL_GOOGLE_SECRET"), + RedirectURL: "http://localhost:8080/login/authorized", + Endpoint: google.Endpoint, + Scopes: []string{gmail.GmailReadonlyScope}, + } +) + +func main() { + // TODO(bzz): + // - configure the log level, to include requests in debug + // - add default req timeouts + throttling, to prevent abuse + + log.Printf("starting the web server at http://%s", addr) + defer log.Printf("stoping the web server") + + mux := http.NewServeMux() + mux.HandleFunc("/", handleRoot) + mux.HandleFunc("/login", handleLogin) + mux.HandleFunc("/login/authorized", handleAuth) + + http.ListenAndServe(addr, sessionMiddleware(mux)) +} + +func handleRoot(w http.ResponseWriter, r *http.Request) { + // get token, stored in context by middleware (from cookies) + tok, authorized := token.FromContext(r.Context()) + if !authorized { // TODO(bzz): move this to middleware + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Not logged in: go to /login\n")) + } + + // TOOD(bzz): if the label is known, fetch messages instead + // gmailutils.Fetch(srv, "me", fmt.Sprintf("label:%s is:unread", gmailLabel)) + labels, err := gmailutils.FetchLabels(r.Context(), oauthCfg, tok) + if err != nil { + log.Printf("Unable to retrieve all labels: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // TODO: render a template \w POST form to label selection + data, err := labels.MarshalJSON() + if err != nil { + log.Printf("Failed to encode labels in JSON: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + w.Write(data) +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + // the URL which shows the Google Auth page to the user + url := oauthCfg.AuthCodeURL("") + http.Redirect(w, r, url, http.StatusFound) +} + +func handleAuth(w http.ResponseWriter, r *http.Request) { + // get the code from URL + err := r.ParseForm() + if err != nil { // url query part is not valid + log.Printf("Unable to parse query string: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // exchange the received code for a bearer token + code := r.FormValue("code") + tok, err := oauthCfg.Exchange(r.Context(), code) + if err != nil { + log.Printf("Unable to exchange the code %q for token: %v", code, err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // save token in the session cookie + cookie := token.NewSessionCookie(tok) + log.Printf("Saving new cookie: %s", cookie.String()) + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusFound) +} + +// sessionMiddleware reads token from session cookie, saves it into the Context. +func sessionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Println(r.Method, "-", r.RequestURI /*, r.Cookies()*/) + ctx := token.NewSessionContext(r.Context(), r.Cookies()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/gmailutils/gmail.go b/gmailutils/gmail.go index 17d1801..b2e7e5b 100644 --- a/gmailutils/gmail.go +++ b/gmailutils/gmail.go @@ -27,6 +27,7 @@ import ( "log" "net/http" "strings" + "time" "github.com/bzz/scholar-alert-digest/gmailutils/token" @@ -35,7 +36,8 @@ import ( "google.golang.org/api/gmail/v1" ) -const instructions = `Please follow https://developers.google.com/gmail/api/quickstart/go#step_1_turn_on_the +// Instructions are user manual for OAuth app configuration from Gmail. +const Instructions = `Please follow https://developers.google.com/gmail/api/quickstart/go#step_1_turn_on_the in oreder to: - create a new "Quickstart" API project under your account - enable GMail API on it @@ -46,7 +48,7 @@ in oreder to: func NewClient(needWriteAccess bool) *http.Client { b, err := ioutil.ReadFile("credentials.json") if err != nil { - log.Fatalf("Unable to read client secret file: %v\n%s", err, instructions) + log.Fatalf("Unable to read client secret file: %v\n%s", err, Instructions) } // If modifying these scopes, delete your previously saved token.json. @@ -77,6 +79,27 @@ func getClient(config *oauth2.Config, tokFile string) *http.Client { return config.Client(context.Background(), tok) } +// FetchLabels fetches the list of labels, as returned by Gmail. +func FetchLabels(ctx context.Context, oauthCfg *oauth2.Config, token *oauth2.Token) ( + *gmail.ListLabelsResponse, error) { // TODO(bzz): extract all args to a struct and make it a method + // get an authorized Gmail API client + client := oauthCfg.Client(ctx, token) + srv, err := gmail.New(client) + if err != nil { + return nil, err + } + + // TODO(bzz): handle token expiration (by cookie expiration? or set refresh token?) + // Unable to retrieve all labels: Get https://www.googleapis.com/gmail/v1/users/me/labels?alt=json&prettyPrint=false: oauth2: token expired and refresh token is not set + + // fetch from Gmail + lablesResp, err := srv.Users.Labels.List("me").Do() + if err != nil { + return nil, err + } + return lablesResp, nil +} + // PrintAllLabels prints all labels for a given user. func PrintAllLabels(srv *gmail.Service, user string) { log.Printf("Listing all Gmail labels") @@ -91,6 +114,14 @@ func PrintAllLabels(srv *gmail.Service, user string) { } } +// Fetch fetches all messages matching a given query from the Gmail. +func Fetch(srv *gmail.Service, user, query string) []*gmail.Message { + start := time.Now() + msgs := QueryMessages(srv, user, query) + log.Printf("%d messages found under %q (took %.0f sec)", len(msgs), query, time.Since(start).Seconds()) + return msgs +} + // QueryMessages returns the all messages, matching a query for a given user. func QueryMessages(srv *gmail.Service, user, query string) []*gmail.Message { var messages []*gmail.Message diff --git a/gmailutils/token/token.go b/gmailutils/token/token.go index 32d750a..9f76e8b 100644 --- a/gmailutils/token/token.go +++ b/gmailutils/token/token.go @@ -18,11 +18,15 @@ package token import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "log" + "net/http" "os" + "strings" "golang.org/x/oauth2" ) @@ -32,6 +36,7 @@ func FromWeb(config *oauth2.Config) *oauth2.Token { authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) fmt.Fprintf(os.Stderr, "Go to the following link in your browser then type the "+ "authorization code: \n%v\n", authURL) + // TODO(bzz): open URL in browser https://github.com/youtube/api-samples/blob/master/go/oauth2.go#L124 var authCode string if _, err := fmt.Scan(&authCode); err != nil { @@ -57,6 +62,64 @@ func FromFile(file string) (*oauth2.Token, error) { return tok, err } +// contextKey is unexported type to prevent collisions with context keys. +type contextKey string + +const sessionKey contextKey = "session" + +// FromContext returnes the token, saved from the cookies, if any. +func FromContext(ctx context.Context) (*oauth2.Token, bool) { + token := ctx.Value(sessionKey) + if token == nil { // not authorized + return nil, false + } + + var tok oauth2.Token + tokenStr := token.(string) + err := json.NewDecoder(strings.NewReader(tokenStr)).Decode(&tok) + if err != nil { + log.Printf("Unable to decode JSON cookie k:%s v:%s, %v", sessionKey, tokenStr, err) + return nil, true + } + return &tok, true +} + +// NewSessionCookie returns a new cookie with the token set. +func NewSessionCookie(token *oauth2.Token) *http.Cookie { + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(token) + sessionVal := base64.StdEncoding.EncodeToString(buf.Bytes()) + + return &http.Cookie{ + Name: string(sessionKey), + Value: sessionVal, + Path: "/", + //HttpOnly: true, + } +} + +// NewSessionContext returnes a context with token set from the session cookie, if any. +func NewSessionContext(parent context.Context, cookies []*http.Cookie) context.Context { + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == string(sessionKey) { + sessionCookie = c + break + } + } + + if sessionCookie == nil { + return parent + } + + sessionVal, err := base64.StdEncoding.DecodeString(sessionCookie.Value) + if err != nil { + log.Printf("Unable to decode base64 session cookie val: %v", err) + return parent + } + return context.WithValue(parent, sessionKey, string(sessionVal)) +} + // Save saves the token to a file path. func Save(path string, token *oauth2.Token) { log.Printf("Saving credential file to: %s\n", path) diff --git a/main.go b/main.go index 672ae90..4503208 100644 --- a/main.go +++ b/main.go @@ -131,13 +131,13 @@ func main() { gmailLabel = &envLabel } - // TODO(bzz): fetchGmailAsync returning chan *gmail.Message - var urMsgs []*gmail.Message = fetchGmail(srv, user, fmt.Sprintf("label:%s is:unread", *gmailLabel)) + // TODO(bzz): FetchAsync returning chan *gmail.Message + var urMsgs []*gmail.Message = gmailutils.Fetch(srv, user, fmt.Sprintf("label:%s is:unread", *gmailLabel)) errCnt, urTitlesCnt, urTitles := extractPapersFromMsgs(urMsgs) var rTitles map[paper]int if *read { - rMsgs := fetchGmail(srv, user, fmt.Sprintf("label:%s is:read", *gmailLabel)) + rMsgs := gmailutils.Fetch(srv, user, fmt.Sprintf("label:%s is:read", *gmailLabel)) _, _, rTitles = extractPapersFromMsgs(rMsgs) } @@ -159,14 +159,6 @@ func main() { } } -// fetchGmail fetches all messages matching a given query from the Gmail. -func fetchGmail(srv *gmail.Service, user, query string) []*gmail.Message { - start := time.Now() - msgs := gmailutils.QueryMessages(srv, user, query) - log.Printf("%d messages found under %q (took %.0f sec)", len(msgs), query, time.Since(start).Seconds()) - return msgs -} - func extractPapersFromMsgs(messages []*gmail.Message) (int, int, map[paper]int) { errCount := 0 titlesCount := 0