├── .travis.yml ├── dev ├── dev │ ├── local.clj │ └── tasks.clj ├── user.clj ├── resources │ └── zanmi │ │ └── config.edn └── dev.clj ├── src └── zanmi │ ├── util │ ├── time.clj │ ├── response.clj │ └── validation.clj │ ├── middleware │ ├── format.clj │ ├── cors.clj │ ├── logger.clj │ └── authentication.clj │ ├── component │ ├── database.clj │ ├── signer.clj │ ├── signer │ │ ├── sha.clj │ │ └── asymetric.clj │ ├── timbre.clj │ ├── immutant.clj │ └── database │ │ ├── mongo.clj │ │ └── postgres.clj │ ├── view │ └── profile_view.clj │ ├── boundary │ ├── logger.clj │ ├── signer.clj │ └── database.clj │ ├── main.clj │ ├── config.clj │ ├── system.clj │ ├── data │ └── profile.clj │ └── endpoint │ └── profile_endpoint.clj ├── .gitignore ├── test └── zanmi │ ├── test_config.clj │ ├── boundary │ └── database_test.clj │ ├── view │ └── profile_view_test.clj │ └── data │ └── profile_test.clj ├── config.edn.example ├── LICENSE ├── project.clj └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: clojure 2 | -------------------------------------------------------------------------------- /dev/dev/local.clj: -------------------------------------------------------------------------------- 1 | ;; Local REPL configuration 2 | 3 | (alter-var-root #'config meta-merge {}) 4 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user) 2 | 3 | (defn dev 4 | "Load and switch to the 'dev' namespace." 5 | [] 6 | (require 'dev) 7 | (in-ns 'dev) 8 | :loaded) 9 | -------------------------------------------------------------------------------- /src/zanmi/util/time.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.util.time 2 | (:require [clj-time.core :as time])) 3 | 4 | (defn now [] 5 | (.toDate (time/now))) 6 | 7 | (defn in-hours [hours] 8 | (.toDate (time/plus (time/now) (time/hours hours)))) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | /.dir-locals.el 11 | /profiles.clj 12 | dev/local.clj 13 | log/ 14 | dev/resources/keypair 15 | -------------------------------------------------------------------------------- /src/zanmi/middleware/format.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.middleware.format 2 | (:require [ring.middleware.format :refer [wrap-restful-format]])) 3 | 4 | (defn wrap-format [handler formats] 5 | (wrap-restful-format handler :formats formats)) 6 | -------------------------------------------------------------------------------- /src/zanmi/util/response.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.util.response 2 | (:require [zanmi.view.profile-view :refer [render-error]] 3 | [ring.util.response :as response :refer [response]])) 4 | 5 | (defn error [msg status] 6 | (-> (response (render-error msg)) 7 | (assoc :status status))) 8 | -------------------------------------------------------------------------------- /src/zanmi/component/database.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.database 2 | (:require [zanmi.component.database.mongo :refer [mongo]] 3 | [zanmi.component.database.postgres :refer [postgres]])) 4 | 5 | (defn database [config] 6 | (case (:engine config) 7 | :postgres (postgres config) 8 | :mongo (mongo config))) 9 | -------------------------------------------------------------------------------- /dev/dev/tasks.clj: -------------------------------------------------------------------------------- 1 | (ns dev.tasks 2 | (:refer-clojure :exclude [test]) 3 | (:require [duct.generate :as gen] 4 | [eftest.runner :as eftest] 5 | [reloaded.repl :refer [system]])) 6 | 7 | (defn setup [] 8 | (gen/locals)) 9 | 10 | (defn test [] 11 | (eftest/run-tests (eftest/find-tests "test") {:multithread? false})) 12 | -------------------------------------------------------------------------------- /src/zanmi/middleware/cors.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.middleware.cors 2 | (:require [ring.middleware.cors :as cors])) 3 | 4 | (defn wrap-cors [handler allowed-origins] 5 | (let [allowed-origin-patterns (map re-pattern allowed-origins)] 6 | (cors/wrap-cors handler 7 | :access-control-allow-origin allowed-origin-patterns 8 | :access-control-allow-methods [:post :put :delete]))) 9 | -------------------------------------------------------------------------------- /test/zanmi/test_config.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.test-config) 2 | 3 | (def config 4 | {:db {:engine :postgres 5 | :username "zanmi" 6 | :password "zanmi-password" 7 | :host "localhost" 8 | :db-name "zanmi_test"} 9 | 10 | :profile-schema {:username-length 32 11 | :password-length 64 12 | :password-score 1} 13 | 14 | :secret "nobody knows this!"}) 15 | -------------------------------------------------------------------------------- /src/zanmi/view/profile_view.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.view.profile-view 2 | (:require [zanmi.boundary.signer :as signer])) 3 | 4 | (defn render-auth-token [profile signer] 5 | {:auth-token (signer/auth-token signer profile)}) 6 | 7 | (defn render-reset-token [profile signer] 8 | {:reset-token (signer/reset-token signer profile)}) 9 | 10 | (defn render-message [message] 11 | {:message message}) 12 | 13 | (defn render-error [message] 14 | {:error message}) 15 | -------------------------------------------------------------------------------- /src/zanmi/component/signer.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.signer 2 | (:require [zanmi.component.signer.asymetric :refer [ecdsa-signer 3 | rsa-pss-signer]] 4 | [zanmi.component.signer.sha :refer [sha-signer]])) 5 | 6 | (defn signer [config] 7 | (case (:alg config) 8 | (:es256 :es512) (ecdsa-signer config) 9 | (:ps256 :ps512) (rsa-pss-signer config) 10 | (:hs256 :hs512) (sha-signer config))) 11 | -------------------------------------------------------------------------------- /src/zanmi/middleware/logger.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.middleware.logger 2 | (:require [zanmi.component.timbre :as logger] 3 | [ring.logger :refer [wrap-with-logger]] 4 | [ring.logger.protocols] 5 | [taoensso.timbre :as timbre]) 6 | (:import (zanmi.component.timbre Timbre))) 7 | 8 | (extend-protocol ring.logger.protocols/Logger 9 | Timbre 10 | (add-extra-middleware [_ handler] handler) 11 | (log [logger level throwable message] 12 | (timbre/log* logger level throwable message))) 13 | 14 | (defn wrap-logger [handler logger] 15 | (wrap-with-logger handler {:logger logger})) 16 | -------------------------------------------------------------------------------- /src/zanmi/component/signer/sha.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.signer.sha 2 | (:require [zanmi.boundary.signer :as signer] 3 | [buddy.sign.jwt :as jwt])) 4 | 5 | (defn- alg-key [size] 6 | (if (= size 256) 7 | :hs256 8 | :hs512)) 9 | 10 | (defrecord ShaSigner [size secret] 11 | signer/Signer 12 | (sign [signer data] 13 | (let [alg (alg-key size)] 14 | (jwt/sign data secret {:alg alg}))) 15 | 16 | (unsign [signer signed-data] 17 | (let [alg (alg-key size)] 18 | (jwt/unsign signed-data secret {:alg alg})))) 19 | 20 | (defn sha-signer [{:keys [size secret] :as config}] 21 | (map->ShaSigner config)) 22 | -------------------------------------------------------------------------------- /src/zanmi/component/timbre.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.timbre 2 | (:require [zanmi.boundary.logger :as logger] 3 | [taoensso.timbre :as timbre] 4 | [taoensso.timbre.appenders.3rd-party.rolling 5 | :refer [rolling-appender]])) 6 | 7 | (defrecord Timbre [] 8 | logger/Logger 9 | (log [logger level throwable message] 10 | (timbre/log* logger level throwable message))) 11 | 12 | (defn timbre [{:keys [level] :as config}] 13 | (let [appender-config (select-keys config [:path :pattern])] 14 | (map->Timbre 15 | {:level level 16 | :appenders {:rolling (rolling-appender appender-config)}}))) 17 | -------------------------------------------------------------------------------- /src/zanmi/util/validation.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.util.validation 2 | (:require [bouncer.core :as bouncer])) 3 | 4 | (defn- with-fn-messages [{:keys [message metadata path value] :as error}] 5 | (let [message-fn (:message-fn metadata)] 6 | (if (and (fn? message-fn) (not message)) 7 | (message-fn path value) 8 | (bouncer/with-default-messages error)))) 9 | 10 | (defn- validate [data schema] 11 | (bouncer/validate with-fn-messages data schema)) 12 | 13 | (defn when-valid [data schema validated-fn] 14 | (let [[errors validated] (validate data schema)] 15 | (if errors 16 | {:error errors} 17 | {:ok (validated-fn validated)}))) 18 | -------------------------------------------------------------------------------- /dev/resources/zanmi/config.edn: -------------------------------------------------------------------------------- 1 | {:api-key "unlock this door!" 2 | 3 | :allowed-origins ["http://localhost:3000"] 4 | 5 | :db {:engine :postgres 6 | :username "zanmi" 7 | :password "zanmi-password" 8 | :host "localhost" 9 | :db-name "zanmi_dev"} 10 | 11 | :http {:port 8686} 12 | 13 | :logger {:level :info 14 | :path "log/zanmi.log" 15 | :pattern :daily} 16 | 17 | :profile-schema {:username-length 32 18 | :password-length 64 19 | :password-score 3} 20 | 21 | :signer {:alg :ps512 22 | :keypair {:public "dev/resources/keypair/pub.pem" 23 | :private "dev/resources/keypair/priv.pem"} 24 | :auth-exp 24 25 | :reset-exp 1}} 26 | -------------------------------------------------------------------------------- /src/zanmi/boundary/logger.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.boundary.logger) 2 | 3 | (defprotocol Logger 4 | "Log messages" 5 | (log [logger level throwable message] 6 | "Log `message` to `logger` along with the trace from `throwable`")) 7 | 8 | (defn trace [logger message] 9 | (log logger :trace nil message)) 10 | 11 | (defn debug [logger message] 12 | (log logger :debug nil message)) 13 | 14 | (defn info [logger message] 15 | (log logger :info nil message)) 16 | 17 | (defn warn [logger message] 18 | (log logger :warn nil message)) 19 | 20 | (defn error 21 | ([logger message] 22 | (log logger :error nil message)) 23 | ([logger throwable message] 24 | (log logger :error throwable message))) 25 | 26 | (defn fatal 27 | ([logger message] 28 | (log logger :fatal nil message)) 29 | ([logger throwable message] 30 | (log logger :fatal throwable message))) 31 | 32 | (defn report [logger message] 33 | (log logger :report nil message)) 34 | -------------------------------------------------------------------------------- /src/zanmi/boundary/signer.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.boundary.signer 2 | (:require [zanmi.util.time :as time])) 3 | 4 | (defprotocol Signer 5 | "Sign data" 6 | (sign [signer data]) 7 | (unsign [signer signed-data])) 8 | 9 | (defn auth-token [signer profile] 10 | (let [now (time/now) 11 | exp (time/in-hours (:auth-exp signer))] 12 | (-> profile 13 | (select-keys [:id :username :modified]) 14 | (assoc :sub "authenticate", :iat now, :exp exp) 15 | (as-> data (sign signer data))))) 16 | 17 | (defn reset-token [signer profile] 18 | (let [now (time/now) 19 | exp (time/in-hours (:reset-exp signer))] 20 | (-> profile 21 | (select-keys [:id :username]) 22 | (assoc :sub "reset", :iat now, :exp exp) 23 | (as-> data (sign signer data))))) 24 | 25 | (defn parse-reset-token [signer token] 26 | (let [payload (unsign signer token)] 27 | (when (= (:sub payload) "reset") 28 | payload))) 29 | -------------------------------------------------------------------------------- /src/zanmi/component/signer/asymetric.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.signer.asymetric 2 | (:require [zanmi.boundary.signer :as signer] 3 | [buddy.core.keys :as keys] 4 | [buddy.sign.jwt :as jwt])) 5 | 6 | (defrecord AsymetricSigner [alg public-key private-key] 7 | signer/Signer 8 | (sign [signer data] 9 | (jwt/sign data private-key {:alg alg})) 10 | 11 | (unsign [signer signed-data] 12 | (jwt/unsign signed-data public-key {:alg alg}))) 13 | 14 | (defn- asymetric-signer [{:keys [alg keypair] :as config}] 15 | (let [pubkey (keys/public-key (:public keypair)) 16 | privkey (keys/private-key (:private keypair))] 17 | (-> config 18 | (dissoc :keypair) 19 | (assoc :alg alg, :public-key pubkey, :private-key privkey) 20 | (map->AsymetricSigner)))) 21 | 22 | (defn ecdsa-signer [config] 23 | (asymetric-signer config)) 24 | 25 | (defn rsa-pss-signer [config] 26 | (asymetric-signer config)) 27 | -------------------------------------------------------------------------------- /config.edn.example: -------------------------------------------------------------------------------- 1 | {;; random string used to sign requests for reset tokens 2 | :api-key "123-456-789-abc-def-ghi" 3 | 4 | ;; cors settings 5 | :allowed-origins ["https://example1.com", "https://example2.com"] 6 | 7 | ;; database credentials 8 | :db {:engine :postgres 9 | :username "zanmi" 10 | :password "zanmi-password" 11 | :host "localhost" 12 | :db-name "zanmi_dev"} 13 | 14 | ;; web server port 15 | :http {:port 8686} 16 | 17 | ;; log output config 18 | :logger {:level :info 19 | :path "/var/log/zanmi.log" 20 | :pattern :daily} 21 | 22 | ;; profile validations 23 | :profile-schema {:username-length 32 24 | :password-length 64 25 | :password-score 3} 26 | 27 | ;; configuration for signing auth tokens 28 | :signer {:alg :ps512 29 | :keypair {:public "/home/zanmi/keypair/pub.pem" 30 | :private "/home/zanmi/keypair/priv.pem"} 31 | :auth-exp 24 32 | :reset-exp 1}} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 ben lamothe 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /dev/dev.clj: -------------------------------------------------------------------------------- 1 | (ns dev 2 | (:refer-clojure :exclude [test]) 3 | (:require [zanmi.config :as config] 4 | [zanmi.system :as system] 5 | [clojure.repl :refer :all] 6 | [clojure.pprint :refer [pprint]] 7 | [clojure.tools.namespace.repl :refer [refresh]] 8 | [clojure.java.io :as io] 9 | [com.stuartsierra.component :as component] 10 | [duct.generate :as gen] 11 | [meta-merge.core :refer [meta-merge]] 12 | [reloaded.repl :refer [system init start stop go reset]] 13 | [ring.middleware.stacktrace :refer [wrap-stacktrace]] 14 | [dev.tasks :refer :all])) 15 | 16 | (def dev-config {:app {:allowed-origins ["http://localhost:3000"] 17 | :middleware [wrap-stacktrace]}}) 18 | 19 | (def config 20 | (meta-merge config/defaults 21 | config/file 22 | config/environ 23 | dev-config)) 24 | 25 | (defn zanmi [] 26 | (into (system/zanmi config) 27 | {})) 28 | 29 | (when (io/resource "dev/local.clj") 30 | (load "dev/local")) 31 | 32 | (gen/set-ns-prefix 'zanmi) 33 | 34 | (reloaded.repl/set-init! zanmi) 35 | -------------------------------------------------------------------------------- /src/zanmi/component/immutant.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.immutant 2 | (:require [com.stuartsierra.component :as component] 3 | [immutant.web :as web])) 4 | 5 | (defrecord ImmutantWeb [] 6 | component/Lifecycle 7 | (start [immutant] 8 | (if-not (:server immutant) 9 | (let [host (or (:host immutant) 10 | "0.0.0.0") 11 | port (:port immutant) 12 | config {:host host :port port} 13 | handler (:app immutant) 14 | server (do (-> (str "Starting web server. Listening on host: %s " 15 | "and port: %d") 16 | (format host port) 17 | (println)) 18 | (web/run (:handler handler) config))] 19 | (assoc immutant 20 | :server server 21 | :host host)) 22 | immutant)) 23 | 24 | (stop [immutant] 25 | (if-let [server (:server immutant)] 26 | (do (-> (str "Stopping web server on host: %s and port: %d") 27 | (format (:host immutant) (:port immutant)) 28 | (println)) 29 | (web/stop server) 30 | (dissoc immutant :server)) 31 | immutant))) 32 | 33 | (defn immutant-web-server [config] 34 | (map->ImmutantWeb config)) 35 | -------------------------------------------------------------------------------- /test/zanmi/boundary/database_test.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.boundary.database-test 2 | (require [zanmi.boundary.database :as db] 3 | [zanmi.util.time :as time] 4 | [clojure.test :refer :all] 5 | [shrubbery.core :as shrubbery :refer [mock received?]])) 6 | 7 | (let [now (time/now) 8 | profile {:id (java.util.UUID/randomUUID), :username "tester", 9 | :hashed-password "corned beef", :created now, :modified now}] 10 | 11 | (deftest save!-test 12 | (let [test-db (mock db/Database {:create! profile})] 13 | (testing "save!" 14 | (is (not (received? test-db db/create!)) 15 | "starts with a clean db mock") 16 | 17 | (is (= (db/save! test-db {:ok profile}) 18 | {:ok profile}) 19 | "saves to the database") 20 | 21 | (is (received? test-db db/create!) 22 | "calls db/create!")))) 23 | 24 | (deftest set!-test 25 | (let [test-db (mock db/Database {:update! profile})] 26 | (testing "set!" 27 | (is (not (received? test-db db/update!)) 28 | "starts with a clean db mock") 29 | 30 | (is (= (db/set! test-db "tester" {:ok profile}) 31 | {:ok profile}) 32 | "sets attributes in the database") 33 | 34 | (is (received? test-db db/update!) 35 | "calls db/update!"))))) 36 | -------------------------------------------------------------------------------- /src/zanmi/boundary/database.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.boundary.database 2 | (require [zanmi.util.time :as time] 3 | [clojure.core.match :refer [match]])) 4 | 5 | (defprotocol Database 6 | "Interact with a database" 7 | (initialize! [db] 8 | "create the database specified by `db`") 9 | (destroy! [db] 10 | "drop the database specified by `db`") 11 | (create! [db attrs] 12 | "save the new profile with `attrs` in `db`") 13 | (fetch [db username] 14 | "fetch the profile for `username` from `db`") 15 | (update! [db username attrs] 16 | "update the saved profile for `username` with `attrs` in `db`") 17 | (delete! [db username] 18 | "remove the profile for `username` from `db`")) 19 | 20 | (defn save! [db validated] 21 | (match validated 22 | {:ok new-profile} (try (let [now (time/now)] 23 | {:ok (-> new-profile 24 | (assoc :created now, :modified now) 25 | (as-> created (create! db created)))}) 26 | (catch Exception e {:error (.getMessage e)})) 27 | :else validated)) 28 | 29 | (defn set! [db username validated] 30 | (match validated 31 | {:ok attrs} (try (let [now (time/now)] 32 | {:ok (-> attrs 33 | (assoc :modified now) 34 | (as-> updated (update! db username updated)))}) 35 | (catch Exception e {:error (.getMessage e)})) 36 | :else validated)) 37 | -------------------------------------------------------------------------------- /src/zanmi/main.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.main 2 | (:gen-class) 3 | (:require [zanmi.boundary.database :as db] 4 | [zanmi.config :as config] 5 | [zanmi.system :refer [zanmi]] 6 | [com.stuartsierra.component :as component] 7 | [duct.middleware.errors :refer [wrap-hide-errors]] 8 | [duct.util.runtime :refer [add-shutdown-hook]] 9 | [ring.middleware.ssl :refer [wrap-hsts wrap-ssl-redirect]] 10 | [meta-merge.core :refer [meta-merge]])) 11 | 12 | (def ssl-config 13 | {:app {:middleware [[wrap-hsts wrap-ssl-redirect]]}}) 14 | 15 | (def prod-config 16 | {:app {:middleware [[wrap-hide-errors :internal-error]] 17 | 18 | :internal-error "Internal Server Error"}}) 19 | 20 | (defn- config-map [& args] 21 | (meta-merge config/defaults 22 | config/file 23 | config/environ 24 | (when-not (:skip-ssl args) ssl-config) 25 | prod-config)) 26 | 27 | (defn- parse-command-line-args [args] 28 | (into {} (map (fn [arg] (cond 29 | (= arg "--skip-ssl") {:skip-ssl true} 30 | (= arg "--init-db") {:init-db true})) 31 | args))) 32 | 33 | (defn -main [& args] 34 | (let [cli (parse-command-line-args args) 35 | config (config-map cli) 36 | system (zanmi config)] 37 | (if (:init-db cli) 38 | (do (println "Inizializing zanmi database") 39 | (db/initialize! (:db system))) 40 | (do (println "Starting zanmi http server on port" (-> system :http :port)) 41 | (add-shutdown-hook ::stop-system #(component/stop system)) 42 | (component/start system))))) 43 | -------------------------------------------------------------------------------- /src/zanmi/config.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.config 2 | (:require [clojure.edn :as edn] 3 | [clojure.java.io :as io] 4 | [environ.core :refer [env]] 5 | [meta-merge.core :refer [meta-merge]]) 6 | (:import java.io.PushbackReader)) 7 | 8 | (def defaults 9 | ^:displace {:http {:port 8686}}) 10 | 11 | (def environ 12 | {:api-key (:api-key env) 13 | 14 | :app {:allowed-origins (:allowed-origins env)} 15 | 16 | :db {:engine (some-> env :db-engine symbol) 17 | :username (:db-username env) 18 | :password (:db-password env) 19 | :host (:db-host env) 20 | :db-name (:db-name env)} 21 | 22 | :http {:port (or (some-> env :port Integer.) 23 | 8686)} 24 | 25 | :logger {:level (some-> env :log-level symbol) 26 | :path (:log-path env) 27 | :pattern (some-> env :log-pattern symbol)} 28 | 29 | :profile-schema {:username-length (some-> env :username-length Integer.) 30 | :password-length (some-> env :password-length Integer.) 31 | :password-score (some-> env :password-score Integer.)} 32 | 33 | :signer {:alg (some-> env :sign-algorithm symbol) 34 | :secret (:sign-secret env) 35 | :keypair {:public (:sign-public-key env) 36 | :private (:sign-private-key env)} 37 | :auth-exp (some-> env :auth-expiration Integer.) 38 | :reset-exp (some-> env :reset-expiration Integer.)}}) 39 | 40 | (def file 41 | (when-let [path (:zanmi-config env)] 42 | (let [file-config (with-open [reader (-> path io/reader PushbackReader.)] 43 | (edn/read reader)) 44 | allowed-origins (:allowed-origins file-config)] 45 | (-> file-config 46 | (dissoc :allowed-origins) 47 | (assoc :app {:allowed-origins allowed-origins}))))) 48 | -------------------------------------------------------------------------------- /test/zanmi/view/profile_view_test.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.view.profile-view-test 2 | (:require [zanmi.boundary.signer :as signer] 3 | [zanmi.component.signer.sha :refer [sha-signer]] 4 | [zanmi.data.profile :refer [create]] 5 | [zanmi.test-config :refer [config]] 6 | [zanmi.util.time :as time] 7 | [zanmi.view.profile-view :refer :all] 8 | [clojure.test :refer :all])) 9 | 10 | (deftest render-auth-token-test 11 | (testing "render-auth-token" 12 | (let [now (time/now) 13 | profile {:username "tester", :hashed-password "a long hash", 14 | :id (java.util.UUID/randomUUID), :created now, :modified now} 15 | secret "nobody knows this!" 16 | signer (sha-signer {:secret secret, :size 256, :auth-exp 1, 17 | :reset-exp 1}) 18 | subject (:auth-token (render-auth-token profile signer))] 19 | (is (not (nil? subject)) 20 | "renders the token") 21 | 22 | (testing "signs the data" 23 | (let [unsigned (signer/unsign signer subject)] 24 | (is (not (nil? (:username unsigned))) 25 | "includes the username") 26 | 27 | (is (not (nil? (:id unsigned))) 28 | "includes the id") 29 | 30 | (is (not (nil? (:modified unsigned))) 31 | "includes the modified time") 32 | 33 | (is (nil? (:hashed-password unsigned)) 34 | "does not include the hashed password")))))) 35 | 36 | (deftest render-message-test 37 | (testing "render-message" 38 | (let [msg "important message" 39 | subject (render-message msg)] 40 | (is (= (:message subject) msg) 41 | "renders the message")))) 42 | 43 | (deftest render-error-test 44 | (testing "render-error" 45 | (let [e "bad news" 46 | subject (render-error e)] 47 | (is (= (:error subject) e) 48 | "renders the error")))) 49 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject zanmi "0.1.0-alpha1-SNAPSHOT" 2 | :description "Identity http service" 3 | 4 | :url "https://github.com/zonotope/zanmi" 5 | 6 | :min-lein-version "2.0.0" 7 | 8 | :dependencies [[org.clojure/clojure "1.9.0-alpha10"] 9 | [org.clojure/core.match "0.3.0-alpha4"] 10 | [org.clojure/data.codec "0.1.0"] 11 | 12 | [com.stuartsierra/component "0.3.1"] 13 | [compojure "1.5.2"] 14 | [duct "0.7.0"] 15 | 16 | [buddy/buddy-auth "1.3.0"] 17 | [buddy/buddy-hashers "1.2.0"] 18 | [buddy/buddy-sign "1.3.0"] 19 | [zxcvbn-clj "0.1.0-alpha0"] 20 | 21 | [funcool/clojure.jdbc "0.9.0"] 22 | [hikari-cp "1.7.3"] 23 | [honeysql "0.8.0"] 24 | [org.postgresql/postgresql "9.4.1208"] 25 | 26 | [com.novemberain/monger "3.0.2"] 27 | 28 | [org.immutant/web "2.1.6"] 29 | [ring "1.4.0"] 30 | [ring/ring-defaults "0.2.0"] 31 | [ring/ring-ssl "0.2.1"] 32 | [ring-cors "0.1.9"] 33 | [ring-logger "0.7.6"] 34 | [ring-middleware-format "0.7.0"] 35 | 36 | [bouncer "1.0.0"] 37 | [camel-snake-kebab "0.4.0"] 38 | [clj-time "0.12.0"] 39 | [environ "1.1.0"] 40 | [meta-merge "0.1.1"] 41 | [com.taoensso/timbre "4.7.4"]] 42 | 43 | :plugins [[lein-environ "1.0.3"]] 44 | 45 | :main ^:skip-aot zanmi.main 46 | 47 | :target-path "target/%s/" 48 | 49 | :aliases {"run-task" ["with-profile" "+repl" "run" "-m"] 50 | "setup" ["run-task" "dev.tasks/setup"]} 51 | 52 | :profiles 53 | {:dev [:project/dev :profiles/dev] 54 | 55 | :test [:project/test :profiles/test] 56 | 57 | :uberjar {:aot :all} 58 | 59 | :profiles/dev {} 60 | 61 | :profiles/test {} 62 | 63 | :project/dev {:dependencies [[com.gearswithingears/shrubbery "0.4.1"] 64 | [duct/generate "0.7.0"] 65 | [eftest "0.1.1"] 66 | [org.clojure/test.check "0.9.0"] 67 | [org.clojure/tools.namespace "0.2.11"] 68 | [org.clojure/tools.nrepl "0.2.12"] 69 | [kerodon "0.7.0"] 70 | [reloaded.repl "0.2.2"]] 71 | 72 | :source-paths ["dev"] 73 | 74 | :repl-options {:init-ns user} 75 | 76 | :env {:zanmi-config "dev/resources/zanmi/config.edn"}} 77 | 78 | :project/test {}}) 79 | -------------------------------------------------------------------------------- /test/zanmi/data/profile_test.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.data.profile-test 2 | (:require [zanmi.data.profile :refer :all] 3 | [zanmi.test-config :refer [config]] 4 | [clojure.test :refer :all])) 5 | 6 | (let [schema (profile-schema (:profile-schema config))] 7 | 8 | (deftest test-authenticate 9 | (testing "authenticate" 10 | (let [profile (:ok (create schema {:username "tester", 11 | :password "correct"}))] 12 | (testing "with the correct password" 13 | (is (= (authenticate profile "correct") 14 | profile) 15 | "returns the profile")) 16 | 17 | (testing "with the wrong password" 18 | (is (nil? (authenticate profile "wrong")) 19 | "returns nil"))))) 20 | 21 | (deftest test-create 22 | (testing "create" 23 | (testing "with valid attributes" 24 | (let [profile (:ok (create schema {:username "tester" 25 | :password "this is only a test"}))] 26 | (is (not (nil? profile)) 27 | "returns the profile") 28 | 29 | (testing "id" 30 | (let [id (:id profile)] 31 | (is (not (nil? id)) 32 | "is included") 33 | 34 | (is (uuid? id) 35 | "is a uuid"))) 36 | 37 | (is (nil? (:password profile)) 38 | "doesn't include the raw password") 39 | 40 | (is (not (nil? :hashed-password)) 41 | "includes the hashed password"))) 42 | 43 | (testing "with invalid attributes" 44 | (let [{profile :ok error :error} (create schema {:username "tester" 45 | :password "p4$$w0rd"})] 46 | (is (not (nil? error)) 47 | "returns an error") 48 | 49 | (is (nil? profile) 50 | "does not return the profile"))))) 51 | 52 | (deftest test-update 53 | (testing "update" 54 | (let [profile (:ok (create schema {:username "tester" 55 | :password "this is only a test"}))] 56 | (testing "with a valid new password" 57 | (let [updated (:ok (update schema profile "this really is a test"))] 58 | (is (not (nil? updated)) 59 | "returns the profile") 60 | 61 | (is (nil? (:username updated)) 62 | "doesn't include the username") 63 | 64 | (is (not (= (:hashed-password profile) (:hashed-pasword updated))) 65 | "includes a different hashed password"))) 66 | 67 | (testing "with an invalid new password" 68 | (let [error (:error (update schema profile "p4$$w0rd"))] 69 | (is (not (nil? error)) 70 | "returns an error"))))))) 71 | -------------------------------------------------------------------------------- /src/zanmi/middleware/authentication.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.middleware.authentication 2 | (:require [zanmi.boundary.database :as db] 3 | [zanmi.boundary.signer :as signer] 4 | [zanmi.data.profile :as profile] 5 | [buddy.core.codecs :refer [bytes->str]] 6 | [buddy.core.codecs.base64 :as base64] 7 | [clojure.string :as string])) 8 | 9 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 10 | ;; header parsing ;; 11 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 12 | 13 | (defn base64-decode [string] 14 | (-> (base64/decode string) 15 | (bytes->str))) 16 | 17 | (defn- parse-authorization [req scheme] 18 | (let [scheme-pattern (re-pattern (str "^" scheme " (.*)$"))] 19 | (some-> (:headers req) 20 | (get "authorization") 21 | (as-> header (re-find scheme-pattern header)) 22 | (second)))) 23 | 24 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 25 | ;; application authentication ;; 26 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 27 | 28 | (defn parse-app-claims [req app-validater] 29 | (some->> (parse-authorization req "ZanmiAppToken") 30 | (signer/unsign app-validater))) 31 | 32 | (defn wrap-app-claims [handler app-validater] 33 | (fn [req] 34 | (let [claims (parse-app-claims req app-validater)] 35 | (handler (assoc req :app-claims claims))))) 36 | 37 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 38 | ;; user credential authentication ;; 39 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 40 | 41 | (defn- parse-credentials [req db] 42 | (some-> (parse-authorization req "Basic") 43 | (base64-decode) 44 | (string/split #":" 2) 45 | (as-> creds (zipmap [:username :password] creds)))) 46 | 47 | (defn- authenticate-credentials [{:keys [username password] :as creds} db] 48 | (when (and username password) 49 | (some-> (db/fetch db username) 50 | (profile/authenticate password)))) 51 | 52 | (defn wrap-user-credentials [handler db] 53 | (fn [req] 54 | (let [creds (parse-credentials req db) 55 | authenticated (authenticate-credentials creds db)] 56 | (handler (assoc req :user-profile authenticated))))) 57 | 58 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 59 | ;; user reset token authentication ;; 60 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 61 | 62 | (defn- parse-reset-claims [req signer] 63 | (some->> (parse-authorization req "ZanmiResetToken") 64 | (signer/parse-reset-token signer))) 65 | 66 | (defn wrap-reset-claims [handler signer] 67 | (fn [req] 68 | (let [claims (parse-reset-claims req signer)] 69 | (handler (assoc req :reset-claims claims))))) 70 | -------------------------------------------------------------------------------- /src/zanmi/system.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.system 2 | (:require [zanmi.component.database :refer [database]] 3 | [zanmi.component.immutant :refer [immutant-web-server]] 4 | [zanmi.component.signer :refer [signer]] 5 | [zanmi.component.timbre :refer [timbre]] 6 | [zanmi.component.signer.sha :refer [sha-signer]] 7 | [zanmi.data.profile :refer [profile-schema]] 8 | [zanmi.endpoint.profile-endpoint :refer [profile-routes]] 9 | [zanmi.middleware.authentication :refer [wrap-user-credentials 10 | wrap-app-claims 11 | wrap-reset-claims]] 12 | [zanmi.middleware.cors :refer [wrap-cors]] 13 | [zanmi.middleware.format :refer [wrap-format]] 14 | [zanmi.middleware.logger :refer [wrap-logger]] 15 | [com.stuartsierra.component :as component] 16 | [duct.component.endpoint :refer [endpoint-component]] 17 | [duct.component.handler :refer [handler-component]] 18 | [duct.middleware.not-found :refer [wrap-not-found]] 19 | [meta-merge.core :refer [meta-merge]] 20 | [ring.middleware.defaults :refer [wrap-defaults api-defaults]])) 21 | 22 | (def base-config 23 | {:app {:middleware [[wrap-defaults :defaults] 24 | [wrap-cors :allowed-origins] 25 | [wrap-user-credentials :db] 26 | [wrap-app-claims :app-validater] 27 | [wrap-reset-claims :signer] 28 | [wrap-not-found :not-found] 29 | [wrap-format :formats] 30 | [wrap-logger :logger]] 31 | 32 | :defaults (meta-merge api-defaults 33 | {:params {:keywordize true 34 | :nested true} 35 | :responses {:absolute-redirects true 36 | :not-modified-responses true}}) 37 | 38 | :formats [:json :transit-json] 39 | 40 | :not-found "Resource Not Found"}}) 41 | 42 | (defn zanmi [config] 43 | (let [config (meta-merge base-config config)] 44 | (-> (component/system-map 45 | :app-validater (sha-signer {:secret (:api-key config) 46 | :size 512}) 47 | :app (handler-component (:app config)) 48 | :db (database (:db config)) 49 | :http (immutant-web-server (:http config)) 50 | :logger (timbre (:logger config)) 51 | :profile-endpoint (endpoint-component profile-routes) 52 | :profile-schema (profile-schema (:profile-schema config)) 53 | :signer (signer (:signer config))) 54 | 55 | (component/system-using 56 | {:app [:app-validater :db :logger :profile-endpoint 57 | :signer] 58 | :http [:app] 59 | :profile-endpoint [:app-validater :db :logger :profile-schema 60 | :signer]})))) 61 | -------------------------------------------------------------------------------- /src/zanmi/component/database/mongo.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.database.mongo 2 | (:require [zanmi.boundary.database :as database] 3 | [camel-snake-kebab.core :refer [->camelCaseKeyword 4 | ->kebab-case-keyword 5 | ->snake_case_keyword]] 6 | [com.stuartsierra.component :as component] 7 | [monger.core :as mongo] 8 | [monger.collection :as collection] 9 | [monger.credentials :as credentials] 10 | [monger.operators :refer [$set]])) 11 | 12 | (def ^:private collection "profiles") 13 | 14 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 15 | ;; key sanitization ;; 16 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 17 | 18 | (defn- transform-keys 19 | ([m f] 20 | (transform-keys m f {})) 21 | ([m f overrides] 22 | (into {} (for [[k v] m] 23 | (let [new-key (if-let [override (k overrides)] override (f k))] 24 | [new-key v]))))) 25 | 26 | (defn- map->doc [m] 27 | (transform-keys m ->snake_case_keyword {:id :_id})) 28 | 29 | (defn- doc->map [d] 30 | (transform-keys d ->kebab-case-keyword)) 31 | 32 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 33 | ;; component ;; 34 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 35 | 36 | (defrecord Mongo [] 37 | component/Lifecycle 38 | (start [mongo] 39 | (if (:connection mongo) 40 | mongo 41 | (let [{:keys [db-name host password username]} mongo 42 | cred (credentials/create username db-name password) 43 | conn (mongo/connect-with-credentials host cred) 44 | db (mongo/get-db conn db-name)] 45 | (assoc mongo 46 | :connection conn 47 | :database db)))) 48 | 49 | (stop [mongo] 50 | (if-let [conn (:connection mongo)] 51 | (do (mongo/disconnect conn) 52 | (dissoc mongo :connection :database)) 53 | mongo)) 54 | 55 | database/Database 56 | (initialize! [{db :database}] 57 | (collection/create db collection {:capped false}) 58 | (collection/ensure-index db collection {:username 1} {:unique true})) 59 | 60 | (destroy! [{db :database}] 61 | (mongo/command db {:dropDatabase 1})) 62 | 63 | (fetch [{db :database} username] 64 | (let [attr {:username username}] 65 | (doc->map (collection/find-one-as-map db collection attr)))) 66 | 67 | (create! [{db :database} attrs] 68 | (let [attrs-doc (map->doc attrs)] 69 | (doc->map (collection/insert-and-return db collection attrs-doc)))) 70 | 71 | (update! [{db :database} username attrs] 72 | (let [attr-doc (map->doc attrs) 73 | query (map->doc {:username username})] 74 | (doc->map (collection/find-and-modify db collection 75 | {:username username} 76 | {$set attr-doc} 77 | {:return-new true})))) 78 | 79 | (delete! [{db :database} username] 80 | (collection/remove db collection {:username username}))) 81 | 82 | (defn mongo [config] 83 | (map->Mongo config)) 84 | -------------------------------------------------------------------------------- /src/zanmi/data/profile.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.data.profile 2 | (:require [zanmi.util.validation :refer [when-valid]] 3 | [bouncer.validators :refer [defvalidator max-count required string]] 4 | [buddy.hashers :as hash] 5 | [clojure.string :as string] 6 | [zxcvbn.core :as zxcvbn])) 7 | 8 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 9 | ;; attribute sanitzation ;; 10 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 11 | 12 | (defn- with-id [{:keys [username] :as attrs}] 13 | (let [id (java.util.UUID/randomUUID)] 14 | (assoc attrs :id id))) 15 | 16 | (defn- hash-password [{:keys [password] :as attrs}] 17 | (-> attrs 18 | (dissoc :password) 19 | (assoc :hashed-password (hash/derive password)))) 20 | 21 | (defn- reset-password [profile new-password] 22 | (-> profile 23 | (dissoc :hashed-password) 24 | (assoc :password new-password))) 25 | 26 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 27 | ;; profile processing ;; 28 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 29 | 30 | (defn authenticate [{:keys [hashed-password] :as profile} password] 31 | (when (hash/check password hashed-password) 32 | profile)) 33 | 34 | (defn create [schema attrs] 35 | (let [create-attrs (-> attrs 36 | (select-keys [:username :password]) 37 | (with-id))] 38 | (when-valid create-attrs schema 39 | (fn [valid-attrs] (hash-password valid-attrs))))) 40 | 41 | (defn update [schema profile new-password] 42 | (let [update-attrs (reset-password profile new-password)] 43 | (when-valid update-attrs schema 44 | (fn [valid-attrs] (-> (hash-password valid-attrs) 45 | (select-keys [:hashed-password])))))) 46 | 47 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 48 | ;; profile validation ;; 49 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 50 | 51 | (defvalidator no-colon {:default-message-format "%s can't have a ':'"} 52 | [username] 53 | (not (string/includes? username ":"))) 54 | 55 | (defn- password-error-message [path value] 56 | (let [{:keys [suggestions warning]} (:feedback (zxcvbn/check value)) 57 | path-name (name (peek path))] 58 | (str "The " path-name " is too weak. " 59 | warning " " (string/join " " suggestions)))) 60 | 61 | (defvalidator min-password-score {:message-fn password-error-message} 62 | [password strength] 63 | (>= (:score (zxcvbn/check password)) 64 | strength)) 65 | 66 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 67 | ;; data schema ;; 68 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 69 | 70 | (defn profile-schema [{:keys [username-length password-length password-score]}] 71 | {:username [required 72 | string 73 | no-colon 74 | [max-count username-length]] 75 | 76 | :password [required 77 | string 78 | [max-count password-length] 79 | [min-password-score password-score]]}) 80 | -------------------------------------------------------------------------------- /src/zanmi/endpoint/profile_endpoint.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.endpoint.profile-endpoint 2 | (:require [zanmi.boundary.database :as db] 3 | [zanmi.boundary.signer :as signer] 4 | [zanmi.data.profile :refer [create update]] 5 | [zanmi.view.profile-view :refer [render-error render-message 6 | render-auth-token 7 | render-reset-token]] 8 | [clojure.core.match :refer [match]] 9 | [compojure.core :refer [context DELETE POST PUT]] 10 | [ring.util.response :as response :refer [response]])) 11 | 12 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 13 | ;; url ;; 14 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 15 | 16 | (def ^:private route-prefix "/profiles") 17 | 18 | (defn- profile-url [{username :username :as profile}] 19 | (str route-prefix "/" username)) 20 | 21 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 22 | ;; responses ;; 23 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 24 | 25 | (defn- created [profile signer] 26 | (response/created (profile-url profile) 27 | (render-auth-token profile signer))) 28 | 29 | (defn- deleted [username] 30 | (let [deleted-message (format "profile for '%s' deleted" username)] 31 | (response (render-message deleted-message)))) 32 | 33 | (defn- error [e status] 34 | (-> (response (render-error e)) 35 | (assoc :status status))) 36 | 37 | (defn- ok [profile signer] 38 | (response (render-auth-token profile signer))) 39 | 40 | (defn- reset [profile signer] 41 | (response (render-reset-token profile signer))) 42 | 43 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 44 | ;; actions ;; 45 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 46 | 47 | (defn- create-profile [attrs & {:keys [db schema signer]}] 48 | (-> (create schema attrs) 49 | (as-> validated (db/save! db validated)) 50 | (match {:ok new-profile} (created new-profile signer) 51 | {:error messages} (error messages 409)))) 52 | 53 | (defn- update-password [profile new-password & {:keys [db schema signer]}] 54 | (let [username (:username profile)] 55 | (-> (update schema profile new-password) 56 | (as-> validated (db/set! db username validated)) 57 | (match {:ok new-profile} (ok new-profile signer) 58 | {:error messages} (error messages 400))))) 59 | 60 | (defn- delete-profile [{:keys [username] :as profile} & {db :db}] 61 | (when (db/delete! db username) 62 | (deleted username))) 63 | 64 | (defn- show-auth-token [profile & {:keys [signer]}] 65 | (ok profile signer)) 66 | 67 | (defn- show-reset-token [profile & {:keys [signer]}] 68 | (reset profile signer)) 69 | 70 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 71 | ;; request authorization ;; 72 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 73 | 74 | (defn- authorize [credentials username & {:keys [action unauth-message]}] 75 | (if credentials 76 | (if (= (:username credentials) username) 77 | (action credentials) 78 | (error "unauthorized" 409)) 79 | (error unauth-message 401))) 80 | 81 | (defn- authorize-profile [profile username action] 82 | (authorize profile username 83 | :action action, :unauth-message "bad username or password")) 84 | 85 | (defn- authorize-reset [reset-claims username action] 86 | (authorize reset-claims username 87 | :action action, :unauth-message "invalid reset token")) 88 | 89 | (defn- authorize-app [app-claims username action] 90 | (authorize app-claims username 91 | :action action, :unauth-message "invalid app token")) 92 | 93 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 94 | ;; routes ;; 95 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 96 | 97 | (defn profile-routes [{:keys [db profile-schema signer] :as endpoint}] 98 | (context route-prefix [] 99 | (POST "/" [profile] 100 | (create-profile profile :db db, :schema profile-schema, :signer signer)) 101 | 102 | (context "/:username" [username :as {:keys [app-claims reset-claims 103 | user-profile]}] 104 | (PUT "/" [profile] 105 | (let [{:keys [password]} profile] 106 | (if reset-claims 107 | (authorize-reset reset-claims username 108 | (fn [{:keys [username] :as claims}] 109 | (let [profile (db/fetch db username)] 110 | (update-password profile password 111 | :db db, :schema profile-schema 112 | :signer signer)))) 113 | (authorize-profile user-profile username 114 | (fn [profile] 115 | (update-password profile password 116 | :db db, :schema profile-schema 117 | :signer signer)))))) 118 | 119 | (DELETE "/" [] 120 | (authorize-profile user-profile username 121 | (fn [profile] 122 | (delete-profile profile :db db)))) 123 | 124 | (POST "/auth" [] 125 | (authorize-profile user-profile username 126 | (fn [profile] 127 | (show-auth-token profile :signer signer)))) 128 | 129 | (POST "/reset" [] 130 | (authorize-app app-claims username 131 | (fn [{:keys [username] :as claims}] 132 | (let [profile (db/fetch db username)] 133 | (show-reset-token profile :signer signer)))))))) 134 | -------------------------------------------------------------------------------- /src/zanmi/component/database/postgres.clj: -------------------------------------------------------------------------------- 1 | (ns zanmi.component.database.postgres 2 | (:require [zanmi.boundary.database :as database] 3 | [zanmi.config :as config] 4 | [camel-snake-kebab.core :refer [->kebab-case-keyword]] 5 | [clojure.string :refer [join]] 6 | [clojure.set :refer [rename-keys]] 7 | [com.stuartsierra.component :as component] 8 | [hikari-cp.core :refer [make-datasource close-datasource]] 9 | [honeysql.core :as sql] 10 | [honeysql.format :as sql.fmt] 11 | [honeysql.helpers :as sql-helper :refer [defhelper delete-from 12 | from insert-into select 13 | sset update values where]] 14 | [jdbc.core :as jdbc] 15 | [jdbc.proto])) 16 | 17 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 18 | ;; db connection specs ;; 19 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 20 | 21 | (defn- pooled-spec [postgres] 22 | {:datasource (make-datasource postgres)}) 23 | 24 | (defn- connection-spec [{:keys [username password server-name 25 | database-name] 26 | :as db}] 27 | (let [subname (str "//" server-name "/" database-name)] 28 | {:subprotocol "postgresql" 29 | :subname subname 30 | :user username 31 | :password password})) 32 | 33 | (defn- postgres-db-spec [db] 34 | (connection-spec (assoc db :database-name "postgres"))) 35 | 36 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 37 | ;; ddl ;; 38 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 39 | 40 | (def ^:private table :profiles) 41 | 42 | (defn- create-database! [{:keys [database-name] :as db}] 43 | (with-open [conn (jdbc/connection (postgres-db-spec db))] 44 | (jdbc/execute conn (str "CREATE DATABASE " database-name)))) 45 | 46 | (defn- create-table! [db] 47 | (with-open [conn (jdbc/connection (connection-spec db))] 48 | (let [length 32] 49 | (jdbc/execute conn (str "CREATE TABLE " (name table) " (" 50 | " id UUID PRIMARY KEY NOT NULL," 51 | " username VARCHAR(" length ") NOT NULL UNIQUE," 52 | " hashed_password VARCHAR(128) NOT NULL," 53 | " created TIMESTAMP NOT NULL," 54 | " modified TIMESTAMP NOT NULL" 55 | ")"))))) 56 | 57 | (defn- drop-database! [{:keys [database-name] :as db}] 58 | (with-open [conn (jdbc/connection (postgres-db-spec db))] 59 | (jdbc/execute conn (str "DROP DATABASE " database-name)))) 60 | 61 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 62 | ;; sql time conversion ;; 63 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 64 | 65 | (extend-protocol jdbc.proto/ISQLType 66 | java.util.Date 67 | (as-sql-type [date conn] 68 | (java.sql.Timestamp. (.getTime date))) 69 | 70 | (set-stmt-parameter! [date conn stmt index] 71 | (.setObject stmt index (jdbc.proto/as-sql-type date conn)))) 72 | 73 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 74 | ;; querying ;; 75 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 76 | 77 | (defmethod sql.fmt/format-clause :returning [[_ fields] _] 78 | (str "RETURNING " (join ", " (map sql.fmt/to-sql fields)))) 79 | 80 | (defhelper returning [m args] 81 | (assoc m :returning args)) 82 | 83 | (defn- sanitize-keys [m] 84 | (let [keymap (into {} (map (fn [k] {k (->kebab-case-keyword k)}) 85 | (keys m)))] 86 | (rename-keys m keymap))) 87 | 88 | (defn- query-one [db-spec statement] 89 | (with-open [conn (jdbc/connection db-spec)] 90 | (->> statement 91 | (sql/format) 92 | (jdbc/fetch conn) 93 | (first) 94 | (sanitize-keys)))) 95 | 96 | (defn- execute [db-spec statement] 97 | (with-open [conn (jdbc/connection db-spec)] 98 | (->> statement 99 | (sql/format) 100 | (jdbc/execute conn)))) 101 | 102 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 103 | ;; component ;; 104 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 105 | 106 | (defrecord Postgres [] 107 | component/Lifecycle 108 | (start [postgres] 109 | (if (:spec postgres) 110 | postgres 111 | (assoc postgres :spec (pooled-spec postgres)))) 112 | 113 | (stop [postgres] 114 | (if-let [datasource (-> postgres :spec :datasource)] 115 | (do (close-datasource datasource) 116 | (dissoc postgres :spec)) 117 | postgres)) 118 | 119 | database/Database 120 | (initialize! [db] 121 | (create-database! db) 122 | (create-table! db)) 123 | 124 | (destroy! [db] 125 | (drop-database! db)) 126 | 127 | (fetch [{db-spec :spec} username] 128 | (query-one db-spec (-> (select :*) 129 | (from table) 130 | (where [:= :username username])))) 131 | 132 | (create! [{db-spec :spec} attrs] 133 | (query-one db-spec (-> (insert-into table) 134 | (values [attrs]) 135 | (returning :*)))) 136 | 137 | (update! [{db-spec :spec} username attrs] 138 | (query-one db-spec (-> (update table) 139 | (sset attrs) 140 | (where [:= :username username]) 141 | (returning :*)))) 142 | 143 | (delete! [{db-spec :spec} username] 144 | (execute db-spec (-> (delete-from table) 145 | (where [:= :username username]))))) 146 | 147 | (defn postgres [{:keys [db-name host] :as config}] 148 | (-> config 149 | (dissoc :engine :host :db-name) 150 | (assoc :adapter "postgresql" 151 | :server-name host 152 | :database-name db-name) 153 | (map->Postgres))) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zanmi [![Build Status](https://travis-ci.org/zonotope/zanmi.svg?branch=master)](https://travis-ci.org/zonotope/zanmi) 2 | 3 | An HTTP identity service based on JWT auth tokens, and built 4 | on [buddy](https://github.com/funcool/buddy). Authenticate users while managing 5 | their passwords and auth tokens independently of the apps or services they use. 6 | 7 | zanmi serves auth tokens in response to requests with the correct user 8 | credentials. It manages a self contained password database with configurable 9 | back ends (current support for PostgreSQL and MongoDB) and hashes passwords 10 | with [BCrypt + SHA512](https://en.wikipedia.org/wiki/Bcrypt) before they're 11 | stored. The signing algorithm zanmi signs it's auth tokens with is also 12 | configurable. [RSASSA-PSS](https://en.wikipedia.org/wiki/PKCS_1) is the default, 13 | but 14 | [ECDSA](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) and 15 | [SHA512 HMAC](https://en.wikipedia.org/wiki/SHA-2) are also supported. 16 | 17 | Other back-end services can then authenticate users by verifying these auth 18 | tokens with the zanmi keypair's public key. 19 | 20 | ## Project Maturity 21 | zanmi is still alpha software. There are probably bugs, and the api will most 22 | likely change. 23 | 24 | ## Usage 25 | zanmi is designed to be deployed with SSL/TLS in production. User passwords will 26 | be sent in the clear otherwise. 27 | 28 | zanmi is a way to share authentication across many independent services and 29 | front ends. it depends on a database back end and a key pair/secret to sign 30 | tokens. Both the database and the algorithm used to sign tokens is configurable. 31 | The supported databases are PostgreSQL (default) and MongoDB, and the supported 32 | token signing algorithms are RSASSA-PSS (default), ECDSA, and SHA512 HMAC. Both 33 | RSA-PSS and ECDSA require paths to both a public and private key file, and 34 | SHA512 HMAC needs a secret supplied in the server config. 35 | 36 | To try it out in development: 37 | 38 | * download the latest [release jar](https://github.com/zonotope/zanmi/releases/download/0.1.0-alpha0/zanmi-0.1.0-alpha0-standalone.jar) 39 | 40 | * generate an RSA keypair with [openssl](https://www.openssl.org/) by running 41 | the following in a terminal, where `` is some path of your 42 | choosing: 43 | 44 | ```sh 45 | mkdir -p 46 | openssl genrsa -out /priv.pem 2048 47 | openssl rsa -pubout -in /priv.pem -out /pub.pem 48 | ``` 49 | 50 | * run either a PostgreSQL or MongoDB server including a database user that can 51 | update and delete databases. 52 | 53 | * download and edit the 54 | [example config](https://github.com/zonotope/zanmi/blob/master/config.edn.example) 55 | by adding a random string as an api key and replacing the keypair paths and 56 | database credentials with your own. 57 | 58 | * initialize the database by running: 59 | 60 | ```sh 61 | ZANMI_CONFIG= java -jar --init-db 62 | ``` 63 | 64 | * zanmi was designed to be run with ssl, but we'll turn it off for now. Start 65 | the server by running: 66 | 67 | ```sh 68 | ZANMI_CONFIG= java -jar --skip-ssl 69 | ``` 70 | 71 | The server will be listening at `localhost:8686` (unless you changed the port in 72 | the config). zanmi speaks json by default, but can also use transit/json if you 73 | set the request's accept/content-type headers. 74 | 75 | ### Clients 76 | There is a [Clojure zanmi client](https://github.com/zonotope/zanmi-client), and 77 | since zanmi is just a plain http server, clients for other languages should be 78 | easy to write as long as those languages have good http and jwt libraries. 79 | 80 | We'll use [cURL](https://curl.haxx.se) to make requests to the running server to 81 | be as general as possible. Enter the following commands into a new terminal 82 | window. 83 | 84 | #### Registering User Profiles 85 | Send a `post` request to the profiles url with your credentials to register a 86 | new user: 87 | 88 | ```bash 89 | curl -XPOST --data "profile[username]=gwcarver&profile[password]=pulverized peanuts" localhost:8686/profiles/ 90 | ``` 91 | 92 | zanmi uses [zxcvbn-clj](https://github.com/zonotope/zxcvbn-clj) to validate 93 | password strength, so simple passwords like "p4ssw0rd" will fail validations and 94 | an error response will be returned. The server will respond with an auth token 95 | if the password is strong enough and the username isn't already taken. 96 | 97 | #### Authenticating Users 98 | Clients send user credentials with http basic auth to zanmi servers to 99 | authenticate against existing user profiles. To verify that you have the right 100 | password, send a `post` request to the user's profile auth url with the 101 | credentials formatted `"username:password"` after the `-u` command switch to 102 | cURL: 103 | 104 | ```bash 105 | curl -XPOST -u "gwcarver:pulverized peanuts" localhost:8686/profiles/gwcarver/auth 106 | ``` 107 | 108 | The server will respond with an auth token if the credentials are correct. 109 | 110 | #### Resetting Passwords 111 | 112 | ##### With the Current Password 113 | To reset the user's password, send a `put` request to the user's profile url 114 | with the existing credentials through basic auth and the new password in the 115 | request body: 116 | 117 | ```bash 118 | curl -XPUT -u "gwcarver:pulverized peanuts" --data "profile[password]=succulent sweet potatos" localhost:8686/profiles/gwcarver 119 | ``` 120 | 121 | The server will respond with a new auth token if your credentials are correct 122 | and the new password is strong enough according to zxcvbn 123 | 124 | ##### With a Reset Token 125 | zanmi also supports resetting user passwords if they've forgotten them. First, 126 | create a JWT of the hash `{"username" : }` sha512 signed with 127 | the api-key from the zanmi config and send a `post` request with that JWT as the 128 | `ZanmiAppToken` in the authorization header to get a reset token 129 | for that user: 130 | 131 | ```bash 132 | curl -XPOST -H "Authorization: ZanmiAppToken " localhost:8686/profiles/gwcarver/reset 133 | ``` 134 | 135 | Then send the same `put` request as resetting with the current password above, 136 | but change the authorization header value to `ZanmiResetToken `, 137 | where ``. 138 | 139 | ```bash 140 | curl -XPUT -H "Authorization: ZanmiResetToken " --data "profile[password]=succulent sweet potatos" localhost:8686/profiles/gwcarver 141 | ``` 142 | 143 | The [clojure zanmi client](https://github.com/zonotope/zanmi-client) builds the 144 | authorization JWT used to get the reset token automatically. 145 | 146 | In production, your back-end application should request the reset token and send 147 | that to the user's email, or some other trusted channel that will verify their 148 | identity. 149 | 150 | #### Removing User Profiles 151 | 152 | ##### With Valid Credentials 153 | To remove a user's profile from the database, send a delete request to the 154 | users's profile url with the right credentials: 155 | 156 | ```bash 157 | curl -XDELETE -u "gwcarver:succulent sweet potatos" localhost:8686/profiles/gwcarver 158 | ``` 159 | 160 | ## Deployment 161 | 162 | ### Configuration 163 | See the 164 | [example config](https://github.com/zonotope/zanmi/blob/master/config.edn.example) 165 | for configuration options. The easiest way to configure zanmi is to edit the 166 | example config file linked above and set the `ZANMI_CONFIG` environment variable 167 | when running the relase jar. You can also set the configuration from the 168 | environment. See `environ` in `src/zanmi/config.clj` for environment variable 169 | overrides. 170 | 171 | ### Database Initialization 172 | PostgreSQL and MongoDB are the only databases supported currently, but pull 173 | requests are welcome. 174 | 175 | There are no migrations. Use the `--init-db` command line switch to set up the 176 | database and database tables. 177 | 178 | ## FAQs 179 | * What about OAuth? 180 | - While I do have plans to implement an OAuth2 provider based on zanmi 181 | eventually, full OAuth2 support was a little overkill for my immediate use 182 | case. I wrote zanmi because I primarily wanted to (1) share user 183 | authentication among decoupled services and (2) isolate user password 184 | storage from all the other application data. 185 | 186 | * How do users log out? 187 | - zanmi only supports password database management and stateless 188 | authentication, so there is no session management. Client applications are 189 | free to manage their own separate sessions and use that in combination with 190 | the `:iat` and `:updated` fields of the auth tokens to support logout. 191 | 192 | * What's with the name? 193 | - "zanmi" means [friend](https://github.com/cemerick/friend) or 194 | [buddy](https://github.com/funcool/buddy) in Haitian Creole. 195 | 196 | ## TODO 197 | * Full OAuth2 implementation 198 | * Configurable password hashing schemes (support for pbkdf2, scrypt, etc) 199 | * Password database back ends for MySQL, Cassandra, etc. 200 | * More configurable password strength validations 201 | * Shared sessions (possibly with Redis) 202 | * Validate zanmi configuration map with clojure.spec 203 | * More tests! 204 | 205 | ## Contributing 206 | Pull requests welcome! 207 | 208 | ### Developing 209 | First install [leiningen](http://leiningen.org/) and clone the repository. 210 | 211 | #### Setup 212 | 213 | When you first clone this repository, run: 214 | 215 | ```sh 216 | lein setup 217 | ``` 218 | 219 | This will create files for local configuration, and prep your system 220 | for the project. 221 | 222 | #### Environment 223 | 224 | To begin developing, start with a REPL. 225 | 226 | ```sh 227 | lein repl 228 | ``` 229 | 230 | Then load the development environment. 231 | 232 | ```clojure 233 | user=> (dev) 234 | :loaded 235 | ``` 236 | 237 | Run `go` to initiate and start the system. 238 | 239 | ```clojure 240 | dev=> (go) 241 | :started 242 | ``` 243 | 244 | By default this creates a web server at . 245 | 246 | When you make changes to your source files, use `reset` to reload any 247 | modified files and reset the server. 248 | 249 | ```clojure 250 | dev=> (reset) 251 | :reloading (...) 252 | :resumed 253 | ``` 254 | 255 | #### Testing 256 | 257 | Testing is fastest through the REPL, as you avoid environment startup 258 | time. 259 | 260 | ```clojure 261 | dev=> (test) 262 | ... 263 | ``` 264 | 265 | But you can also run tests through Leiningen. 266 | 267 | ```sh 268 | lein test 269 | ``` 270 | 271 | ## Legal 272 | 273 | Copyright © 2016 ben lamothe. 274 | 275 | Distributed under the MIT License 276 | --------------------------------------------------------------------------------