From 0ad21cafc9cd5c8fd5941b007a75573885bc0838 Mon Sep 17 00:00:00 2001 From: Maarten Abbink Date: Sun, 11 Nov 2012 01:01:17 +0100 Subject: [PATCH] implemented oauth2.0 for stackoverflow --- .gitignore | 6 +- langpop-aggregate/.classpath | 4 + langpop-web/.classpath | 18 +- langpop-web/build.sbt | 4 + .../main/resources/application.conf.sample | 18 ++ langpop-web/src/main/scala/Scalatra.scala | 2 + .../com/abbink/langpop/web/AuthServlet.scala | 57 ++++++ .../langpop/web/ComponentRegistry.scala | 6 +- .../abbink/langpop/web/LangpopServlet.scala | 7 +- .../langpop/web/auth/StackOverflowAuth.scala | 179 ++++++++++++++++++ .../langpop/web/DateLangRequestTests.scala | 2 + 11 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 langpop-web/src/main/resources/application.conf.sample create mode 100644 langpop-web/src/main/scala/com/abbink/langpop/web/AuthServlet.scala create mode 100644 langpop-web/src/main/scala/com/abbink/langpop/web/auth/StackOverflowAuth.scala diff --git a/.gitignore b/.gitignore index 13c6a34..1f5b9a0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ project/plugins/project/ # eclipse specific bin/* -.cache \ No newline at end of file +.cache + +# sensitive information +stackoverflow.auth.properties +langpop-web/src/main/resources/application.conf \ No newline at end of file diff --git a/langpop-aggregate/.classpath b/langpop-aggregate/.classpath index 90523af..c753084 100644 --- a/langpop-aggregate/.classpath +++ b/langpop-aggregate/.classpath @@ -6,6 +6,10 @@ + + + + diff --git a/langpop-web/.classpath b/langpop-web/.classpath index e5d9761..5679ff6 100644 --- a/langpop-web/.classpath +++ b/langpop-web/.classpath @@ -5,19 +5,23 @@ - + + + + + - + + + + - - - @@ -32,10 +36,6 @@ - - - - diff --git a/langpop-web/build.sbt b/langpop-web/build.sbt index b9d90e3..80f4154 100644 --- a/langpop-web/build.sbt +++ b/langpop-web/build.sbt @@ -6,9 +6,13 @@ seq(webSettings :_*) classpathTypes ~= (_ + "orbit") +resolvers += "Typesafe Repository" at "http://repo.typesafe.com/typesafe/releases/" + libraryDependencies ++= Seq( "org.scalatra" % "scalatra" % "2.1.1", "org.scalatra" % "scalatra-scalate" % "2.1.1", + "com.typesafe" % "config" % "1.0.0", + "org.apache.httpcomponents" % "httpclient" % "4.2.2", "org.scalatra" % "scalatra-specs2" % "2.1.1" % "test", "org.scalatra" % "scalatra-scalatest" % "2.1.1" % "test", "ch.qos.logback" % "logback-classic" % "1.0.6" % "runtime", diff --git a/langpop-web/src/main/resources/application.conf.sample b/langpop-web/src/main/resources/application.conf.sample new file mode 100644 index 0000000..360299f --- /dev/null +++ b/langpop-web/src/main/resources/application.conf.sample @@ -0,0 +1,18 @@ +# copy this file to application.conf +# and edit + +langpop-web { + auth { + stackoverflow { + client_id = 123 # your app's id + client_secret = "your own app's super secret client secred" + key = "your app's not so secret key" + redirect_uri = "http://localhost:8080/auth/stackoverflow/redirect" + + credentialsFile = "stackoverflow.auth.properties" # file will be created in execution path of server + } + github { + # nothing yet + } + } +} diff --git a/langpop-web/src/main/scala/Scalatra.scala b/langpop-web/src/main/scala/Scalatra.scala index b137a85..9177134 100644 --- a/langpop-web/src/main/scala/Scalatra.scala +++ b/langpop-web/src/main/scala/Scalatra.scala @@ -2,6 +2,7 @@ import org.scalatra.LifeCycle import com.abbink.langpop.web.ComponentRegistry import com.abbink.langpop.web.LangpopServlet +import com.abbink.langpop.web.AuthServlet import javax.servlet.ServletContext @@ -18,5 +19,6 @@ class Scalatra extends LifeCycle with ComponentRegistry { // Mount one or more servlets context.mount(new LangpopServlet, "/langpop/*") + context.mount(new AuthServlet, "/auth/*") } } diff --git a/langpop-web/src/main/scala/com/abbink/langpop/web/AuthServlet.scala b/langpop-web/src/main/scala/com/abbink/langpop/web/AuthServlet.scala new file mode 100644 index 0000000..20ef8db --- /dev/null +++ b/langpop-web/src/main/scala/com/abbink/langpop/web/AuthServlet.scala @@ -0,0 +1,57 @@ +package com.abbink.langpop.web + +import org.scalatra.scalate.ScalateSupport +import org.scalatra.ScalatraServlet +import java.net.URLDecoder + +class AuthServlet extends ScalatraServlet with ScalateSupport with ComponentRegistry { + + get("/") { + + +

