Skip to content

Commit

Permalink
fixed innoq#148: introduce OpenId Connect
Browse files Browse the repository at this point in the history
- the configuration initialization is now a function which called during server startup
   - the configuration instance will be passed as a parameter to the functions requiring it
- added depenendcies to https://github.com/ddellacosta/friend-oauth2/ and https://github.com/cemerick/friend
- modified routing.clj to secure all requests via `allow-anon? false`
- upon first request to `/statuses/updates`, you are now redirected to the innoq-internal oAuth2/ OpenID connect server
- added configuration properties for the oAuth2 process
- introduced avatar image beneath the statuses logo at the top left to indicate currently active user
  • Loading branch information
aheusingfeld committed Dec 8, 2014
1 parent 0c7a4a1 commit 0dcc1e1
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 65 deletions.
29 changes: 21 additions & 8 deletions config.clj
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
{:title "innoQ Status Updates"
:database-path "data/db.json"
:save-interval 2
:http-port 8080
:host "localhost"
:run-mode :prod
{:title "innoQ Status Updates"
:database-path "data/db.json"
:save-interval 2
:host "localhost"
:http-port 8080
:external-url "http://localhost:8080"
:external-url-path "/statuses"
:run-mode :dev
; {username} is replaced with the username
:avatar-url "https://.../users/{username}/avatar/32x32"
:avatar-url "https://testldap.innoq.com/liqid/users/{username}/avatar/32x32"
;:avatar-url "http://assets.github.com/images/gravatars/gravatar-user-420.png"
:profile-url-prefix "https://intern.innoq.com/liqid/users/"}
:profile-url-prefix "https://testldap.innoq.com/liqid/users/"
:entry {
:min-length 1
:max-length 140}
; set the following parameters to enable openID connect authentication
:oauth-server-authorize-uri "https://testldap.innoq.com/openid/authorize"
:oauth-server-token-uri "https://testldap.innoq.com/openid/token"
:oauth-server-userinfo-uri "https://testldap.innoq.com/openid/userinfo"
:oauth-client-id "08f74afd-aa5a-4fda-b506-56955ed0089a"
:oauth-client-secret "ANPgFiTvF9-1FoNrOMwCls36CEIYC1to6J4vjQJuFwKwCGtuRnvbx1zFHmqCuKG0fFZPfOdd9GdGF3Qd67p87wc"
; registration-access-token ""
}
6 changes: 4 additions & 2 deletions project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
:comments "A business-friendly OSS license"}
:dependencies [[org.clojure/clojure "1.6.0"]
[ring "1.3.2"]
[compojure "1.2.2"]
[compojure "1.3.1" :exclusions [ring/ring-core]]
[clj-time "0.8.0"]
[org.clojure/data.json "0.2.5"]]
[org.clojure/data.json "0.2.5"]
[com.cemerick/friend "0.2.1" :exclusions ([ring/ring-core] [slingshot] [org.apache.httpcomponents/httpclient] [commons-logging])]
[friend-oauth2 "0.1.2" :exclusions ([commons-codec] [crypto-random])]]
:pedantic? :abort
:plugins [[jonase/eastwood "0.2.0"]]
:profiles {:dev {:dependencies [[ring-mock "0.1.5"]]}
Expand Down
5 changes: 5 additions & 0 deletions resources/public/statuses/css/statuses.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ ul {
font-size: 12px;
}

.navbar .avatar {
margin: 10px 0 0 0;
float:left;
}

.navbar label {
font-weight: 300;
margin: 0;
Expand Down
17 changes: 14 additions & 3 deletions src/statuses/configuration.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@
{:title "Status Updates"
:database-path "data/db.json"
:save-interval 1
:host "localhost"
:http-port 8080
:external-url "http://localhost:8080"
:external-url-path "/statuses"
:run-mode :dev
:profile-url-prefix "https://intern.innoq.com/liqid/users/"
:avatar-url "http://assets.github.com/images/gravatars/gravatar-user-420.png"
; {username} is replaced with the username
:avatar-url "https://example.com/ldap/users/{username}/avatar/32x32"
:profile-url-prefix "https://example.com/ldap/users/"
:entry {
:min-length 1
:max-length 140}})
:max-length 140}
; set the following parameters to enable openID connect authentication
:oauth-server-authorize-uri nil
:oauth-server-token-uri nil
:oauth-server-userinfo-uri nil
:oauth-client-id nil
:oauth-client-secret nil
})

