diff --git a/config.clj b/config.clj index 1425db0..c4161b3 100644 --- a/config.clj +++ b/config.clj @@ -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 "" + } diff --git a/project.clj b/project.clj index 02f9c88..609a8cf 100644 --- a/project.clj +++ b/project.clj @@ -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"]]} diff --git a/resources/public/statuses/css/statuses.css b/resources/public/statuses/css/statuses.css index 129ab19..aff7fd9 100644 --- a/resources/public/statuses/css/statuses.css +++ b/resources/public/statuses/css/statuses.css @@ -37,6 +37,11 @@ ul { font-size: 12px; } +.navbar .avatar { + margin: 10px 0 0 0; + float:left; +} + .navbar label { font-weight: 300; margin: 0; diff --git a/src/statuses/configuration.clj b/src/statuses/configuration.clj index 7bc4fa6..b3bcef8 100644 --- a/src/statuses/configuration.clj +++ b/src/statuses/configuration.clj @@ -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)) diff --git a/src/statuses/routes.clj b/src/statuses/routes.clj index 5c9f776..a240342 100644 --- a/src/statuses/routes.clj +++ b/src/statuses/routes.clj @@ -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)) + diff --git a/src/statuses/routing.clj b/src/statuses/routing.clj index 6b34c50..1779f6a 100644 --- a/src/statuses/routing.clj +++ b/src/statuses/routing.clj @@ -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] @@ -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] @@ -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 @@ -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 "

The current session:

" (cjson/generate-string session {:pretty true}) "

")))) +(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})]}))) diff --git a/src/statuses/server.clj b/src/statuses/server.clj index 79caff0..6720700 100644 --- a/src/statuses/server.clj +++ b/src/statuses/server.clj @@ -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 @@ -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}))) diff --git a/src/statuses/views/layout.clj b/src/statuses/views/layout.clj index b699f30..3dcdbd9 100644 --- a/src/statuses/views/layout.clj +++ b/src/statuses/views/layout.clj @@ -3,16 +3,16 @@ [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] @@ -20,16 +20,17 @@ (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") @@ -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)]]]] diff --git a/test/oauth.clj b/test/oauth.clj new file mode 100644 index 0000000..28fa099 --- /dev/null +++ b/test/oauth.clj @@ -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")))