Login status

+ + + + } + + private def stackoverflowLogin() = { + if (stackOverflowAuth.isAuthenticated()) { + signed in + sign out + } + else { + signed out + sign in + } + } + + private def githubLogin() = { + not implemented yet + } + + get("/stackoverflow/logout") { + if (stackOverflowAuth.isAuthenticated()) + stackOverflowAuth.clearAuth() + redirect("/auth") + } + + get("/stackoverflow/login") { + if (stackOverflowAuth.isAuthenticated()) + redirect("/auth") + else + redirect(stackOverflowAuth.buildOAuthUrl()) + } + + get("/stackoverflow/redirect") { + val code = URLDecoder.decode(params("code")) + //val state = params.get("state") map URLDecoder.decode + + stackOverflowAuth.finalizeAuth(code) + redirect("/auth") + } +} diff --git a/langpop-web/src/main/scala/com/abbink/langpop/web/ComponentRegistry.scala b/langpop-web/src/main/scala/com/abbink/langpop/web/ComponentRegistry.scala index e6a642f..abc8636 100644 --- a/langpop-web/src/main/scala/com/abbink/langpop/web/ComponentRegistry.scala +++ b/langpop-web/src/main/scala/com/abbink/langpop/web/ComponentRegistry.scala @@ -1,9 +1,11 @@ package com.abbink.langpop.web import com.abbink.langpop.aggregate.{ComponentRegistry => AggregatorComponentRegistry} +import com.abbink.langpop.web.auth.StackOverflowAuthComponent trait ComponentRegistry extends - AggregatorComponentRegistry + AggregatorComponentRegistry with + StackOverflowAuthComponent { - + val stackOverflowAuth = StackOverflowAuthImpl } diff --git a/langpop-web/src/main/scala/com/abbink/langpop/web/LangpopServlet.scala b/langpop-web/src/main/scala/com/abbink/langpop/web/LangpopServlet.scala index 4cd46a4..ba65517 100644 --- a/langpop-web/src/main/scala/com/abbink/langpop/web/LangpopServlet.scala +++ b/langpop-web/src/main/scala/com/abbink/langpop/web/LangpopServlet.scala @@ -13,8 +13,11 @@ class LangpopServlet extends ScalatraServlet with ScalateSupport with ComponentR get("/") { -

Hello, world!

- Say hello to Scalate. +

langpop

+ } diff --git a/langpop-web/src/main/scala/com/abbink/langpop/web/auth/StackOverflowAuth.scala b/langpop-web/src/main/scala/com/abbink/langpop/web/auth/StackOverflowAuth.scala new file mode 100644 index 0000000..b19245d --- /dev/null +++ b/langpop-web/src/main/scala/com/abbink/langpop/web/auth/StackOverflowAuth.scala @@ -0,0 +1,179 @@ +package com.abbink.langpop.web.auth + +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.ArrayList +import java.util.Date +import java.util.Properties +import org.apache.http.client.entity.UrlEncodedFormEntity +import org.apache.http.client.methods.HttpPost +import org.apache.http.client.utils.URIBuilder +import org.apache.http.client.HttpClient +import org.apache.http.impl.client.DefaultHttpClient +import org.apache.http.message.BasicNameValuePair +import org.apache.http.HttpResponse +import org.apache.http.NameValuePair +import com.typesafe.config.ConfigFactory +import org.apache.http.util.EntityUtils +import org.apache.http.client.utils.URLEncodedUtils +import java.net.URI +import java.nio.charset.Charset + +trait StackOverflowAuth { + + def isAuthenticated() : Boolean + + def buildOAuthUrl() : String + + def clearAuth() : Unit + + def finalizeAuth(code : String) : Unit +} + +trait StackOverflowAuthComponent { + val stackOverflowAuth:StackOverflowAuth + + object StackOverflowAuthImpl extends StackOverflowAuth { + + val config = ConfigFactory.load() + val mergedConfig = config.getConfig("langpop-web").withFallback(config) + + val client_id = mergedConfig.getString("langpop-web.auth.stackoverflow.client_id") + val client_secret = mergedConfig.getString("langpop-web.auth.stackoverflow.client_secret") + val scope = "no_expiry" + val redirect_uri = mergedConfig.getString("langpop-web.auth.stackoverflow.redirect_uri") + val credentialsFileName = mergedConfig.getString("langpop-web.auth.stackoverflow.credentialsFile") + + var access_token : Option[String] = None + var expires : Option[Date] = None + readAuth() + + def isAuthenticated() = { + access_token match { + case None => false + case Some(t) => expires match { + case None => true + case Some(e) => e after new Date() + } + } + } + + def buildOAuthUrl() = { + val uriBuilder = new URIBuilder(); + uriBuilder.setScheme("https").setHost("stackexchange.com").setPath("/oauth") + .setParameter("client_id", client_id) + .setParameter("scope", scope) + .setParameter("redirect_uri", redirect_uri) + uriBuilder.build.toString() + } + + /** + * reads access token and expiration date from properties file + */ + private def readAuth() : Unit = { + access_token = None + expires = None + try { + val fs : InputStream = new FileInputStream(credentialsFileName); + val props : Properties = new Properties() + props.load(fs); + fs.close() + + val token = props.getProperty("access_token") + val exp = props.getProperty("expires") + access_token = token match { + case null => None + case x => Some(x) + } + expires = exp match {case null => None case x => Some(new Date(1000 * (x.toLong)))} + } + catch { + case e => //TODO + } + (access_token, expires) + } + + /** + * clears all auth data (i.e. signs out) + */ + def clearAuth() = { + access_token = None + expires = None + try { + val props = new Properties() + val fs : OutputStream = new FileOutputStream(credentialsFileName) + props.store(fs, null) + fs.close() + } + catch { + case e => //TODO + } + } + + def finalizeAuth(code : String) = { + val uriBuilder = new URIBuilder() + uriBuilder.setScheme("https").setHost("stackexchange.com").setPath("/oauth/access_token") + val client : HttpClient = new DefaultHttpClient() + val formparams : java.util.List[NameValuePair] = new ArrayList[NameValuePair]() + formparams.add(new BasicNameValuePair("client_id", client_id)) + formparams.add(new BasicNameValuePair("client_secret", client_secret)) + formparams.add(new BasicNameValuePair("code", code)) + formparams.add(new BasicNameValuePair("redirect_uri", redirect_uri)) + val entity : UrlEncodedFormEntity = new UrlEncodedFormEntity(formparams, "UTF-8") + val post = new HttpPost(uriBuilder.build()) + post.setEntity(entity) + val response : HttpResponse = client.execute(post) + + if (response.getStatusLine().getStatusCode() != 400) { + var access_token : Option[String] = None + var expires : Option[Date] = None + + val entity = response.getEntity() + val entityContent = EntityUtils.toString(entity) + val entities : java.util.List[NameValuePair] = URLEncodedUtils.parse( + entityContent, + Charset.forName(entity.getContentEncoding() match { + case null => "ISO-8859-1" //default from EntityUtils + case x => x.getValue() + })) + val iter = entities.iterator() + while (iter.hasNext()) { + val pair : NameValuePair = iter.next() + pair.getName() match { + case "access_token" => access_token = Some(pair.getValue()) + case "expires" => expires = Some(new Date(1000 * (pair.getValue().toLong))) + case _ => //ignore + } + } + + writeAuth(access_token, expires) + } + } + + private def writeAuth(accessToken : Option[String], expires : Option[Date]) = { + if (accessToken == None) { + clearAuth + } + else { + this.access_token = accessToken + this.expires = expires + try { + val props = new Properties() + props.setProperty("access_token", accessToken.get); + expires match { + case Some(date) => props.setProperty("expires", (date.getTime()/1000).toString()) + case _ => //ignore + } + val fs : OutputStream = new FileOutputStream(credentialsFileName); + props.store(fs, null); + fs.close() + } + catch { + case e => //TODO + } + } + } + } +} diff --git a/langpop-web/src/test/scala/com/abbink/langpop/web/DateLangRequestTests.scala b/langpop-web/src/test/scala/com/abbink/langpop/web/DateLangRequestTests.scala index d5c87cf..13f56c9 100644 --- a/langpop-web/src/test/scala/com/abbink/langpop/web/DateLangRequestTests.scala +++ b/langpop-web/src/test/scala/com/abbink/langpop/web/DateLangRequestTests.scala @@ -19,6 +19,8 @@ class DateLangRequestTests extends ScalatraSuite with FunSuite with TestingEnvir // aggregator.system.shutdown() // } + + test("get date/language") { println(":get date/language") get("/langpop/2012-10-19/scala") {