Skip to content

Commit

Permalink
server: initial implementation, multitenant auth + label fetch
Browse files Browse the repository at this point in the history
Signed-off-by: Alexander Bezzubov <[email protected]>
  • Loading branch information
bzz committed Dec 6, 2019
1 parent 1d9e264 commit 39b385d
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 13 deletions.
110 changes: 110 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
@@ -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/<your-project-id>/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))
})
}
35 changes: 33 additions & 2 deletions gmailutils/gmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"log"
"net/http"
"strings"
"time"

"github.com/bzz/scholar-alert-digest/gmailutils/token"

Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions gmailutils/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
package token

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"

"golang.org/x/oauth2"
)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
14 changes: 3 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
Expand Down

0 comments on commit 39b385d

Please sign in to comment.