(def config-holder (atom default-config))

Expand Down
6 changes: 6 additions & 0 deletions src/statuses/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@
(str (updates-path) "?query=@" username
(if response-format (str "&format=" (name response-format)) ""))))

(def logout-template (str base-template "/logout"))
(defn logout-path [] (str (base-path) "/logout"))

(defn issue-path [] "https://github.com/innoq/statuses/issues"); TODO: read from configuration

(defn avatar-path [username]
(clojure.string/replace (config :avatar-url) "{username}" username))

(defn user-profile-path [access-token]
(str (config :oauth-server-userinfo-uri) "?access_token=" access-token))

161 changes: 124 additions & 37 deletions src/statuses/routing.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
(ns statuses.routing
(:require [compojure.core :refer [DELETE GET POST defroutes]]
[compojure.route :refer [not-found]]
[compojure.handler :as handler]
[ring.util.response :refer [redirect response]]
[statuses.backend.core :as core]
[statuses.backend.json :as json]
Expand All @@ -9,19 +10,29 @@
[statuses.views.atom :as atom]
[statuses.views.info :as info-view]
[statuses.views.main :refer [list-page reply-form]]
[statuses.views.too-long :as too-long-view]))
[statuses.views.too-long :as too-long-view]
[cheshire.core :as cjson]
[cemerick.friend :as friend]
(cemerick.friend [workflows :as workflows]
[credentials :as creds])
[friend-oauth2.workflow :as oauth2]
[friend-oauth2.util :refer [format-config-uri get-access-token-from-params]]
[clj-http.client :as http]
[statuses.views.layout :as layout]))

(defn user [request]
(get-in request [:headers "remote_user"] "guest"))
(or (get-in request [:session :cemerick.friend/identity :current :profile :sub])
(get-in request [:headers "remote_user"])
"guest"))

(defn parse-num [s default]
(if (nil? s) default (read-string s)))

(defn base-uri [request]
(str
(name (or (get-in request [:headers "x-forwarded-proto"]) (:scheme request)))
"://"
(get-in request [:headers "host"])))
(name (or (get-in request [:headers "x-forwarded-proto"]) (:scheme request)))
"://"
(get-in request [:headers "host"])))

(defn content-type
[type body]
Expand All @@ -40,31 +51,31 @@
[request etag & body]
`(let [last-etag# (get-in ~request [:headers "if-none-match"])
etag-str# (str ~etag)]
(if (= etag-str# last-etag#)
{:location (:uri ~request), :status 304, :body ""}
(assoc-in ~@body [:headers "etag"] etag-str#))))
(if (= etag-str# last-etag#)
{:location (:uri ~request), :status 304, :body ""}
(assoc-in ~@body [:headers "etag"] etag-str#))))

(defn updates-page [params request]
(let [next (next-uri (update-in params [:offset] (partial + (:limit params))) request)
{:keys [limit offset author query format]} params]
(with-etag request (:time (first (core/get-latest @db 1 offset author query)))
(let [items (core/label-updates :can-delete?
(partial core/can-delete? @db (user request))
(core/get-latest @db limit offset author query))]
(cond
(= format "json") (content-type
"application/json"
(json/as-json {:items items, :next next}))
(= format "atom") (content-type
"application/atom+xml;charset=utf-8"
(atom/render-atom items
(str (base-uri request) "/statuses")
(str (base-uri request)
"/statuses/updates?"
(:query-string request))))
:else (content-type
"text/html;charset=utf-8"
(list-page items next (user request) nil)))))))
(let [items (core/label-updates :can-delete?
(partial core/can-delete? @db (user request))
(core/get-latest @db limit offset author query))]
(cond
(= format "json") (content-type
"application/json"
(json/as-json {:items items, :next next}))
(= format "atom") (content-type
"application/atom+xml;charset=utf-8"
(atom/render-atom items
(str (base-uri request) "/statuses")
(str (base-uri request)
"/statuses/updates?"
(:query-string request))))
:else (content-type
"text/html;charset=utf-8"
(list-page items next (user request) nil)))))))

(defn new-update
"Handles the request to add a new update. Checks whether the post values 'entry-text' or
Expand Down Expand Up @@ -125,16 +136,92 @@
(if-let [item (core/get-update @db (Integer/parseInt id))]
(reply-form (:id item) (:author item))))

(defroutes app-routes
(GET "/" [] (redirect (route/base-path)))
(GET route/base-template [] (redirect (route/updates-path)))
(GET route/updates-template [] handle-list-view)
(POST route/updates-template [] new-update)
(GET [route/update-template, :id #"[0-9]+"] [id :as r] (page id r))
(DELETE [route/update-template, :id #"[0-9]+"] [id :as r] (delete-entry id r))
(GET [route/update-replyform-template, :id #"[0-9]+"] [id :as r] (replyform id r))
(GET route/conversation-template [id :as r] (conversation id r))
(GET route/info-template [] info)
(GET route/too-long-template [length :as r] (too-long length r))
(not-found "Not Found"))
(defn render-session-page [request]
(let [count (:count (:session request) 0)
session (assoc (:session request) :count (inc count))]
(layout/default
"Session page"
(user request)
(str "<p>The current session: <pre style=\"text-align:left\">" (cjson/generate-string session {:pretty true}) "</pre></p>"))))

(defroutes app-routes
(GET "/" [] (redirect (route/base-path)))
(GET route/base-template [] (redirect (route/updates-path)))
(GET route/updates-template [] handle-list-view)
(POST route/updates-template [] new-update)
(GET [route/update-template, :id #"[0-9]+"] [id :as r] (page id r))
(DELETE [route/update-template, :id #"[0-9]+"] [id :as r] (delete-entry id r))
(GET [route/update-replyform-template, :id #"[0-9]+"] [id :as r] (replyform id r))
(GET route/conversation-template [id :as r] (conversation id r))
(GET route/info-template [] info)
(GET route/too-long-template [length :as r] (too-long length r))
(GET "/statuses/session" [] render-session-page)
(GET route/logout-template [] (friend/logout* (redirect (route/updates-path))))
(not-found "Not Found"))






(def config-auth {:roles #{::user ::admin}})

(defn client-config
[current-config]
{:client-id (:oauth-client-id current-config)
:client-secret (:oauth-client-secret current-config)
:auth-uri (:oauth-server-authorize-uri current-config)
:token-uri (:oauth-server-token-uri current-config)
;; TODO get friend-oauth2 to support :context, :path-info
:callback {:domain (:external-url current-config) :path (str (:external-url-path current-config) "/oauth2callback")}})

(defn uri-config
[client-config]
{:authentication-uri {:url (:auth-uri client-config)
:query {:client_id (:client-id client-config)
:redirect_uri (format-config-uri client-config)
:response_type "code"}}
:access-token-uri {:url (:token-uri client-config)
:query {:client_id (:client-id client-config)
:client_secret (:client-secret client-config)
:grant_type "authorization_code"
:redirect_uri (format-config-uri client-config)}}})

(defn get-user-profile
"Call the OpenID provider to retrieve the profile information for the current authenticated user."
[access-token]
(let [url (route/user-profile-path access-token)
response (http/get url {:accept :json})
profile (cjson/parse-string (:body response) true)]
profile))

(defn retrieve-user-profile
"Extracts the access-token to call the user-profile service which returns the user's profile data"
[creds]
(let [token (:access-token creds)
profile (get-user-profile token)]
{:identity {:token token :profile profile}}))

(defn extract-access-token
"Alternate function to read the access_token from the HTTP body"
[request]
(-> (:body request) cjson/parse-string (get "access_token")))

(defn site
[current-config]
"Entry function to be called on server startup"
(handler/site
(friend/authenticate
app-routes
{:allow-anon? false
:login-uri "/statuses/login"
:workflows [(oauth2/workflow
{:client-config (client-config current-config)
:uri-config (uri-config (client-config current-config))
; is called to extract the access_token. This is the chance to retrieve the user profile
:credential-fn retrieve-user-profile
; the access_token is returned by the openID server in the HTTP request body after 'access-token-uri' (see above) has been called
:access-token-parsefn extract-access-token
; this is called if authorization fails
:auth-error-fn render-session-page
:config-auth config-auth})]})))
21 changes: 12 additions & 9 deletions src/statuses/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
[statuses.routing :as main])
(:gen-class))

(def app
(-> main/app-routes
(defn init-app
[current-config]
(-> (main/site current-config)
wrap-params
(wrap-resource "public")
wrap-content-type
Expand All @@ -26,11 +27,13 @@
(println "Starting server on host" (config :host)
"port" (config :http-port)
"in mode" (config :run-mode))
(run-jetty
(if (= (config :run-mode) :dev)
(wrap-reload app)
app)
{:host (config :host)
:port (config :http-port)
:join? false}))

(let [appl (init-app (config))]
(run-jetty
(if (= (config :run-mode) :dev)
(wrap-reload appl)
appl)
{:host (config :host)
:port (config :http-port)
:join? false})))

15 changes: 9 additions & 6 deletions src/statuses/views/layout.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,34 @@
[hiccup.form :refer [check-box]]
[hiccup.page :refer [html5 include-css include-js]]
[statuses.configuration :refer [config]]
[statuses.routes :refer [info-path issue-path mention-path]]
[statuses.routes :refer [info-path issue-path mention-path avatar-path logout-path]]
[statuses.views.common :refer [icon]]))

(defn preference [id title iconname]
(defn- preference [id title iconname]
[:li [:a {:name id}
(icon iconname)
[:label {:for (str "pref-" id)} title]
(check-box {:class "pref" :disabled "disabled"} (str "pref-" id))]])

(defn nav-link [url title iconname]
(defn- nav-link [url title iconname]
[:li (link-to url (icon iconname) title)])

(defn nav-links [username]
(list (nav-link (mention-path username) "Mentions" "at")
(nav-link (mention-path username :atom) "Feed (mentions)" "rss")
(nav-link (info-path) "Info" "info")
(nav-link (issue-path) "Issues" "github")
(preference "inline-images" "Inline images?" "cogs")))
(preference "inline-images" "Inline images?" "cogs")
(nav-link (logout-path) "Logout" "sign-out")))

(defn default
([title username content] (default title username content nil))
([title username content footer]
(html5
(html5 {:lang "de" :content "en" :dir "ltr" :typeof "bibo:Document" :property "dcterms:language"}
[:head
[:meta {:name "viewport"
:content "width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no"}]
[:title (str title " - innoQ Statuses")]
[:title (str title " - " (config :title))]
(include-css "//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css")
(include-css "//maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css")
(include-css "/statuses/css/statuses.css")
Expand All @@ -47,6 +48,8 @@
[:span.icon-bar]
[:span.icon-bar]
[:span.icon-bar]]
[:div.avatar
(link-to "/statuses/session" [:img {:src (avatar-path username) :alt username}])]
[:a {:class "navbar-brand", :href "/statuses/updates"} "Statuses"]]
[:div.collapse.navbar-collapse
[:ul.nav.navbar-nav (nav-links username)]]]]
Expand Down
9 changes: 9 additions & 0 deletions test/oauth.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
(ns oauth
(:use clojure.test
[statuses.routing :only [extract-access-token]]))

(deftest extract-access-token-from-body
(is
(=
(extract-access-token {:body "{\"access_token\":\"test.A-JEKLslDlCv5uO0SmH_TWB9SHxLuk9IqITcWk1ZvA\"}"})
"test.A-JEKLslDlCv5uO0SmH_TWB9SHxLuk9IqITcWk1ZvA")))

0 comments on commit 0dcc1e1

Please sign in to comment.