Skip to content

Instantly share code, notes, and snippets.

@drbobbeaty
Last active August 29, 2015 14:16
Show Gist options
  • Save drbobbeaty/44f60ab64a270ebf71ae to your computer and use it in GitHub Desktop.
Save drbobbeaty/44f60ab64a270ebf71ae to your computer and use it in GitHub Desktop.
OAuth2 Login to Google
(ns bartender.dcm
"The DCM-specific functions for loading data. The Google OAuth2 data for the
application is obtained from the Developer Console."
(:require [bartender.util :refer [parse-json update lcase]]
[cheshire.core :as json]
[clj-http.client :as http]
[clj-oauth2.client :as oauth2]
[clj-time.coerce :refer [to-long from-long]]
[clj-time.core :refer [now]]
[clojure.set :refer [rename-keys]]
[clojure.tools.logging :refer [infof warn warnf error errorf]]
[clojure.walk :refer [keywordize-keys]]
[compojure
[core :refer [defroutes GET POST DELETE PUT]]
[handler :as handler]]
[ring.middleware.jsonp :refer [wrap-json-with-padding]]
[ring.middleware.params :refer [wrap-params]]
[ring.util.io :refer [piped-input-stream]])
(:import [java.io BufferedWriter OutputStreamWriter IOException]))
(declare user-profiles)
;;
;; Missing functions from clj-oauth2
;;
(defn refresh-access-token
"Function to take the existing `access-token` and configuration data
and refresh this token with Google to make sure that we have a valid
token to work with."
[refresh-token {:keys [client-id client-secret access-token-uri]}]
(let [req (http/post access-token-uri {:form-params {:client_id client-id
:client_secret client-secret
:refresh_token refresh-token
:grant_type "refresh_token"}
:as :json
:throw-entire-message? true})]
(when (= (:status req) 200)
(parse-json (:body req)))))
;;
;; Constants from the Google Developer Console as to how to log in using
;; OAuth 2.0 to the site.
;;
(def BASE_URI "https://accounts.google.com")
(def AUTH_URI (str BASE_URI "/o/oauth2/auth"))
(def TOKEN_URI (str BASE_URI "/o/oauth2/token"))
(def CLIENT_ID "<my_client_id>")
(def CLIENT_SECRET "<my_client_secret>")
(def SCOPE
"For Google's OAuth2, we need to provide to them the 'scope' of the login -
meaning those things that we want to be able to control and call using the
`access-token` that Google will return to us. These are all the things we
need from DCM."
["https://www.googleapis.com/auth/dfareporting"
"https://www.googleapis.com/auth/devstorage.read_only"
"https://www.googleapis.com/auth/dfatrafficking"])
(def TRAFFIC_URI
"This is the base URI for all the trafficking-related endpoints at Google.
This will be appended to in the individual functions make the calling URL
but it's nice to have it here at the top to make a global change easier."
"https://www.googleapis.com/dfareporting/v2.0")
(def REDIRECT_URI
"This is the one endpoint that **we** have to implement which will be the
target of Google's OAuth2 callback which will contain all that we need to
get the access-token and the supporting values."
"http://localhost:3000/api/integration/google_auth_callback")
(def gconfig
"Map of basic config data for successfully hitting Gogle using OAuth2 for
the `access-token` to hit the DCM endpoints for all the trafficking data and
reporting data."
{ :authorization-uri AUTH_URI
:access-token-uri TOKEN_URI
:redirect-uri REDIRECT_URI
:client-id CLIENT_ID
:client-secret CLIENT_SECRET
:access-query-param :access_token
:scope SCOPE
:grant-type "authorization_code" })
(def auth-req
"The clj-oauth2 library needs to use the config to make an 'authorization
request' map that will be used in getting the tokens, etc."
(oauth2/make-auth-request gconfig))
(def gtoken
"This will be the data returned from Google that includes the `access-token`,
what we need to refresh it, and all the goodies. This will look something like:
{ :access-token \"ya29.JAG00FF1ZLK-UuRdK6e7zf5uFBuNdw_X46546536I9dbmKMbGVBSfAl7\"
:token-type \"bearer\"
:query-param :access_token
:expires-in 3600 }
"
(atom {}))
(def gprofile
"This will be the user profile for the authenticated user that generated the
`access-token` in use. This is obtained by calling the /userprofiles endpoint
in the Google API, and then extracting out the first item that will look
something like this:
{ :kind \"dfareporting#userProfile\"
:etag \"\"3_UO98K-Piv7TXZrLaOcjWlCRrs/XCU7oULm-5DPoOwijph80\"\"
:profileId \"123456890\"
:userName \"yo-yo-ma\"
:accountId \"1234\"
:accountName \"The Account for All\" }
"
(atom {}))
(defn auth-google
"Function to do the initial authentication request to Google for the above
configuration. This will be called from the `REDIRECT_URI` endpoint, where
the argument is the `:query-params` of the compojure route. This will log
the response from Google and then save it in the `gtoken` atom for later."
[params]
(let [pull (fn [m] (assoc m :expires-in (:expires_in (:params m))))
tok (-> (oauth2/get-access-token gconfig (keywordize-keys params) auth-req)
(update :token-type lcase)
(pull)
(dissoc :params))]
(infof "token response: %s" tok)
(reset! gtoken tok)
(infof "loading user profile...")
(reset! gprofile (first (user-profiles tok)))
tok))
(defn re-auth-google
"Function to update the `access-token` for the current session of the
authenticated user - which may, in fact, be the exact same token, but
Google will let us know either way."
[& [token]]
(let [tok (-> (refresh-access-token (:access-token (or token @gtoken)) gconfig)
(rename-keys {:token_type :token-type
:expires_in :expires-in
:access_token :access-token})
(update :token-type lcase))]
(infof "re-auth token response: %s" tok)
(reset! gtoken tok)
tok))
;;
;; Administrative Functions
;;
(defn user-profiles
"Function to get the complete list of allowed user profiles for the
authenticated user in `gtoken`."
[& [token]]
(let [url (str TRAFFIC_URI "/userprofiles")]
(:items (parse-json (:body (oauth2/get url {:oauth2 (or token @gtoken)}))))))
;;
;; Trafficking Functions
;;
(defn get-cities
"Function to list all the cities for the provided token and profile. The
default values are the current profile and token, but these can be passed
in if you want to make a special call."
[& [token profile]]
(let [pid (:profileId (or profile @gprofile))
url (str TRAFFIC_URI "/userprofiles/" pid "/cities")]
(parse-json (:body (oauth2/get url {:oauth2 (or token @gtoken)})))))
(defproject bartender "1.0.0"
:description "The bartender makes sure that the system is filled with data."
:url "https://github.com/drbobbeaty/bartender"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:plugins [[lein-marginalia "0.7.1"]]
:min-lein-version "2.3.4"
:source-paths ["src"]
:dependencies [[org.clojure/clojure "1.6.0"]
;; nice utilities
[clj-time "0.6.0"]
[org.clojure/math.numeric-tower "0.0.4"]
[org.clojure/core.memoize "0.5.7"]
;; command line option processing
[org.clojure/tools.cli "0.2.2"]
;; logging with log4j
[log4j/log4j "1.2.16"]
[org.clojure/tools.logging "0.2.6"]
[robert/hooke "1.3.0"]
;; JSON parsing library
[cheshire "5.3.1"]
;; nice HTTP client library
[clj-http "1.0.1"]
[clj-oauth2-ls "0.3.0"]
;; web server
[compojure "1.1.3"]
[lib-noir "0.9.5"]
[ring/ring-jetty-adapter "1.1.6"]
[ring.middleware.jsonp "0.1.1"]]
:aot [bartender.main]
:uberjar-merge-with {#"\.sql\.Driver$" [#(str (clojure.string/trim (slurp %)) "\n") str spit]}
:test-selectors {:default (complement :exercise)
:integration :integration
:all (constantly true)}
:jvm-opts ["-Xmx64m"]
:main bartender.main)
(ns bartender.server
"The routes for the web server."
(:require [bartender.dcm :as dcm]
[bartender.util :as util]
[cheshire.core :as json]
[clj-time.coerce :refer [to-long from-long]]
[clj-time.core :refer [now]]
[clojure.tools.logging :refer [infof warn warnf error errorf]]
[compojure
[core :refer [defroutes GET POST DELETE PUT]]
[handler :as handler]]
[noir.response :refer [redirect]]
[ring.middleware.jsonp :refer [wrap-json-with-padding]]
[ring.middleware.params :refer [wrap-params]]
[ring.util.io :refer [piped-input-stream]])
(:import [java.io BufferedWriter OutputStreamWriter IOException]))
(extend-protocol cheshire.generate/JSONable
org.joda.time.DateTime
(to-json [t jg]
(cheshire.generate/write-string jg (str t))))
(defn return-code
"Creates a ring response for returning the given return code."
[code]
{:status code
:headers {"Content-Type" "application/json; charset=UTF-8"}})
(defn return-json
"Creates a ring response for returning the given object as JSON."
([ob] (return-json ob (now) 200))
([ob lastm] (return-json ob lastm 200))
([ob lastm code]
{:status code
:headers {"Content-Type" "application/json; charset=UTF-8"
"Access-Control-Allow-Origin" "*"
"Last-Modified" (str (or lastm (now)))}
:body (piped-input-stream
(bound-fn [out]
(with-open [osw (OutputStreamWriter. out)
bw (BufferedWriter. osw)]
(let [error-streaming
(fn [e]
;; Since the HTTP headers have already been sent,
;; at this point it is too late to report the error
;; as a 500. The best we can do is abruptly print
;; an error and quit.
(.write bw "\n\n---BARTENDER SERVER ERROR WHILE STREAMING JSON---\n")
(.write bw (str e "\n\n"))
(warnf "Streaming exception for JSONP: %s" (.getMessage e)))]
(try
(json/generate-stream ob bw)
;; Handle "pipe closed" errors
(catch IOException e
(if (re-find #"Pipe closed" (.getMessage e))
(infof "Pipe Closed exception: %s" (.getMessage e))
(error-streaming e)))
(catch Throwable t
(error-streaming t)))))))}))
(defroutes app-routes
"Primary routes for the webserver."
(GET "/" []
(return-json {:app "Bartender",
:hello? "World!",
:code (or (util/git-commit) "unknown commit")}))
(GET "/heartbeat" []
(return-code 200))
;;
;; Authentication Endpoints for DCM (Google) - these are necessary due to
;; Google's OAuth2 implementation. But as soon as we have an access-token,
;; we can use Custos Server to hold this and use it over and over.
;;
(GET "/api/integration/google_auth_callback" {params :query-params}
(dcm/auth-google params)
(return-code 200))
(GET "/google" []
(redirect (:uri dcm/auth-req)))
(GET "/token" []
(return-json @dcm/gtoken))
(GET "/re-auth" []
(dcm/re-auth-google)
(return-json @dcm/gtoken))
;;
;;
;;
(GET "/cities" []
(return-json (dcm/get-cities)))
)
(defn wrap-logging
"Ring middleware to log requests and exceptions."
[handler]
(fn [req]
(infof "Handling request: %s" (:uri req))
(try (handler req)
(catch Throwable t
(error t "Server error!")
(throw t)))))
(def app
"The actual ring handler that is run -- this is the routes above
wrapped in various middlewares."
(-> app-routes
wrap-json-with-padding
handler/site
wrap-params
wrap-logging))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment