├── .projectile ├── boot.properties ├── .gitignore ├── test ├── forms │ ├── authorize.html │ └── login.html └── cerber │ ├── oauth2 │ ├── scopes_test.clj │ ├── error_test.clj │ └── authorization_test.clj │ ├── stores │ ├── user_test.clj │ ├── authcode_test.clj │ ├── session_test.clj │ ├── client_test.clj │ └── token_test.clj │ └── test_utils.clj ├── src └── cerber │ ├── oauth2 │ ├── authenticator.clj │ ├── settings.clj │ ├── standalone │ │ ├── system.clj │ │ ├── config.clj │ │ └── server.clj │ ├── scopes.clj │ ├── response.clj │ ├── authorization.clj │ ├── context.clj │ └── core.clj │ ├── db.clj │ ├── mappers.clj │ ├── form.clj │ ├── stores │ ├── authcode.clj │ ├── session.clj │ ├── user.clj │ ├── client.clj │ └── token.clj │ ├── handlers.clj │ ├── helpers.clj │ ├── error.clj │ └── store.clj ├── .circleci └── config.yml ├── resources ├── db │ ├── cerber │ │ ├── authcodes.sql │ │ ├── users.sql │ │ ├── clients.sql │ │ ├── sessions.sql │ │ └── tokens.sql │ └── migrations │ │ ├── postgres │ │ └── V20161007012907__init_cerber_schema.sql │ │ ├── mysql │ │ └── V20161007012907__init_cerber_schema.sql │ │ └── h2 │ │ └── cerber_schema.sql ├── cerber.edn └── templates │ └── cerber │ ├── authorize.html │ └── login.html ├── LICENSE └── README.md /.projectile: -------------------------------------------------------------------------------- 1 | -/.git 2 | -/.repl 3 | -/TAGS 4 | -/*-init.clj 5 | -/*.log 6 | -------------------------------------------------------------------------------- /boot.properties: -------------------------------------------------------------------------------- 1 | #http://boot-clj.com 2 | #Fri Sep 27 08:02:52 NZST 2019 3 | BOOT_VERSION=2.8.2 4 | BOOT_CLOJURE_VERSION=1.9.0 5 | BOOT_CLOJURE_NAME=org.clojure/clojure 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cider_history 2 | .nrepl-port 3 | .nrepl-history 4 | .sass-cache 5 | .sass-tmp 6 | .ctagsignore 7 | .tags 8 | .tags_sorted_by_file 9 | .DS_Store 10 | TAGS 11 | target/ 12 | logs/ 13 | log/ 14 | dist/ 15 | *.pid 16 | *.paw 17 | *-local.edn 18 | gpg.edn 19 | -------------------------------------------------------------------------------- /test/forms/authorize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | {{ csrf|safe }} 6 | 7 |
8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/forms/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | {{ csrf|safe }} 6 | {% if failed? %} failed {% endif %} 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/cerber/oauth2/authenticator.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.authenticator 2 | (:require [cerber.stores.user :as user])) 3 | 4 | (defprotocol Authenticator 5 | (authenticate [this username password] "Returns authenticated user or nil if authentication failed")) 6 | 7 | (defrecord FormAuthenticator [] 8 | Authenticator 9 | (authenticate [this username password] 10 | (if-let [user (user/find-user username)] 11 | (and (user/valid-password? password (:password user)) 12 | (:enabled? user) 13 | user)))) 14 | 15 | (defmulti authentication-handler identity) 16 | 17 | (defmethod authentication-handler :default [_] 18 | (FormAuthenticator.)) 19 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/deps 5 | docker: 6 | - image: circleci/clojure:openjdk-11-boot-2.8.2 7 | environment: 8 | BOOT_JVM_OPTIONS: -Xmx3200m 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | keys: 13 | - v1-deps-checksum-{{ checksum "build.boot" }} 14 | - run: boot midje 15 | - save_cache: 16 | paths: 17 | - ~/.m2 18 | - ~/.boot/cache/bin 19 | - ~/.boot/cache/lib 20 | key: v1-deps-checksum-{{ checksum "build.boot" }} 21 | workflows: 22 | version: 2 23 | build_and_deploy: 24 | jobs: 25 | - build 26 | -------------------------------------------------------------------------------- /src/cerber/oauth2/settings.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.settings) 2 | 3 | (def defaults (atom {:realm "http://localhost" 4 | :authentication-url "/login" 5 | :unauthorized-url "/login" 6 | :landing-url "/" 7 | :token-valid-for 300 8 | :authcode-valid-for 180 9 | :session-valid-for 3600})) 10 | 11 | (defn update-settings 12 | [settings] 13 | (swap! defaults merge settings)) 14 | 15 | (doseq [s (keys @defaults)] 16 | (intern *ns* 17 | (symbol (name s)) 18 | (fn 19 | ([] (s @defaults)) 20 | ([val] (swap! defaults update s (constantly val)))))) 21 | -------------------------------------------------------------------------------- /resources/db/cerber/authcodes.sql: -------------------------------------------------------------------------------- 1 | -- :name find-authcode :? :1 2 | -- :doc Returns authcode bound with given code 3 | select * from authcodes where code=:code 4 | 5 | -- :name insert-authcode :! :1 6 | -- :doc Inserts new authcode 7 | insert into authcodes (code, redirect_uri, client_id, login, scope, expires_at, created_at) values (:code, :redirect-uri, :client-id, :login, :scope, :expires-at, :created-at) 8 | 9 | -- :name delete-authcode :! :1 10 | -- :doc Deletes authcode bound with given code 11 | delete from authcodes where code=:code 12 | 13 | -- :name clear-authcodes :! :1 14 | -- :doc Purges authcodes table 15 | delete from authcodes; 16 | 17 | -- :name clear-expired-authcodes :! :1 18 | -- :doc Purges authcodes table from expired token 19 | delete from authcodes where expires_at < :date 20 | -------------------------------------------------------------------------------- /src/cerber/oauth2/standalone/system.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.standalone.system 2 | (:require [clojure.tools.namespace.repl :as tn] 3 | [mount.core :refer [defstate] :as mount] 4 | [cerber.oauth2.standalone.server])) 5 | 6 | (defn go 7 | "Starts entire system and inititalizes all states defined by mount's `defstate`." 8 | [] 9 | (mount/start)) 10 | 11 | (defn stop 12 | "Stops the system and shuts downs all initialized states." 13 | [] 14 | (mount/stop)) 15 | 16 | (defn reset 17 | "Resets the system by stopping it first and starting again." 18 | [] 19 | (stop) 20 | (tn/refresh :after 'cerber.oauth2.standalone.system/go)) 21 | 22 | (defn refresh 23 | [] 24 | (stop) 25 | (tn/refresh)) 26 | 27 | (defn refresh-all [] 28 | (stop) 29 | (tn/refresh-all)) 30 | -------------------------------------------------------------------------------- /resources/db/cerber/users.sql: -------------------------------------------------------------------------------- 1 | -- :name find-user :? :1 2 | -- :doc Returns user with given login 3 | select * from users where login=:login 4 | 5 | -- :name insert-user :! :1 6 | -- :doc Inserts new user 7 | insert into users (id, login, email, name, password, roles, enabled, created_at, activated_at) values (:id, :login, :email, :name, :password, :roles, :enabled?, :created-at, :activated-at) 8 | 9 | -- :name enable-user :! :1 10 | -- :doc Enables user 11 | update users set enabled=true, activated_at=:activated-at where login=:login 12 | 13 | -- :name disable-user :! :1 14 | -- :doc Disables user 15 | update users set enabled=false, blocked_at=:blocked-at where login=:login 16 | 17 | -- :name delete-user :! :1 18 | -- :doc Deletes user 19 | delete from users where login=:login 20 | 21 | -- :name clear-users :! :1 22 | -- :doc Purges users table 23 | delete from users; 24 | -------------------------------------------------------------------------------- /resources/cerber.edn: -------------------------------------------------------------------------------- 1 | {:server {:port 8090} 2 | 3 | :landing-url "http://localhost:8090/" 4 | 5 | :clients [{:id "KEJ57AVGDWJA4YSEUBX3H3M2RBW53WLA" 6 | :secret "BOQUIIPBU5LDJ5BBZMZQYZZK2KTLHLBS" 7 | :info "Default client" 8 | :redirects ["http://cerber.test:8090"] 9 | :grants ["authorization_code" "password"] 10 | :scopes ["resources:read" "resources:write" "resources:manage"] 11 | :approved? false}] 12 | 13 | :users [{:login "admin" 14 | :email "admin@bar.com" 15 | :name "Admin Bar" 16 | :enabled? true 17 | :password "secret" 18 | :roles #{"user/admin"}} 19 | {:login "foo" 20 | :email "foo@bar.com" 21 | :name "Foo Bar" 22 | :enabled? true 23 | :password "pass" 24 | :roles #{"user/all"}}]} 25 | -------------------------------------------------------------------------------- /src/cerber/oauth2/scopes.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.scopes 2 | (:require [cerber.helpers :refer [str->coll]] 3 | [clojure.string :as str])) 4 | 5 | (defn- distinct-scope 6 | "Returns falsey if scopes contain given scope or any of its parents. 7 | Returns scope otherwise." 8 | 9 | [scopes ^String scope] 10 | (let [v (.split scope ":")] 11 | (loop [s v] 12 | (if (empty? s) 13 | scope 14 | (when-not (contains? scopes (str/join ":" s)) 15 | (recur (drop-last s))))))) 16 | 17 | (defn normalize-scope 18 | "Normalizes scope string by removing duplicated and overlapping scopes." 19 | 20 | [scope] 21 | (->> scope 22 | (str->coll) 23 | (sort-by #(count (re-seq #":" %))) 24 | (reduce (fn [reduced scope] 25 | (if-let [s (distinct-scope reduced scope)] 26 | (conj reduced s) 27 | reduced)) 28 | #{}))) 29 | -------------------------------------------------------------------------------- /resources/db/cerber/clients.sql: -------------------------------------------------------------------------------- 1 | -- :name find-client :? :1 2 | -- :doc Returns client with given client identifier 3 | select * from clients where id=:id 4 | 5 | -- :name insert-client :! :1 6 | -- :doc Inserts new client 7 | insert into clients (id, secret, info, redirects, scopes, grants, approved, enabled, created_at, activated_at) values (:id, :secret, :info, :redirects, :scopes, :grants, :approved?, :enabled?, :created-at, :activated-at) 8 | 9 | -- :name enable-client :! :1 10 | -- :doc Enables client 11 | update clients set enabled=true, activated_at=:activated-at where id=:id 12 | 13 | -- :name disable-client :! :1 14 | -- :doc Disables client 15 | update clients set enabled=false, blocked_at=:blocked-at where id=:id 16 | 17 | -- :name delete-client :! :1 18 | -- :doc Deletes client with given identifier 19 | delete from clients where id=:id 20 | 21 | -- :name clear-clients :! :1 22 | -- :doc Purges clients table 23 | delete from clients; 24 | -------------------------------------------------------------------------------- /resources/templates/cerber/authorize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | {{ csrf|safe }} 7 | 8 |

9 | Client {{ client.info }} wants to access your resources: 10 | 11 |

    12 | {% for scope in scopes %} 13 |
  • {{ scope }}
  • 14 | {% endfor %} 15 |
16 | 17 | Should authorize? 18 |

19 | 20 |
21 | 22 | refuse 23 |
24 | 25 |
26 |
27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /resources/templates/cerber/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | {{ csrf|safe }} 7 | 8 | {% if failed? %} failed {% endif %} 9 | 10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /resources/db/cerber/sessions.sql: -------------------------------------------------------------------------------- 1 | -- :name find-session :? :1 2 | -- :doc Returns session for given session id 3 | select * from sessions where sid=:sid 4 | 5 | -- :name insert-session :! :1 6 | -- :doc Inserts new session 7 | insert into sessions (sid, content, created_at, expires_at) values (:sid, :content, :created-at, :expires-at) 8 | 9 | -- :name delete-session :! :1 10 | -- :doc Deletes session for particular session id 11 | delete from sessions where sid=:sid 12 | 13 | -- :name clear-sessions :! :1 14 | -- :doc Purges sessions table 15 | delete from sessions 16 | 17 | -- :name update-session :! :1 18 | -- :doc Updates session entry 19 | update sessions set content=:content,expires_at=:expires-at where sid=:sid 20 | 21 | -- :name update-session-expiration :! :1 22 | -- :doc Updates expiration date only 23 | update sessions set expires_at=:expires-at where sid=:sid 24 | 25 | -- :name clear-expired-sessions :! :1 26 | -- :doc Purges tokens table from expired token 27 | delete from sessions where expires_at < :date 28 | -------------------------------------------------------------------------------- /src/cerber/oauth2/standalone/config.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.standalone.config 2 | (:require [cprop.core :as cprop] 3 | [cprop.source :refer [from-env from-resource from-system-props]] 4 | [failjure.core :as f] 5 | [mount.core :as mount :refer [defstate]])) 6 | 7 | (defn load-resource 8 | "Loads single configuration resource. 9 | Returns empty map when resource was not found." 10 | 11 | [resource] 12 | (let [res (f/try* (from-resource resource))] 13 | (if (f/failed? res) {} res))) 14 | 15 | (defn load-config 16 | "Loads configuration file depending on environment." 17 | 18 | [env] 19 | (println (str "Loading " env " environment...")) 20 | (cprop/load-config :resource "cerber.edn" 21 | :merge [(load-resource (str "cerber-" env ".edn"))])) 22 | 23 | (defn init-cerber [] 24 | (load-config (or (:env (from-env)) 25 | (:env (from-system-props)) 26 | "local"))) 27 | 28 | (defstate app-config 29 | :start (init-cerber)) 30 | -------------------------------------------------------------------------------- /resources/db/cerber/tokens.sql: -------------------------------------------------------------------------------- 1 | -- :name find-tokens-by-secret :? :1 2 | -- :doc Returns tokens found by secret 3 | select * from tokens where secret=:secret and ttype=:ttype 4 | 5 | -- :name find-tokens-by-client :? :* 6 | -- :doc Returns tokens found by client id 7 | select * from tokens where client_id=:client-id and ttype=:ttype 8 | 9 | -- :name insert-token :! :1 10 | -- :doc Inserts new token 11 | insert into tokens (client_id, user_id, secret, scope, login, ttype, created_at, expires_at) values (:client-id, :user-id, :secret, :scope, :login, :ttype, :created-at, :expires-at) 12 | 13 | -- :name delete-token-by-secret :! :1 14 | -- :doc Deletes token by secret 15 | delete from tokens where secret=:secret 16 | 17 | -- :name delete-tokens-by-login :! :1 18 | -- :doc Deletes access token 19 | delete from tokens where client_id=:client-id and login=:login and ttype=:ttype 20 | 21 | -- :name delete-tokens-by-client :! :1 22 | -- :doc Deletes access token 23 | delete from tokens where client_id=:client-id and ttype=:ttype 24 | 25 | -- :name clear-tokens :! :1 26 | -- :doc Purges tokens table 27 | delete from tokens 28 | 29 | -- :name clear-expired-tokens :! :1 30 | -- :doc Purges tokens table from expired token 31 | delete from tokens where expires_at < :date 32 | -------------------------------------------------------------------------------- /src/cerber/db.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.db 2 | (:require [cerber.helpers :as helpers] 3 | [conman.core :as conman])) 4 | 5 | (defn bind-queries 6 | [db-conn] 7 | (binding [*ns* (the-ns 'cerber.db)] 8 | (conman/bind-connection db-conn 9 | "db/cerber/tokens.sql" 10 | "db/cerber/clients.sql" 11 | "db/cerber/authcodes.sql" 12 | "db/cerber/users.sql" 13 | "db/cerber/sessions.sql"))) 14 | 15 | ;; helper functions to manage with SQL queries called periodically 16 | 17 | (def scheduler ^java.util.concurrent.ScheduledExecutorService 18 | (java.util.concurrent.Executors/newScheduledThreadPool 1)) 19 | 20 | (defn make-periodic 21 | "Schedules a SQL query (a function) to be run periodically at 22 | given interval. Function gets {:date now} as an argument." 23 | 24 | [fn-sym interval] 25 | (when-let [sqfn (resolve fn-sym)] 26 | (let [runnable (proxy [Runnable] [] 27 | (run [] (sqfn {:date (helpers/now)})))] 28 | (.scheduleAtFixedRate scheduler ^Runnable runnable 0 interval java.util.concurrent.TimeUnit/MILLISECONDS)))) 29 | 30 | (defn stop-periodic 31 | "Stops periodically run function created by `make-periodic`." 32 | 33 | [^java.util.concurrent.ScheduledFuture periodic] 34 | (when periodic 35 | (.cancel periodic false))) 36 | 37 | ;; this is to get db functions interned initially to avoid 38 | ;; runtime exceptions like "No such var: db/find-user" 39 | (bind-queries nil) 40 | -------------------------------------------------------------------------------- /test/cerber/oauth2/scopes_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.scopes-test 2 | (:require [cerber.oauth2.core :as core] 3 | [cerber.oauth2.scopes :refer [normalize-scope]] 4 | [cerber.stores.client :refer [scopes-valid?]] 5 | [cerber.test-utils :refer [with-stores]] 6 | [midje.sweet :refer :all])) 7 | 8 | (tabular 9 | (fact "Normalizes scope string by removing duplicates and overlaps" 10 | (normalize-scope ?scope) => ?normalized) 11 | 12 | ?scope ?normalized 13 | "photos:read photos:write photos" #{"photos"} 14 | "photos:read photos:write" #{"photos:read" "photos:write"} 15 | "photos:read user:read user" #{"photos:read" "user"} 16 | "" #{} 17 | nil #{}) 18 | 19 | (tabular 20 | (fact "Valid scopes should be included in client definition." 21 | (with-stores :in-memory 22 | (let [client (core/create-client ["authorization_code"] ["http://localhost"] 23 | :info "dummy" 24 | :scopes ["photos:read" "photos:write"] 25 | :enabled? true 26 | :approved? false)] 27 | (scopes-valid? client ?scopes) => ?expected))) 28 | 29 | ?scopes ?expected 30 | #{"photos:read"} true 31 | #{"photos:read" "photos:write"} true 32 | #{"user:read"} false 33 | #{"photos:read" "user:read"} false 34 | #{} true 35 | nil true) 36 | -------------------------------------------------------------------------------- /test/cerber/oauth2/error_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.error-test 2 | (:require [midje.sweet :refer :all] 3 | [cerber.test-utils :refer [with-stores]] 4 | [cerber.oauth2.authorization :refer [authorize!]] 5 | [cerber.oauth2.core :as core])) 6 | 7 | (fact "Authorization fails with meaningful error message when requested by unknown client, scope or mismatched redirect_uri." 8 | (with-stores :in-memory 9 | 10 | ;; given 11 | (let [user (core/create-user "foo" "secret") 12 | client (core/create-client ["authorization_code"] 13 | ["http://localhost"] 14 | :info "test-client" 15 | :scopes ["photo"] 16 | :enabled? true 17 | :approved? true) 18 | req {:request-method :get 19 | :uri "/users/me" 20 | :scheme "http" 21 | :params {:response_type "code" 22 | :redirect_uri "http://localhost" 23 | :client_id (:id client) 24 | :scope "photo" 25 | :state "123ABC"} 26 | :session {:login "foo"}}] 27 | 28 | ;; then 29 | (:status (authorize! req)) => 302 30 | (:error (authorize! (assoc-in req [:params :client_id] "foo"))) => "bad_request" 31 | (:error (authorize! (assoc-in req [:params :scope] "dummy"))) => "invalid_scope" 32 | (:error (authorize! (assoc-in req [:params :redirect_uri] "http://bar.bazz"))) => "invalid_redirect_uri"))) 33 | -------------------------------------------------------------------------------- /resources/db/migrations/postgres/V20161007012907__init_cerber_schema.sql: -------------------------------------------------------------------------------- 1 | -- migration to be applied 2 | 3 | create table tokens ( 4 | id serial primary key, 5 | ttype varchar(10), 6 | client_id varchar(32) not null, 7 | user_id varchar(50), 8 | secret varchar(64) not null UNIQUE, 9 | scope varchar(255), 10 | login varchar(32), 11 | expires_at timestamp, 12 | created_at timestamp not null 13 | ); 14 | 15 | create table users ( 16 | id varchar(32) primary key, 17 | login varchar(32) not null UNIQUE, 18 | email varchar(50), 19 | name varchar(128), 20 | password varchar(255), 21 | roles varchar(1024), 22 | enabled boolean not null default true, 23 | created_at timestamp not null, 24 | modified_at timestamp, 25 | activated_at timestamp, 26 | blocked_at timestamp 27 | ); 28 | 29 | create table sessions ( 30 | sid varchar(36) primary key, 31 | content bytea, 32 | expires_at timestamp not null, 33 | created_at timestamp not null 34 | ); 35 | 36 | create table authcodes ( 37 | id serial primary key, 38 | client_id varchar(32) not null, 39 | login varchar(32) not null, 40 | code varchar(32) not null, 41 | scope varchar(255), 42 | redirect_uri varchar(255), 43 | expires_at timestamp not null, 44 | created_at timestamp not null, 45 | UNIQUE (code, redirect_uri) 46 | ); 47 | 48 | create table clients ( 49 | id varchar(32) primary key, 50 | secret varchar(32) not null, 51 | info varchar(255), 52 | approved boolean not null default false, 53 | enabled boolean not null default true, 54 | scopes varchar(1024), 55 | grants varchar(255), 56 | redirects varchar(512) not null, 57 | created_at timestamp not null, 58 | modified_at timestamp, 59 | activated_at timestamp, 60 | blocked_at timestamp 61 | ); 62 | -------------------------------------------------------------------------------- /resources/db/migrations/mysql/V20161007012907__init_cerber_schema.sql: -------------------------------------------------------------------------------- 1 | -- migration to be applied 2 | 3 | create table tokens ( 4 | id int auto_increment primary key, 5 | ttype varchar(10), 6 | client_id varchar(32) not null, 7 | user_id varchar(50), 8 | secret varchar(64) not null, 9 | scope varchar(255), 10 | login varchar(32), 11 | expires_at datetime, 12 | created_at datetime not null, 13 | UNIQUE KEY tokens_unique (secret) 14 | ); 15 | 16 | create table users ( 17 | id varchar(32) primary key, 18 | login varchar(32) not null, 19 | email varchar(50), 20 | name varchar(128), 21 | password varchar(255), 22 | roles varchar(1024), 23 | enabled bit not null default true, 24 | created_at datetime not null, 25 | modified_at datetime, 26 | activated_at datetime, 27 | blocked_at datetime, 28 | UNIQUE KEY users_login_unique (login) 29 | ); 30 | 31 | create table sessions ( 32 | sid varchar(36) primary key, 33 | content varbinary(2048), 34 | expires_at datetime not null, 35 | created_at datetime not null 36 | ); 37 | 38 | create table authcodes ( 39 | id int auto_increment primary key, 40 | client_id varchar(32) not null, 41 | login varchar(32) not null, 42 | code varchar(32) not null, 43 | scope varchar(255), 44 | redirect_uri varchar(255), 45 | expires_at datetime not null, 46 | created_at datetime not null, 47 | UNIQUE KEY authcodes_unique (code, redirect_uri) 48 | ); 49 | 50 | create table clients ( 51 | id varchar(32) primary key, 52 | secret varchar(32) not null, 53 | info varchar(255), 54 | approved bit not null default false, 55 | enabled bit not null default true, 56 | scopes varchar(1024), 57 | grants varchar(255), 58 | redirects varchar(512) not null, 59 | created_at datetime not null, 60 | modified_at datetime, 61 | activated_at datetime, 62 | blocked_at datetime 63 | ); 64 | -------------------------------------------------------------------------------- /resources/db/migrations/h2/cerber_schema.sql: -------------------------------------------------------------------------------- 1 | drop table users if exists; 2 | drop table tokens if exists; 3 | drop table clients if exists; 4 | drop table sessions if exists; 5 | drop table authcodes if exists; 6 | 7 | create table tokens ( 8 | id int auto_increment primary key, 9 | ttype varchar(10), 10 | client_id varchar(32) not null, 11 | user_id varchar(50), 12 | secret varchar(64) not null, 13 | scope varchar(255), 14 | login varchar(32), 15 | expires_at timestamp, 16 | created_at timestamp not null, 17 | UNIQUE KEY tokens_unique (secret) 18 | ); 19 | 20 | create table users ( 21 | id varchar(32) primary key, 22 | login varchar(32) not null, 23 | email varchar(128), 24 | name varchar(128), 25 | password varchar(255), 26 | roles varchar(1024), 27 | enabled bit not null default true, 28 | created_at datetime not null, 29 | modified_at datetime, 30 | activated_at datetime, 31 | blocked_at datetime, 32 | UNIQUE KEY users_login_unique (login) 33 | ); 34 | 35 | create table sessions ( 36 | sid varchar(36) primary key, 37 | content varbinary(2048), 38 | expires_at datetime not null, 39 | created_at datetime not null 40 | ); 41 | 42 | create table authcodes ( 43 | id int auto_increment primary key, 44 | client_id varchar(32) not null, 45 | login varchar(32) not null, 46 | code varchar(32) not null, 47 | scope varchar(255), 48 | redirect_uri varchar(255), 49 | expires_at datetime not null, 50 | created_at datetime not null, 51 | UNIQUE KEY authcodes_unique (code, redirect_uri) 52 | ); 53 | 54 | create table clients ( 55 | id varchar(32) primary key, 56 | secret varchar(32) not null, 57 | info varchar(255), 58 | approved bit not null default false, 59 | enabled bit not null default true, 60 | scopes varchar(1024), 61 | grants varchar(255), 62 | redirects varchar(512) not null, 63 | created_at datetime not null, 64 | modified_at datetime, 65 | activated_at datetime, 66 | blocked_at datetime 67 | 68 | ); 69 | -------------------------------------------------------------------------------- /src/cerber/mappers.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.mappers 2 | (:require [cerber.helpers :as helpers] 3 | [taoensso.nippy :as nippy])) 4 | 5 | (defn row->token 6 | [row] 7 | (when-let [{:keys [client_id user_id login scope secret created_at expires_at]} row] 8 | {:client-id client_id 9 | :user-id user_id 10 | :login login 11 | :scope scope 12 | :secret secret 13 | :created-at created_at 14 | :expires-at expires_at})) 15 | 16 | (defn row->user 17 | [row] 18 | (when-let [{:keys [id login email name password roles created_at modified_at activated_at blocked_at enabled]} row] 19 | {:id id 20 | :login login 21 | :email email 22 | :name name 23 | :password password 24 | :enabled? enabled 25 | :created-at created_at 26 | :modified-at modified_at 27 | :activated-at activated_at 28 | :blocked-at blocked_at 29 | :roles (helpers/str->keywords roles)})) 30 | 31 | (defn row->session 32 | [row] 33 | (when-let [{:keys [sid content created_at expires_at]} row] 34 | {:sid sid 35 | :content (nippy/thaw content) 36 | :expires-at expires_at 37 | :created-at created_at})) 38 | 39 | (defn row->client 40 | [row] 41 | (when-let [{:keys [id secret info approved scopes grants redirects enabled created_at modified_at activated_at blocked_at]} row] 42 | {:id id 43 | :secret secret 44 | :info info 45 | :approved? approved 46 | :enabled? enabled 47 | :scopes (helpers/str->coll scopes) 48 | :grants (helpers/str->coll grants) 49 | :redirects (helpers/str->coll redirects) 50 | :created-at created_at 51 | :modified-at modified_at 52 | :activated-at activated_at 53 | :blocked-at blocked_at})) 54 | 55 | (defn row->authcode 56 | [row] 57 | (when-let [{:keys [client_id login code scope redirect_uri created_at expires_at]} row] 58 | {:client-id client_id 59 | :login login 60 | :code code 61 | :scope scope 62 | :redirect-uri redirect_uri 63 | :expires-at expires_at 64 | :created-at created_at})) 65 | -------------------------------------------------------------------------------- /test/cerber/stores/user_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.user-test 2 | (:require [cerber.test-utils :refer [has-secret instance-of with-stores]] 3 | [cerber.stores.user :refer [valid-password?]] 4 | [cerber.oauth2.core :as core] 5 | [midje.sweet :refer :all]) 6 | (:import cerber.stores.user.User)) 7 | 8 | (def login "foo") 9 | (def email "foo@bazz.bar") 10 | (def uname "Foo Bazz") 11 | (def password "pass") 12 | 13 | (fact "Created user has auto-generated id and crypted password filled in." 14 | (with-stores :in-memory 15 | 16 | ;; given 17 | (let [user (core/create-user login password)] 18 | 19 | ;; then 20 | user => (instance-of User) 21 | user => (has-secret :password) 22 | 23 | ;; auto-generated identity 24 | (:id user) => truthy 25 | 26 | ;; password must be encrypted! 27 | (= password (:password user)) => false 28 | 29 | ;; hashed passwords should be the same 30 | (valid-password? password (:password user)) => true))) 31 | 32 | (tabular 33 | (fact "Users are stored in a correct model, enabled by default if no :enabled? property was set." 34 | (with-stores ?store 35 | 36 | ;; given 37 | (let [created1 (core/create-user login password :email email :name uname) 38 | created2 (core/create-user "bazz" password :enabled? false)] 39 | 40 | ;; when 41 | (let [user1 (core/find-user (:login created1)) 42 | user2 (core/find-user (:login created2))] 43 | 44 | ;; then 45 | user1 => (has-secret :password) 46 | user1 => (contains {:login login 47 | :email email 48 | :name uname 49 | :enabled? true}) 50 | 51 | (:enabled? user2)) => false))) 52 | 53 | ?store :in-memory :sql :redis) 54 | 55 | (tabular 56 | (fact "Revoked user is not returned from store." 57 | (with-stores ?store 58 | 59 | ;; given 60 | (let [user (core/create-user login password)] 61 | (core/find-user login) => (instance-of User) 62 | 63 | ;; when 64 | (core/delete-user login) 65 | 66 | ;; then 67 | (core/find-user login) => nil))) 68 | 69 | ?store :in-memory :sql :redis) 70 | -------------------------------------------------------------------------------- /test/cerber/stores/authcode_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.authcode-test 2 | (:require [cerber.stores.authcode :refer :all] 3 | [cerber.test-utils :refer [instance-of has-secret create-test-user create-test-client with-stores]] 4 | [midje.sweet :refer :all]) 5 | (:import cerber.stores.authcode.AuthCode)) 6 | 7 | (def redirect-uri "http://localhost") 8 | (def scope "photo:read") 9 | 10 | (fact "Created authcode has a secret code." 11 | (with-stores :in-memory 12 | 13 | ;; given 14 | (let [user (create-test-user) 15 | client (create-test-client redirect-uri :scope scope) 16 | authcode (create-authcode client user scope redirect-uri)] 17 | 18 | ;; then 19 | authcode => (instance-of AuthCode) 20 | authcode => (has-secret :code)))) 21 | 22 | (tabular 23 | (fact "Authcodes are stored in a correct model." 24 | (with-stores ?store 25 | 26 | ;; given 27 | (let [user (create-test-user) 28 | client (create-test-client redirect-uri :scope scope) 29 | created (create-authcode client user scope redirect-uri)] 30 | 31 | ;; then 32 | (let [authcode (find-authcode (:code created))] 33 | 34 | authcode => (instance-of AuthCode) 35 | authcode => (has-secret :code) 36 | authcode => (contains {:client-id (:id client) 37 | :login (:login user) 38 | :scope scope 39 | :redirect-uri redirect-uri}) 40 | 41 | (:expires-at authcode)) =not=> nil))) 42 | 43 | ?store :in-memory :sql :redis) 44 | 45 | (tabular 46 | (fact "Revoked authcode is not returned from store." 47 | (with-stores ?store 48 | 49 | ;; given 50 | (let [user (create-test-user) 51 | client (create-test-client redirect-uri :scope scope) 52 | authcode (create-authcode client user scope redirect-uri)] 53 | 54 | ;; then 55 | (find-authcode (:code authcode)) => (instance-of AuthCode) 56 | (revoke-authcode authcode) 57 | (find-authcode (:code authcode)) => nil))) 58 | 59 | ?store :in-memory :sql :redis) 60 | 61 | (tabular 62 | (fact "Expired authcodes are removed from store." 63 | (with-stores ?store 64 | 65 | ;; given 66 | (let [user (create-test-user) 67 | client (create-test-client redirect-uri :scope scope) 68 | authcode (create-authcode client user scope redirect-uri -1)] 69 | 70 | ;; then 71 | (find-authcode (:code authcode))) => nil)) 72 | 73 | ?store :in-memory :sql :redis) 74 | -------------------------------------------------------------------------------- /src/cerber/form.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.form 2 | (:require [cerber.helpers :refer [ajax-request? cond-as->]] 3 | [cerber.oauth2 4 | [authenticator :refer [authentication-handler]] 5 | [context :as ctx] 6 | [settings :as settings]] 7 | [failjure.core :as f] 8 | [ring.util 9 | [anti-forgery :refer [anti-forgery-field]] 10 | [response :refer [header redirect response]]] 11 | [selmer.parser :refer [render-file]])) 12 | 13 | (def default-authenticator (authentication-handler :default)) 14 | 15 | (defn render-template [file kv] 16 | (-> (render-file file kv) 17 | (response) 18 | (header "Content-Type" "text/html"))) 19 | 20 | (defn render-login-form [req] 21 | (let [landing-url (get-in req [:session :landing-url])] 22 | (-> (render-template "templates/cerber/login.html" {:csrf (anti-forgery-field) 23 | :failed? (boolean (:failed? req))}) 24 | 25 | ;; convey landing-url if it was set on login 26 | (cond-> landing-url 27 | (assoc :session {:landing-url landing-url}))))) 28 | 29 | (defn render-approval-form [client scopes req] 30 | (render-template "templates/cerber/authorize.html" {:csrf (anti-forgery-field) 31 | :query-params (:query-string req) 32 | :client client 33 | :scopes scopes})) 34 | 35 | (defn handle-login-submit [req] 36 | (let [result (ctx/user-password-valid? req default-authenticator) 37 | ajax-request? (ajax-request? (:headers req))] 38 | 39 | (if (f/failed? result) 40 | 41 | ;; login failed. re-render login page with failure flag set on. 42 | (if ajax-request? 43 | {:status 401} 44 | (render-login-form (assoc req :failed? true))) 45 | 46 | ;; login succeeded. redirect either to session-stored or default landing url. 47 | (let [location (get-in req [:session :landing-url] (settings/landing-url)) 48 | {:keys [id login]} (::ctx/user result)] 49 | (-> (redirect location) 50 | (assoc :session {:id id :login login}) 51 | (update :session dissoc :landing-url) 52 | 53 | ;; do not send HTTP 302 if login was requested via ajax-request. 54 | ;; client should do redirection by himself. 55 | 56 | (cond-as-> response 57 | ajax-request? 58 | (-> response 59 | (update :headers merge {"content-type" "application/json"}) 60 | (assoc :status 200 61 | :body {:landing-url location})))))))) 62 | -------------------------------------------------------------------------------- /src/cerber/stores/authcode.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.authcode 2 | "Functions handling OAuth2 authcode storage." 3 | (:require [cerber.oauth2.settings :as settings] 4 | [cerber 5 | [db :as db] 6 | [error :as error] 7 | [helpers :as helpers] 8 | [mappers :as mappers] 9 | [store :refer :all]])) 10 | 11 | (def authcode-store (atom :not-initialized)) 12 | 13 | (defrecord AuthCode [client-id login code scope redirect-uri expires-at created-at]) 14 | 15 | (defrecord SqlAuthCodeStore [expired-authcodes-cleaner] 16 | Store 17 | (fetch-one [this [code]] 18 | (some-> (db/find-authcode {:code code}) 19 | mappers/row->authcode)) 20 | (revoke-one! [this [code]] 21 | (db/delete-authcode {:code code})) 22 | (store! [this k authcode] 23 | (= 1 (db/insert-authcode authcode))) 24 | (purge! [this] 25 | (db/clear-authcodes)) 26 | (close! [this] 27 | (db/stop-periodic expired-authcodes-cleaner))) 28 | 29 | (defmulti create-authcode-store (fn [type config] type)) 30 | 31 | (defmethod create-authcode-store :in-memory [_ _] 32 | (->MemoryStore "authcodes" (atom {}))) 33 | 34 | (defmethod create-authcode-store :redis [_ redis-spec] 35 | (->RedisStore "authcodes" redis-spec)) 36 | 37 | (defmethod create-authcode-store :sql [_ db-conn] 38 | (when db-conn 39 | (db/bind-queries db-conn) 40 | (->SqlAuthCodeStore (db/make-periodic 'cerber.db/clear-expired-authcodes 8000)))) 41 | 42 | (defn init-store 43 | "Initializes authcode store according to given type and configuration." 44 | 45 | [type config] 46 | (reset! authcode-store (create-authcode-store type config))) 47 | 48 | (defn find-authcode 49 | [code] 50 | (when-let [authcode (fetch-one @authcode-store [code])] 51 | (when-not (helpers/expired? authcode) 52 | (map->AuthCode authcode)))) 53 | 54 | (defn revoke-authcode 55 | "Revokes previously generated authcode." 56 | 57 | [authcode] 58 | (revoke-one! @authcode-store [(:code authcode)]) nil) 59 | 60 | (defn purge-authcodes 61 | "Removes auth code from store." 62 | 63 | [] 64 | (purge! @authcode-store)) 65 | 66 | (defn create-authcode 67 | "Creates and returns a new auth-code." 68 | 69 | [client user scope redirect-uri & [ttl]] 70 | (let [authcode (helpers/reset-ttl 71 | {:client-id (:id client) 72 | :login (:login user) 73 | :scope scope 74 | :code (helpers/generate-secret) 75 | :redirect-uri redirect-uri 76 | :created-at (helpers/now)} 77 | (or ttl (settings/authcode-valid-for)))] 78 | 79 | (if (store! @authcode-store [:code] authcode) 80 | (map->AuthCode authcode) 81 | (error/internal-error "Cannot store authcode")))) 82 | -------------------------------------------------------------------------------- /test/cerber/stores/session_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.session-test 2 | (:require [cerber.test-utils :refer [instance-of has-secret with-stores]] 3 | [cerber.stores.session :refer :all] 4 | [midje.sweet :refer :all] 5 | [cerber.oauth2.settings :as settings]) 6 | (:import cerber.stores.session.Session)) 7 | 8 | (def session-content {:sample "value"}) 9 | 10 | (fact "Created session has a session content." 11 | (with-stores :in-memory 12 | 13 | ;; given 14 | (let [session (create-session session-content)] 15 | 16 | ;; then 17 | session => (instance-of Session) 18 | session => (contains {:content session-content})))) 19 | 20 | (tabular 21 | (fact "Sessions are stored in a correct model." 22 | (with-stores ?store 23 | 24 | ;; given 25 | (let [created (create-session session-content) 26 | session (find-session (:sid created))] 27 | 28 | ;; then 29 | session => (instance-of Session) 30 | session => (has-secret :sid) 31 | session => (contains {:content session-content}) 32 | 33 | (:expires-at session) =not=> nil))) 34 | 35 | ?store :in-memory :sql :redis) 36 | 37 | (tabular 38 | (fact "Expired sessions are removed from store." 39 | (with-stores ?store 40 | 41 | ;; given 42 | (let [session (create-session session-content -1)] 43 | 44 | ;; then 45 | (find-session (:sid session)) => nil))) 46 | 47 | ?store :in-memory :sql :redis) 48 | 49 | (tabular 50 | (fact "Extended session has expires-at updated." 51 | (with-stores ?store 52 | 53 | ;; given 54 | (let [created (create-session session-content 1) 55 | expires (:expires-at created) 56 | 57 | ;; when 58 | session (find-session (:sid (extend-session created)))] 59 | 60 | ;; then 61 | (compare (:expires-at session) expires) => 1))) 62 | 63 | ?store :in-memory :sql :redis) 64 | 65 | (tabular 66 | (fact "Existing sessions can be updated with new content." 67 | (with-stores ?store 68 | 69 | ;; given 70 | (let [created (create-session session-content) 71 | 72 | ;; when 73 | updated (update-session (assoc created :content {:sample "updated"})) 74 | session (find-session (:sid updated))] 75 | 76 | ;; then 77 | (-> updated :content :sample) => "updated" 78 | (-> session :content :sample) => "updated" 79 | 80 | (= (:sid updated) (:sid session)) => true 81 | (= (:sid created) (:sid session)) => true))) 82 | 83 | ?store :in-memory :sql :redis) 84 | 85 | (tabular 86 | (fact "Non-existent sessions cannot be updated." 87 | (with-stores ?store 88 | (update-session (map->Session {:sid "123" :content session-content})) => nil)) 89 | 90 | ?store :in-memory :sql :redis) 91 | -------------------------------------------------------------------------------- /src/cerber/stores/session.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.session 2 | "Functions handling OAuth2 session storage." 3 | (:require [cerber.helpers :as helpers] 4 | [cerber.oauth2.settings :as settings] 5 | [cerber 6 | [db :as db] 7 | [error :as error] 8 | [helpers :as helpers] 9 | [mappers :as mappers] 10 | [store :refer :all]] 11 | [taoensso.nippy :as nippy])) 12 | 13 | (def session-store (atom :not-initialized)) 14 | 15 | (defrecord Session [sid content created-at expires-at]) 16 | 17 | (defrecord SqlSessionStore [expired-sessions-cleaner] 18 | Store 19 | (fetch-one [this [sid]] 20 | (some-> (db/find-session {:sid sid}) 21 | mappers/row->session)) 22 | (revoke-one! [this [sid]] 23 | (db/delete-session {:sid sid})) 24 | (store! [this k session] 25 | (let [content (nippy/freeze (:content session))] 26 | (= 1 (db/insert-session (assoc session :content content))))) 27 | (modify! [this k session] 28 | (let [result (db/update-session (update session :content nippy/freeze))] 29 | (when (= 1 result) session))) 30 | (touch! [this k session ttl] 31 | (let [extended (helpers/reset-ttl session ttl) 32 | result (db/update-session-expiration extended)] 33 | (when (= 1 result) extended))) 34 | (purge! [this] 35 | (db/clear-sessions)) 36 | (close! [this] 37 | (db/stop-periodic expired-sessions-cleaner))) 38 | 39 | (defmulti create-session-store (fn [type config] type)) 40 | 41 | (defmethod create-session-store :in-memory [_ _] 42 | (->MemoryStore "sessions" (atom {}))) 43 | 44 | (defmethod create-session-store :redis [_ redis-spec] 45 | (->RedisStore "sessions" redis-spec)) 46 | 47 | (defmethod create-session-store :sql [_ db-conn] 48 | (when db-conn 49 | (db/bind-queries db-conn) 50 | (->SqlSessionStore (db/make-periodic 'cerber.db/clear-expired-sessions 10000)))) 51 | 52 | (defn init-store 53 | "Initializes session store according to given type and configuration." 54 | 55 | [type config] 56 | (reset! session-store (create-session-store type config))) 57 | 58 | (defn find-session [sid] 59 | (when-let [session (fetch-one @session-store [sid])] 60 | (when-not (helpers/expired? session) 61 | (map->Session session)))) 62 | 63 | (defn revoke-session 64 | "Revokes previously generated session." 65 | 66 | [session] 67 | (revoke-one! @session-store [(:sid session)]) nil) 68 | 69 | (defn purge-sessions 70 | "Removes sessions from store." 71 | 72 | [] 73 | (purge! @session-store)) 74 | 75 | (defn create-session 76 | "Creates and returns a new session." 77 | 78 | [content & [ttl]] 79 | (let [session (helpers/reset-ttl 80 | {:sid (helpers/uuid) 81 | :content content 82 | :created-at (helpers/now)} 83 | (or ttl (settings/session-valid-for)))] 84 | 85 | (if (store! @session-store [:sid] session) 86 | (map->Session session) 87 | (error/internal-error "Cannot store session")))) 88 | 89 | (defn update-session [session] 90 | (modify! @session-store [:sid] (helpers/reset-ttl session (settings/session-valid-for)))) 91 | 92 | (defn extend-session [session] 93 | (touch! @session-store [:sid] session (settings/session-valid-for))) 94 | -------------------------------------------------------------------------------- /src/cerber/oauth2/standalone/server.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.standalone.server 2 | (:require [cerber.handlers :as handlers] 3 | [cerber.oauth2.context :as ctx] 4 | [cerber.oauth2.core :as core] 5 | [cerber.oauth2.standalone.config :refer [app-config]] 6 | [cerber.store :refer :all] 7 | [compojure.core :refer [defroutes GET POST routes wrap-routes]] 8 | [conman.core :as conman] 9 | [mount.core :as mount :refer [defstate]] 10 | [org.httpkit.server :as web] 11 | [ring.middleware.defaults :refer [api-defaults wrap-defaults]])) 12 | 13 | (defn user-info-handler 14 | [req] 15 | {:status 200 16 | :body (select-keys (::ctx/user req) [:login :name :email :roles])}) 17 | 18 | (defroutes oauth2-routes 19 | (GET "/authorize" [] handlers/authorization-handler) 20 | (POST "/approve" [] handlers/client-approve-handler) 21 | (GET "/refuse" [] handlers/client-refuse-handler) 22 | (POST "/token" [] handlers/token-handler) 23 | (GET "/login" [] handlers/login-form-handler) 24 | (POST "/login" [] handlers/login-submit-handler) 25 | (GET "/logout" [] handlers/logout-handler)) 26 | 27 | (defroutes restricted-routes 28 | (GET "/users/me" [] user-info-handler)) 29 | 30 | (def app-handler 31 | (wrap-defaults 32 | (routes oauth2-routes 33 | (wrap-routes restricted-routes handlers/wrap-authorized)) 34 | api-defaults)) 35 | 36 | (defn init-server 37 | "Initializes preconfigured users, clients and standalone 38 | HTTP server handling OAuth2 endpoints." 39 | 40 | [] 41 | (core/init-users (:users app-config)) 42 | (core/init-clients (:clients app-config)) 43 | 44 | (when-let [url (:landing-url app-config)] 45 | (core/set-landing-url! url)) 46 | (when-let [http-config (:server app-config)] 47 | (web/run-server app-handler http-config))) 48 | 49 | (defstate db-conn 50 | :start (and (Class/forName "org.h2.Driver") 51 | (conman/connect! {:init-size 1 52 | :min-idle 1 53 | :max-idle 4 54 | :max-active 32 55 | :jdbc-url "jdbc:h2:mem:testdb;MODE=MySQL;INIT=RUNSCRIPT FROM 'classpath:/db/migrations/h2/cerber_schema.sql'" 56 | ;:driver-class "org.postgresql.Driver" 57 | ;:jdbc-url "jdbc:postgresql://localhost:5432/template1?user=postgres" 58 | })) 59 | :stop (conman/disconnect! db-conn)) 60 | 61 | ;; oauth2 stores 62 | 63 | (defstate client-store 64 | :start (core/create-client-store :sql db-conn) 65 | :stop (close! client-store)) 66 | 67 | (defstate user-store 68 | :start (core/create-user-store :sql db-conn) 69 | :stop (close! user-store)) 70 | 71 | (defstate token-store 72 | :start (core/create-token-store :sql db-conn) 73 | :stop (close! token-store)) 74 | 75 | (defstate authcode-store 76 | :start (core/create-authcode-store :sql db-conn) 77 | :stop (close! authcode-store)) 78 | 79 | (defstate session-store 80 | :start (core/create-session-store :sql db-conn) 81 | :stop (close! session-store)) 82 | 83 | (defstate http-server 84 | :start (init-server) 85 | :stop (when http-server (http-server))) 86 | -------------------------------------------------------------------------------- /src/cerber/stores/user.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.user 2 | "Functions handling OAuth2 user storage." 3 | (:require [cerber 4 | [db :as db] 5 | [error :as error] 6 | [helpers :as helpers] 7 | [mappers :as mappers] 8 | [store :refer :all]] 9 | [clojure.string :as str])) 10 | 11 | (def user-store (atom :not-initialized)) 12 | 13 | (defrecord User [id login email name password enabled? created-at modified-at activated-at blocked-at]) 14 | 15 | (defrecord SqlUserStore [] 16 | Store 17 | (fetch-one [this [login]] 18 | (some-> (db/find-user {:login login}) 19 | mappers/row->user)) 20 | (revoke-one! [this [login]] 21 | (db/delete-user {:login login})) 22 | (store! [this k user] 23 | (= 1 (db/insert-user (update user :roles helpers/keywords->str)))) 24 | (modify! [this k user] 25 | (if (:enabled? user) 26 | (db/enable-user user) 27 | (db/disable-user user))) 28 | (purge! [this] 29 | (db/clear-users)) 30 | (close! [this] 31 | )) 32 | 33 | (defmulti create-user-store (fn [type config] type)) 34 | 35 | (defmethod create-user-store :in-memory [_ _] 36 | (->MemoryStore "users" (atom {}))) 37 | 38 | (defmethod create-user-store :redis [_ redis-spec] 39 | (->RedisStore "users" redis-spec)) 40 | 41 | (defmethod create-user-store :sql [_ db-conn] 42 | (when db-conn 43 | (db/bind-queries db-conn) 44 | (->SqlUserStore))) 45 | 46 | (defn init-store 47 | "Initializes user store according to given type and configuration." 48 | 49 | [type config] 50 | (reset! user-store (create-user-store type config))) 51 | 52 | (defn find-user 53 | "Returns users with given login if found or nil otherwise." 54 | 55 | [login] 56 | (when-let [user (and login (fetch-one @user-store [login]))] 57 | (map->User user))) 58 | 59 | (defn revoke-user 60 | "Removes user from store" 61 | 62 | [user] 63 | (revoke-one! @user-store [(:login user)])) 64 | 65 | (defn purge-users 66 | "Removes users from store." 67 | 68 | [] 69 | (purge! @user-store)) 70 | 71 | (defn create-user 72 | "Creates and returns a new user, enabled by default." 73 | 74 | [login password {:keys [name email roles enabled?] :as details}] 75 | (when (and login password) 76 | (let [user (-> details 77 | (assoc :id (helpers/uuid) 78 | :login login 79 | :password (helpers/bcrypt-hash password) 80 | :created-at (helpers/now) 81 | :activated-at (when enabled? (helpers/now))))] 82 | 83 | (if (store! @user-store [:login] user) 84 | (map->User user) 85 | (error/internal-error "Cannot store user"))))) 86 | 87 | (defn enable-user 88 | "Enables user. Returns true if user has been enabled successfully or false otherwise." 89 | 90 | [user] 91 | (= 1 (modify! @user-store [:login] (assoc user :enabled? true :activated-at (helpers/now))))) 92 | 93 | (defn disable-user 94 | "Disables user. Returns true if user has been disabled successfully or false otherwise." 95 | 96 | [user] 97 | (= 1 (modify! @user-store [:login] (assoc user :enabled? false :blocked-at (helpers/now))))) 98 | 99 | (defn valid-password? 100 | "Verifies that given password matches the hashed one." 101 | 102 | [password hashed] 103 | (and (not (str/blank? password)) 104 | (not (str/blank? hashed)) 105 | (helpers/bcrypt-check password hashed))) 106 | -------------------------------------------------------------------------------- /src/cerber/handlers.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.handlers 2 | (:require [cerber 3 | [error :as error] 4 | [form :as form]] 5 | [cerber.oauth2 6 | [authorization :as auth] 7 | [context :as ctx]] 8 | [cerber.stores.session 9 | :refer 10 | [create-session 11 | extend-session 12 | find-session 13 | revoke-session 14 | update-session]] 15 | [ring.middleware 16 | [anti-forgery :refer [wrap-anti-forgery]] 17 | [format :refer [wrap-restful-format]] 18 | [session :refer [wrap-session]]] 19 | [ring.middleware.session.store :refer [SessionStore]] 20 | [failjure.core :as f])) 21 | 22 | (deftype RingCustomizedStore [] 23 | SessionStore 24 | (read-session [_ key] 25 | (when-let [session (find-session key)] 26 | (:content (extend-session session)))) 27 | (write-session [_ key data] 28 | (:sid 29 | (if key 30 | (when-let [session (find-session key)] 31 | (update-session (assoc session :content data))) 32 | (create-session data)))) 33 | (delete-session [_ key] 34 | (revoke-session (find-session key)) 35 | nil)) 36 | 37 | (defonce session-store (RingCustomizedStore.)) 38 | 39 | (defn wrap-errors [handler] 40 | (fn [req] 41 | (let [response (handler req)] 42 | (if-let [error (:error response)] 43 | (if (= (:code response) 302) 44 | (error/error->redirect response req) 45 | (error/error->edn response req)) 46 | response)))) 47 | 48 | (defn wrap-context [handler redirect-on-error?] 49 | (fn [req] 50 | (let [result (or (and (-> req :session :login) 51 | (ctx/user-authenticated? req)) 52 | (ctx/bearer-valid? req))] 53 | (if (f/failed? result) 54 | (if redirect-on-error? 55 | result 56 | (handler req)) 57 | (handler result))))) 58 | 59 | (defn wrap-maybe-authorized [handler] 60 | (-> handler 61 | (wrap-context false) 62 | (wrap-session {:store session-store}))) 63 | 64 | (defn wrap-authorized [handler] 65 | (-> handler 66 | (wrap-context true) 67 | (wrap-errors) 68 | (wrap-session {:store session-store}))) 69 | 70 | (defn login-form-handler [req] 71 | (-> form/render-login-form 72 | (wrap-anti-forgery) 73 | (wrap-session {:store session-store}))) 74 | 75 | (defn login-submit-handler [req] 76 | (-> form/handle-login-submit 77 | (wrap-anti-forgery) 78 | (wrap-session {:store session-store}))) 79 | 80 | (defn logout-handler [req] 81 | (-> auth/unauthorize! 82 | (wrap-context false) 83 | (wrap-errors) 84 | (wrap-session {:store session-store}))) 85 | 86 | (defn authorization-handler [req] 87 | (-> auth/authorize! 88 | (wrap-errors) 89 | (wrap-session {:store session-store}) 90 | (wrap-restful-format :formats [:json-kw]))) 91 | 92 | (defn client-approve-handler [req] 93 | (-> auth/approve! 94 | (wrap-errors) 95 | (wrap-anti-forgery) 96 | (wrap-session {:store session-store}) 97 | (wrap-restful-format :formats [:json-kw]))) 98 | 99 | (defn client-refuse-handler [req] 100 | (-> auth/refuse! 101 | (wrap-errors) 102 | (wrap-restful-format :formats [:json-kw]))) 103 | 104 | (defn token-handler [req] 105 | (-> auth/issue-token! 106 | (wrap-errors) 107 | (wrap-restful-format :formats [:json-kw]))) 108 | -------------------------------------------------------------------------------- /src/cerber/oauth2/response.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.response 2 | (:require [cerber 3 | [error :as error] 4 | [helpers :as helpers] 5 | [form :as form]] 6 | [cerber.oauth2 7 | [context :as ctx] 8 | [settings :as settings]] 9 | [cerber.stores 10 | [authcode :as authcode] 11 | [client :as client] 12 | [token :as token]] 13 | [failjure.core :as f] 14 | [ring.middleware.anti-forgery :refer [wrap-anti-forgery]] 15 | [ring.util 16 | [request :refer [request-url]] 17 | [response :as response]] 18 | [cerber.stores.user :as user])) 19 | 20 | 21 | (defn redirect-to 22 | "Redirects browser to given location" 23 | 24 | [location] 25 | (response/redirect location)) 26 | 27 | (defn redirect-with-session [url session] 28 | (-> (redirect-to url) 29 | (assoc :session session))) 30 | 31 | (defn redirect-with-code [{:keys [params ::ctx/user ::ctx/client ::ctx/scopes ::ctx/state ::ctx/redirect-uri]}] 32 | (f/attempt-all [access-scope (helpers/coll->str scopes) 33 | authcode (or (authcode/create-authcode client user access-scope redirect-uri) error/server-error) 34 | redirect (str redirect-uri 35 | "?code=" (:code authcode) 36 | (when state (str "&state=" state)))] 37 | (redirect-to redirect))) 38 | 39 | (defn redirect-with-token [{:keys [params ::ctx/user ::ctx/client ::ctx/scopes ::ctx/state ::ctx/redirect-uri]}] 40 | (f/attempt-all [access-scope (helpers/coll->str scopes) 41 | access-token (token/generate-access-token client user access-scope) 42 | redirect (str redirect-uri 43 | "?access_token=" (:access_token access-token) 44 | "&expires_in=" (:expires_in access-token) 45 | (when access-scope (str "&scope=" access-scope)) 46 | (when state (str "&state=" state)))] 47 | (redirect-to redirect))) 48 | 49 | (defn access-token-response [{:keys [::ctx/client ::ctx/scopes ::ctx/user ::ctx/authcode]}] 50 | (when authcode 51 | (authcode/revoke-authcode authcode)) 52 | 53 | ;; generate access & refresh token (if requested). 54 | ;; 55 | ;; note that scope won't exist in authorization_code scenario as it should be taken straight from authcode. 56 | ;; also, user won't exist for Client Credentials grant. 57 | 58 | (f/attempt-all [access-scope (and scopes (helpers/coll->str scopes)) 59 | access-token (token/generate-access-token client 60 | (or user {:enabled? true}) 61 | (or access-scope (:scope authcode)) 62 | (boolean user))] 63 | {:status 200 64 | :body access-token})) 65 | 66 | (defn refresh-token-response [req] 67 | (let [{:keys [login scope]} (::ctx/refresh-token req)] 68 | (f/attempt-all [access-token (token/generate-access-token (::ctx/client req) 69 | (user/find-user login) 70 | scope 71 | true)] 72 | {:status 200 73 | :body access-token}))) 74 | 75 | 76 | 77 | (defn approval-form-response [req client scopes] 78 | ((wrap-anti-forgery 79 | (partial form/render-approval-form client scopes)) req)) 80 | 81 | (defn authentication-form-response [req] 82 | (redirect-with-session (settings/authentication-url) 83 | {:landing-url (request-url req)})) 84 | -------------------------------------------------------------------------------- /src/cerber/helpers.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.helpers 2 | (:require [crypto.random :as random] 3 | [clojure.string :as str] 4 | [failjure.core :as f] 5 | [digest]) 6 | (:import [org.mindrot.jbcrypt BCrypt])) 7 | 8 | (defn now [] 9 | (java.sql.Timestamp. (System/currentTimeMillis))) 10 | 11 | (defn now-plus-seconds [seconds] 12 | (when seconds 13 | (java.sql.Timestamp. (+ (System/currentTimeMillis) (* 1000 seconds))))) 14 | 15 | (defn generate-secret 16 | "Generates a unique secret code." 17 | 18 | [] 19 | (random/base32 20)) 20 | 21 | (defn expired? 22 | "Returns true if given item (more specifically its :expires-at value) 23 | is expired or falsey otherwise. Item with no expires-at is non-expirable." 24 | 25 | [item] 26 | (let [^java.sql.Timestamp expires-at (:expires-at item)] 27 | (and expires-at 28 | (.isBefore (.toLocalDateTime expires-at) 29 | (java.time.LocalDateTime/now))))) 30 | 31 | (defn reset-ttl 32 | "Extends time to live of given item by ttl seconds." 33 | 34 | [item ttl] 35 | (assoc item :expires-at (now-plus-seconds ttl))) 36 | 37 | (defn str->coll 38 | "Return a vector of `str` substrings split by space." 39 | 40 | [^String str] 41 | (into [] 42 | (when (and str (> (.length str) 0)) 43 | (str/split str #" ")))) 44 | 45 | (defn coll->str 46 | "Serializes collection of strings into single 47 | (space-separated) string." 48 | 49 | [coll] 50 | (str/join " " coll)) 51 | 52 | (defn str->keywords 53 | [str] 54 | (into #{} (map keyword) (str->coll str))) 55 | 56 | (defn keywords->str 57 | [keywords] 58 | (coll->str (map #(subs (str %) 1) keywords))) 59 | 60 | (defn str->int 61 | "Safely transforms stringified number into an Integer. 62 | Returns a Failure in case of any exception." 63 | 64 | [^String str] 65 | (f/try* (Integer/parseInt str))) 66 | 67 | (defn expires->ttl 68 | "Returns number of seconds between now and expires-at." 69 | 70 | [^java.sql.Timestamp expires-at] 71 | (when expires-at 72 | (.between (java.time.temporal.ChronoUnit/SECONDS) 73 | (java.time.LocalDateTime/now) 74 | (.toLocalDateTime expires-at)))) 75 | 76 | (defn digest 77 | "Applies SHA-256 on given secret." 78 | 79 | [secret] 80 | (digest/sha-256 secret)) 81 | 82 | (defn bcrypt-hash 83 | "Performs BCrypt hashing of password." 84 | 85 | [password] 86 | (BCrypt/hashpw password (BCrypt/gensalt))) 87 | 88 | (defn bcrypt-check 89 | "Validates password against given hash." 90 | 91 | [password hashed] 92 | (BCrypt/checkpw password hashed)) 93 | 94 | (defn uuid 95 | "Generates uuid" 96 | 97 | [] 98 | (.replaceAll (.toString (java.util.UUID/randomUUID)) "-" "")) 99 | 100 | (defn ajax-request? 101 | "Returns true if X-Requested-With header was found with 102 | XMLHttpRequest value, returns false otherwise." 103 | 104 | [headers] 105 | (= (headers "x-requested-with") "XMLHttpRequest")) 106 | 107 | (defn assoc-if-exists 108 | "Assocs k with value v to map m only if there is already k associated." 109 | 110 | [m k v] 111 | (when (m k) (assoc m k v))) 112 | 113 | (defn assoc-if-not-exists 114 | "Assocs k with value v to map m only if no k was associated before." 115 | 116 | [m k v] 117 | (when-not (m k) (assoc m k v))) 118 | 119 | (defn atomic-assoc-or-nil 120 | [a k v f] 121 | (get (swap! a f k v) k)) 122 | 123 | (defmacro cond-as-> 124 | "A mixture of cond-> and as-> allowing more flexibility in the test and step forms. 125 | Stolen from https://juxt.pro/blog/posts/condas.html." 126 | 127 | [expr name & clauses] 128 | (assert (even? (count clauses))) 129 | (let [pstep (fn [[test step]] `(if ~test ~step ~name))] 130 | `(let [~name ~expr 131 | ~@(interleave (repeat name) (map pstep (partition 2 clauses)))] 132 | ~name))) 133 | -------------------------------------------------------------------------------- /src/cerber/error.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.error 2 | (:require [cerber.oauth2.settings :as settings] 3 | [cerber.helpers :as helpers] 4 | [failjure.core :as f] 5 | [ring.util.request :refer [request-url]])) 6 | 7 | (defrecord HttpError [error message code]) 8 | 9 | (extend-protocol f/HasFailed 10 | HttpError 11 | (message [this] (:message this)) 12 | (failed? [this] true)) 13 | 14 | (def invalid-scope 15 | (map->HttpError {:error "invalid_scope" :message "Invalid scope" :code 302})) 16 | 17 | (def invalid-state 18 | (map->HttpError {:error "invalid_state" :message "Invalid state. Only alphanumeric characters are allowed." :code 302})) 19 | 20 | (def access-denied 21 | (map->HttpError {:error "access_denied" :message "Authorization refused" :code 302})) 22 | 23 | (def invalid-request 24 | (map->HttpError {:error "invalid_request" :message "Invalid request" :code 400})) 25 | 26 | (def invalid-token 27 | (map->HttpError {:error "invalid_request" :message "Invalid token" :code 400})) 28 | 29 | (def invalid-redirect-uri 30 | (map->HttpError {:error "invalid_redirect_uri" :message "Invalid redirect URI" :code 400})) 31 | 32 | (def unapproved 33 | (map->HttpError {:error "unapproved" :message "Authorization not approved" :code 403})) 34 | 35 | (def unauthorized 36 | (map->HttpError {:error "unauthorized" :message "Authorization failed" :code 401})) 37 | 38 | (def forbidden 39 | (map->HttpError {:error "forbidden" :message "No permission to the resource" :code 403})) 40 | 41 | (def unsupported-response-type 42 | (map->HttpError {:error "unsupported_response_type" :message "Unsupported response type" :code 400})) 43 | 44 | (def unsupported-grant-type 45 | (map->HttpError {:error "unsupported_grant_type" :message "Unsupported grant type" :code 400})) 46 | 47 | (def server-error 48 | (map->HttpError {:error "server_error" :message "Invalid request" :code 500})) 49 | 50 | (def not-found 51 | (map->HttpError {:error "bad_request" :message "Resource not found" :code 404})) 52 | 53 | (defn internal-error [message] 54 | (map->HttpError {:error "server_error" :message message :code 500})) 55 | 56 | (defn bad-request [message] 57 | (map->HttpError {:error "bad_request" :message message :code 400})) 58 | 59 | (defn error->redirect 60 | "Tranforms error into http redirect response. 61 | Error info is added as query param as described in 4.1.2.1. Error Response of OAuth2 spec" 62 | 63 | [http-error req] 64 | (let [{:keys [code error message]} http-error 65 | {:keys [headers params]} req 66 | {:keys [redirect_uri state]} params] 67 | {:status 302 68 | :headers {"Location" (str redirect_uri 69 | "?error=" error 70 | "&state=" state)}})) 71 | 72 | (defn error->edn 73 | "Tranforms error into http response. 74 | 75 | In case of 401 (unauthorized) and 403 (forbidden) error codes additional WWW-Authenticate 76 | header is returned as described in https://tools.ietf.org/html/rfc6750#section-3" 77 | 78 | [http-error req] 79 | (let [{:keys [code error message]} http-error 80 | {:keys [headers params]} req] 81 | (if (or (= code 401) (= code 403)) 82 | (if (or (get headers "authorization") 83 | (helpers/ajax-request? headers)) 84 | 85 | ;; oauth or ajax request 86 | {:status code 87 | :headers {"WWW-Authenticate" (str "Bearer realm=\"" (settings/realm) 88 | "\",error=\"" error 89 | "\",error_description=\"" message "\"")}} 90 | 91 | ;; browser-based requested 92 | {:status 302 93 | :headers {"Location" (settings/unauthorized-url)} 94 | :session {:landing-url (request-url req)}}) 95 | 96 | ;; uups, something bad happened 97 | (let [state (:state params)] 98 | {:status (or code 500) 99 | :body (-> {:error (or error "server_error") 100 | :error_description message} 101 | (cond-> state 102 | (assoc :state state)))})))) 103 | -------------------------------------------------------------------------------- /test/cerber/stores/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.client-test 2 | (:require [cerber.test-utils :refer [instance-of has-secret with-stores]] 3 | [cerber.oauth2.core :as core] 4 | [midje.sweet :refer :all]) 5 | (:import cerber.error.HttpError 6 | cerber.stores.client.Client)) 7 | 8 | (def info "testing client") 9 | (def scopes ["photo:read"]) 10 | (def grants ["authorization_code" "password"]) 11 | (def redirects ["http://localhost" "http://defunkt.pl"]) 12 | 13 | (tabular 14 | (fact "Redirect URIs must be a valid URLs with no forbidden characters." 15 | (with-stores :in-memory 16 | (core/create-client grants ?redirects 17 | :info info 18 | :scopes scopes 19 | :enabled? true 20 | :approved? false) => ?expected)) 21 | 22 | ?redirects ?expected 23 | ["http://foo.bar.bazz"] truthy 24 | ["foo.bar" "http://bar.foo.com"] (instance-of HttpError) 25 | ["http://foo.bar" ""] (instance-of HttpError) 26 | ["http://foo.bar#bazz"] (instance-of HttpError) 27 | ["http://some.nasty/../hack"] (instance-of HttpError) 28 | ["http://some nasty.hack"] (instance-of HttpError) 29 | ["http://some\tvery.nasty.hack"] (instance-of HttpError)) 30 | 31 | (fact "Created client has a secret code." 32 | (with-stores :in-memory 33 | 34 | ;; given 35 | (let [client (core/create-client grants redirects 36 | :info info 37 | :scopes scopes 38 | :enabled? true 39 | :approved? false)] 40 | ;; then 41 | client => (instance-of Client) 42 | client => (has-secret :secret)))) 43 | 44 | (tabular 45 | (fact "Clients are stored in a correct model." 46 | (with-stores ?store 47 | 48 | ;; given 49 | (let [created (core/create-client grants redirects 50 | :info info 51 | :scopes scopes 52 | :enabled? true 53 | :approved? false) 54 | client (core/find-client (:id created))] 55 | 56 | ;; then 57 | client => (instance-of Client) 58 | client => (has-secret :secret) 59 | client => (contains {:id (:id client) 60 | :info info 61 | :redirects redirects 62 | :grants grants 63 | :scopes scopes 64 | :enabled? true 65 | :approved? false})))) 66 | 67 | ?store :in-memory :redis :sql) 68 | 69 | (tabular 70 | (fact "Revoked client is not returned from store." 71 | (with-stores ?store 72 | 73 | ;; given 74 | (let [client (core/create-client grants redirects 75 | :info info 76 | :scopes scopes 77 | :enabled? true 78 | :approved? false) 79 | client-id (:id client)] 80 | 81 | ;; when 82 | (core/delete-client client-id) 83 | 84 | ;; then 85 | (core/find-client client-id) => nil))) 86 | 87 | ?store :in-memory :sql :redis) 88 | 89 | (tabular 90 | (fact "Revoked client has all its tokens revoked as well." 91 | (with-stores ?store 92 | 93 | ;; given 94 | (let [client (core/create-client grants redirects 95 | :info info 96 | :scopes scopes 97 | :enabled? true 98 | :approved? false) 99 | client-id (:id client)] 100 | 101 | ;; when 102 | (core/delete-client client-id) 103 | 104 | ;; then 105 | (core/find-refresh-tokens client-id) => (just '())))) 106 | 107 | ?store :in-memory :sql :redis) 108 | -------------------------------------------------------------------------------- /src/cerber/store.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.store 2 | (:require [taoensso.carmine :as car] 3 | [clojure.string :as str] 4 | [cerber.helpers :as helpers])) 5 | 6 | (def ^:private select-values 7 | (comp vals select-keys)) 8 | 9 | (defn ^:private ns-key 10 | ([namespace composite nil-to] 11 | (ns-key namespace (mapv #(or %1 nil-to) composite))) 12 | ([namespace composite] 13 | (str namespace "/" (str/join ":" (remove str/blank? composite))))) 14 | 15 | (defprotocol Store 16 | (fetch-one [this k] "Finds single item based on exact key") 17 | (fetch-all [this k] "Finds all items matching pattern key") 18 | (revoke-one! [this k] "Removes item based on exact key") 19 | (revoke-all! [this k] "Removes all items matching pattern key") 20 | (store! [this k item] "Stores new item with key taken from item map at k") 21 | (modify! [this k item] "Modifies item stored at key k") 22 | (touch! [this k item ttl] "Extends life time of given item by ttl seconds") 23 | (purge! [this] "Purges store") 24 | (close! [this] "Closes store and frees all allocated resources")) 25 | 26 | ;; basic in-memory store implementations 27 | 28 | (defrecord MemoryStore [namespace store] 29 | Store 30 | (fetch-one [this k] 31 | (get @store (ns-key namespace k))) 32 | (fetch-all [this k] 33 | (let [matcher (re-pattern (ns-key namespace k ".*"))] 34 | (vals (filter (fn [[s _]] (re-matches matcher s)) @store)))) 35 | (revoke-one! [this k] 36 | (swap! store dissoc (ns-key namespace k))) 37 | (revoke-all! [this k] 38 | (let [matcher (re-pattern (ns-key namespace k ".*"))] 39 | (doseq [[s v] @store] 40 | (when (re-matches matcher s) (swap! store dissoc s))))) 41 | (store! [this k item] 42 | (let [nskey (ns-key namespace (select-values item k))] 43 | (helpers/atomic-assoc-or-nil store nskey item helpers/assoc-if-not-exists))) 44 | (modify! [this k item] 45 | (let [nskey (ns-key namespace (select-values item k))] 46 | (helpers/atomic-assoc-or-nil store nskey item helpers/assoc-if-exists))) 47 | (touch! [this k item ttl] 48 | (.modify! this k (helpers/reset-ttl item ttl))) 49 | (purge! [this] 50 | (swap! store empty)) 51 | (close! [this] 52 | (reset! store nil))) 53 | 54 | ;; Redis-based store implementation 55 | 56 | (defn- scan-by-key [spec key] 57 | (car/reduce-scan 58 | (fn rf [acc in] (into acc in)) 59 | [] 60 | (fn scan-fn [cursor] (car/wcar spec (car/scan cursor :match key))))) 61 | 62 | (defn- store-with-opt [ns item k conn opt] 63 | (let [nskey (ns-key ns (select-values item k)) 64 | seconds (helpers/expires->ttl (:expires-at item)) 65 | result (car/wcar conn (if (and seconds (> seconds 0)) 66 | (car/set nskey item "EX" seconds opt) 67 | (car/set nskey item opt)))] 68 | (when (or (= result 1) 69 | (= result "OK")) 70 | item))) 71 | 72 | (defrecord RedisStore [namespace server-conn] 73 | Store 74 | (fetch-one [this k] 75 | (let [nskey (ns-key namespace k) 76 | [item ttl] (car/wcar server-conn 77 | (car/get nskey) 78 | (car/ttl nskey))] 79 | (when item 80 | (assoc item :expires-at (and ttl (helpers/now-plus-seconds ttl)))))) 81 | (fetch-all [this k] 82 | (when-let [result (scan-by-key server-conn (ns-key namespace k "*"))] 83 | (filter (complement nil?) 84 | (car/wcar server-conn (apply (partial car/mget server-conn) result))))) 85 | (revoke-one! [this k] 86 | (car/wcar server-conn (car/del (ns-key namespace k)))) 87 | (revoke-all! [this k] 88 | (if-let [result (scan-by-key server-conn (ns-key namespace k "*"))] 89 | (car/wcar server-conn (doseq [s result] (car/del s))))) 90 | (store! [this k item] 91 | (store-with-opt namespace item k server-conn "NX")) 92 | (modify! [this k item] 93 | (store-with-opt namespace item k server-conn "XX")) 94 | (touch! [this k item ttl] 95 | (let [nskey (ns-key namespace (select-values item k))] 96 | (when (= (car/wcar server-conn (car/expire nskey ttl)) 1) 97 | (helpers/reset-ttl item ttl)))) 98 | (purge! [this] 99 | (try 100 | (car/wcar server-conn (car/flushdb)) 101 | (catch java.io.EOFException e 102 | (if-let [msg (.getMessage e)] 103 | (println msg))))) 104 | (close! [this] 105 | )) 106 | -------------------------------------------------------------------------------- /test/cerber/test_utils.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.test-utils 2 | (:require [cerber.db :as db] 3 | [cerber.store :refer :all] 4 | [cerber.oauth2.core :as core] 5 | [conman.core :as conman] 6 | [clojure.data.codec.base64 :as b64] 7 | [peridot.core :refer [request header]]) 8 | (:import redis.embedded.RedisServer)) 9 | 10 | (def redis-spec {:spec {:host "localhost" 11 | :port 6380}}) 12 | 13 | (def jdbc-spec {:init-size 1 14 | :min-idle 1 15 | :max-idle 4 16 | :max-active 32 17 | :jdbc-url "jdbc:h2:mem:testdb;MODE=MySQL;INIT=RUNSCRIPT FROM 'classpath:/db/migrations/h2/cerber_schema.sql'"}) 18 | 19 | ;; connection to testing H2 instance 20 | (defonce db-conn 21 | (and (Class/forName "org.h2.Driver") 22 | (conman/connect! jdbc-spec))) 23 | 24 | ;; connection to testing redis instance 25 | (defonce redis-instance 26 | (when-let [redis (RedisServer. (Integer. (-> redis-spec :spec :port)))] 27 | (.start redis))) 28 | 29 | ;; some additional midje checkers 30 | 31 | (defn has-secret [field] 32 | (fn [actual] 33 | (boolean (seq (get actual field))))) 34 | 35 | (defn instance-of [clazz] 36 | (fn [actual] 37 | (instance? clazz actual))) 38 | 39 | ;; additional helpers 40 | 41 | (defn extract-csrf [state] 42 | (second 43 | (re-find #"__anti\-forgery\-token.*value=\"([^\"]+)\"" (get-in state [:response :body])))) 44 | 45 | (defn extract-access-code [state] 46 | (when-let [loc (get-in state [:response :headers "Location"])] 47 | (second 48 | (re-find #"code=([^\&]+)" loc)))) 49 | 50 | (defn base64-auth [client] 51 | (String. (b64/encode (.getBytes (str (:id client) ":" (:secret client)))) "UTF-8")) 52 | 53 | (defn random-string [n] 54 | (let [chars (map char (range 97 122)) ;; a-z 55 | login (take n (repeatedly #(rand-nth chars)))] 56 | (reduce str login))) 57 | 58 | (defn request-secured [state & opts] 59 | (let [token (extract-csrf state)] 60 | (apply request state (map #(if (map? %) (assoc % "__anti-forgery-token" token) %) opts)))) 61 | 62 | (defn request-authorized [req url token] 63 | (-> req 64 | (header "Authorization" (str "Bearer " token)) 65 | (request url) 66 | :response 67 | :body)) 68 | 69 | (defn create-test-user 70 | "Creates a test user - enabled by default." 71 | 72 | [& {:keys [login password enabled?] :or {enabled? true}}] 73 | (core/create-user (or login (random-string 12)) 74 | (or password (random-string 8)) 75 | :enabled? enabled?)) 76 | 77 | (defn create-test-client 78 | "Creates test client - enabled and unapproved by default." 79 | 80 | [redirect-uri & {:keys [scope approved?]}] 81 | (core/create-client ["authorization_code" "token" "password" "client_credentials"] 82 | [redirect-uri] 83 | :info "test client" 84 | :scopes [scope] 85 | :enabled? true 86 | :approved? (boolean approved?))) 87 | 88 | (defn disable-test-user [login] 89 | (core/disable-user login)) 90 | 91 | (defn disable-test-client [client-id] 92 | (core/disable-client client-id)) 93 | 94 | (defn enable-test-user [login] 95 | (core/enable-user login)) 96 | 97 | (defn init-stores 98 | "Initializes all the OAuth2 stores of given type and 99 | purges data kept by underlaying databases (H2 and redis)." 100 | 101 | [type store-params] 102 | [(core/create-user-store type store-params) 103 | (core/create-client-store type store-params) 104 | (core/create-authcode-store type store-params) 105 | (core/create-session-store type store-params) 106 | (core/create-token-store type store-params)]) 107 | 108 | (defn purge-stores 109 | "Clears all the database-related stuff as one connection 110 | to redis & H2 is used across all the tests." 111 | 112 | [stores] 113 | (doseq [store stores] (purge! store))) 114 | 115 | (defn close-stores 116 | "Closes all the stores provided in a collection." 117 | 118 | [stores] 119 | (doseq [store stores] (close! store))) 120 | 121 | (defmacro with-stores 122 | [type & body] 123 | `(let [stores# (init-stores ~type (condp = ~type 124 | :sql db-conn 125 | :redis redis-spec 126 | nil))] 127 | (purge-stores stores#) 128 | ~@body 129 | (close-stores stores#))) 130 | -------------------------------------------------------------------------------- /src/cerber/oauth2/authorization.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.authorization 2 | (:require [cerber.error :as error] 3 | [cerber.form :as form] 4 | [cerber.oauth2.context :as ctx] 5 | [cerber.oauth2.response :as response] 6 | [cerber.stores.token :as token] 7 | [failjure.core :as f])) 8 | 9 | (defmulti authorization-request-handler (comp :response_type :params)) 10 | 11 | (defmulti token-request-handler (comp :grant_type :params)) 12 | 13 | ;; authorization request handler for Authorization Code grant type 14 | 15 | (defmethod authorization-request-handler "code" 16 | [req] 17 | (let [result (f/attempt-> req 18 | (ctx/client-valid?) 19 | (ctx/redirect-allowed?) 20 | (ctx/state-allowed?) 21 | (ctx/scopes-allowed?) 22 | (ctx/user-authenticated?) 23 | (ctx/request-approved?))] 24 | (if (f/failed? result) 25 | result 26 | (response/redirect-with-code result)))) 27 | 28 | ;; authorization request handler for Implict grant type 29 | 30 | (defmethod authorization-request-handler "token" 31 | [req] 32 | (let [result (f/attempt-> req 33 | (ctx/client-valid?) 34 | (ctx/redirect-allowed?) 35 | (ctx/grant-allowed? "token") 36 | (ctx/state-allowed?) 37 | (ctx/scopes-allowed?) 38 | (ctx/user-authenticated?))] 39 | (if (f/failed? result) 40 | result 41 | (response/redirect-with-token result)))) 42 | 43 | ;; default response handler for unknown grant types 44 | 45 | (defmethod authorization-request-handler :default 46 | [req] 47 | error/unsupported-response-type) 48 | 49 | ;; token request handler for Authorization Code grant type 50 | 51 | (defmethod token-request-handler "authorization_code" 52 | [req] 53 | (let [result (f/attempt-> req 54 | (ctx/client-authenticated?) 55 | (ctx/grant-allowed? "authorization_code") 56 | (ctx/authcode-valid?) 57 | (ctx/redirect-valid?) 58 | (ctx/user-valid?))] 59 | (if (f/failed? result) 60 | result 61 | (response/access-token-response result)))) 62 | 63 | ;; token request handler for Resource Owner Password Credentials grant 64 | 65 | (defmethod token-request-handler "password" 66 | [req] 67 | (let [result (f/attempt-> req 68 | (ctx/client-authenticated?) 69 | (ctx/grant-allowed? "password") 70 | (ctx/scopes-allowed?) 71 | (ctx/user-password-valid? form/default-authenticator))] 72 | (if (f/failed? result) 73 | result 74 | (response/access-token-response result)))) 75 | 76 | ;; token request handler for Client Credentials grant 77 | 78 | (defmethod token-request-handler "client_credentials" 79 | [req] 80 | (let [result (f/attempt-> req 81 | (ctx/client-authenticated?) 82 | (ctx/grant-allowed? "client_credentials") 83 | (ctx/scopes-allowed?))] 84 | (if (f/failed? result) 85 | result 86 | (response/access-token-response result)))) 87 | 88 | ;; refresh-token request handler 89 | 90 | (defmethod token-request-handler "refresh_token" 91 | [req] 92 | (let [result (f/attempt-> req 93 | (ctx/client-authenticated?) 94 | (ctx/refresh-token-valid?) 95 | (ctx/scopes-allowed?))] 96 | (if (f/failed? result) 97 | result 98 | (response/refresh-token-response result)))) 99 | 100 | ;; default response handler for unknown token requests 101 | 102 | (defmethod token-request-handler :default 103 | [req] 104 | error/unsupported-grant-type) 105 | 106 | (defn authorize! [req] 107 | (let [{:keys [client scopes] :as response} (authorization-request-handler req)] 108 | (condp = (:error response) 109 | "unapproved" (response/approval-form-response req client scopes) 110 | "unauthorized" (response/authentication-form-response req) 111 | response))) 112 | 113 | (defn unauthorize! [req] 114 | (let [user (::ctx/user req) 115 | client (::ctx/client req)] 116 | 117 | (when client 118 | (token/revoke-client-tokens client user)) 119 | 120 | (if (or user client) 121 | (response/redirect-with-session "/" nil) 122 | error/unauthorized))) 123 | 124 | (defn approve! [req] 125 | (authorize! (ctx/approve-authorization req))) 126 | 127 | (defn refuse! [req] 128 | error/access-denied) 129 | 130 | (defn issue-token! [req] 131 | (token-request-handler req)) 132 | -------------------------------------------------------------------------------- /src/cerber/stores/client.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.client 2 | "Functions handling OAuth2 client storage." 3 | (:require [cerber.stores.token :as token] 4 | [cerber 5 | [db :as db] 6 | [error :as error] 7 | [helpers :as helpers] 8 | [mappers :as mappers] 9 | [store :refer :all]] 10 | [failjure.core :as f])) 11 | 12 | (def client-store (atom :not-initialized)) 13 | 14 | (defrecord Client [id secret info redirects grants scopes approved? enabled? created-at modified-at activated-at blocked-at]) 15 | 16 | (defrecord SqlClientStore [] 17 | Store 18 | (fetch-one [this [client-id]] 19 | (some-> (db/find-client {:id client-id}) 20 | mappers/row->client)) 21 | (revoke-one! [this [client-id]] 22 | (db/delete-client {:id client-id})) 23 | (store! [this k client] 24 | (= 1 (db/insert-client (-> client 25 | (update :scopes helpers/coll->str) 26 | (update :grants helpers/coll->str) 27 | (update :redirects helpers/coll->str))))) 28 | (modify! [this k client] 29 | (if (:enabled? client) 30 | (db/enable-client client) 31 | (db/disable-client client))) 32 | (purge! [this] 33 | (db/clear-clients)) 34 | (close! [this] 35 | )) 36 | 37 | (defmulti create-client-store (fn [type config] type)) 38 | 39 | (defmethod create-client-store :in-memory [_ _] 40 | (->MemoryStore "clients" (atom {}))) 41 | 42 | (defmethod create-client-store :redis [_ redis-spec] 43 | (->RedisStore "clients" redis-spec)) 44 | 45 | (defmethod create-client-store :sql [_ db-conn] 46 | (when db-conn 47 | (db/bind-queries db-conn) 48 | (->SqlClientStore))) 49 | 50 | (defn init-store 51 | "Initializes client store according to given type and configuration." 52 | 53 | [type config] 54 | (reset! client-store (create-client-store type config))) 55 | 56 | (defn validate-uri 57 | "Returns java.net.URL instance of given uri or failure info in case of error." 58 | 59 | [uri] 60 | (if (empty? uri) 61 | (error/internal-error "redirect-uri cannot be empty") 62 | (if (or (>= (.indexOf uri "#") 0) 63 | (>= (.indexOf uri "..") 0) 64 | (.matches uri ".*\\s+.*")) 65 | (error/internal-error "Illegal characters in redirect URI") 66 | (try 67 | (java.net.URL. uri) 68 | (catch Exception e (error/internal-error (.getMessage e))))))) 69 | 70 | (defn validate-redirects 71 | "Goes through all redirects and returns list of validation failures." 72 | 73 | [redirects] 74 | (filter f/failed? (map validate-uri redirects))) 75 | 76 | (defn find-client 77 | "Returns a client with given id if any found or nil otherwise." 78 | 79 | [client-id] 80 | (when-let [found (and client-id (fetch-one @client-store [client-id]))] 81 | (map->Client found))) 82 | 83 | (defn revoke-client 84 | "Revokes previously generated client and all tokens generated to this client so far." 85 | 86 | [client] 87 | (let [id (:id client)] 88 | (revoke-one! @client-store [id]) 89 | (token/revoke-client-tokens client))) 90 | 91 | (defn purge-clients 92 | "Removes clients from store." 93 | 94 | [] 95 | (purge! @client-store)) 96 | 97 | (defn enable-client 98 | "Enables client. Returns true if client has been enabled successfully or false otherwise." 99 | 100 | [client] 101 | (= 1 (modify! @client-store [:id] (assoc client :enabled? true :activated-at (helpers/now))))) 102 | 103 | (defn disable-client 104 | "Disables client. Returns true if client has been disabled successfully or false otherwise." 105 | 106 | [client] 107 | (token/revoke-client-tokens client) 108 | (= 1 (modify! @client-store [:id] (assoc client :enabled? false :blocked-at (helpers/now))))) 109 | 110 | (defn create-client 111 | "Creates and returns a new client." 112 | 113 | [grants redirects {:keys [info scopes enabled? approved? id secret]}] 114 | (let [result (validate-redirects redirects) 115 | client {:id (or id (helpers/generate-secret)) 116 | :secret (or secret (helpers/generate-secret)) 117 | :info info 118 | :approved? (boolean approved?) 119 | :enabled? (boolean enabled?) 120 | :scopes scopes 121 | :grants grants 122 | :redirects redirects 123 | :activated-at (helpers/now) 124 | :created-at (helpers/now)}] 125 | 126 | (if (seq result) 127 | (error/internal-error (first result)) 128 | (if (store! @client-store [:id] client) 129 | (map->Client client) 130 | (error/internal-error "Cannot store client"))))) 131 | 132 | (defn grant-allowed? 133 | [client grant] 134 | (when-let [grants (:grants client)] 135 | (.contains grants grant))) 136 | 137 | (defn redirect-uri-valid? 138 | [client redirect-uri] 139 | (.contains (:redirects client) redirect-uri)) 140 | 141 | (defn scopes-valid? 142 | "Checks whether given scopes are valid ones assigned to client." 143 | 144 | [client scopes] 145 | (let [client-scopes (:scopes client)] 146 | (or (empty? scopes) 147 | (every? #(.contains client-scopes %) scopes)))) 148 | -------------------------------------------------------------------------------- /src/cerber/oauth2/context.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.context 2 | (:require [cerber.error :as error] 3 | [cerber.helpers :as helpers] 4 | [cerber.oauth2.authenticator :refer [Authenticator]] 5 | [cerber.oauth2.scopes :as scopes] 6 | [cerber.stores.authcode :as authcode] 7 | [cerber.stores.client :as client] 8 | [cerber.stores.token :as token] 9 | [cerber.stores.user :as user] 10 | [failjure.core :as f]) 11 | (:import org.apache.commons.codec.binary.Base64)) 12 | 13 | (def state-pattern #"\p{Alnum}+") 14 | 15 | (defn basic-authentication-credentials 16 | "Decodes basic authentication credentials. 17 | If it exists it returns a vector of username and password. Returns nil otherwise." 18 | [req] 19 | (when-let [auth-string ((req :headers {}) "authorization")] 20 | (when-let [^String basic-token (last (re-find #"^Basic (.*)$" auth-string))] 21 | (when-let [credentials (String. (Base64/decodeBase64 basic-token))] 22 | (.split credentials ":"))))) 23 | 24 | (defn state-allowed? [req] 25 | (let [state (get-in req [:params :state])] 26 | (f/attempt-all [valid? (or (nil? state) 27 | (re-matches state-pattern state) 28 | error/invalid-state)] 29 | (assoc req ::state state)))) 30 | 31 | (defn scopes-allowed? [req] 32 | (let [scope (get-in req [:params :scope])] 33 | (f/attempt-all [scopes (scopes/normalize-scope scope) 34 | valid? (or (client/scopes-valid? (::client req) scopes) error/invalid-scope)] 35 | (assoc req ::scopes scopes)))) 36 | 37 | (defn grant-allowed? [req grant] 38 | (f/attempt-all [allowed? (or (client/grant-allowed? (::client req) grant) error/unsupported-grant-type)] 39 | (assoc req ::grant grant))) 40 | 41 | (defn redirect-allowed? [req] 42 | (f/attempt-all [redirect-uri (get-in req [:params :redirect_uri] error/invalid-request) 43 | allowed? (or (client/redirect-uri-valid? (::client req) redirect-uri) error/invalid-redirect-uri)] 44 | (assoc req ::redirect-uri (.replaceAll ^String redirect-uri "/\\z" "")))) 45 | 46 | (defn redirect-valid? [req] 47 | (f/attempt-all [redirect-uri (get-in req [:params :redirect_uri] error/invalid-request) 48 | valid? (or (= redirect-uri (:redirect-uri (::authcode req))) error/invalid-request)] 49 | (assoc req ::redirect-uri (.replaceAll ^String redirect-uri "/\\z" "")))) 50 | 51 | (defn authcode-valid? [req] 52 | (f/attempt-all [code (get-in req [:params :code] error/invalid-request) 53 | authcode (or (authcode/find-authcode code) error/invalid-request) 54 | valid? (or (and (= (:client-id authcode) (:id (::client req))) 55 | (not (helpers/expired? authcode))) 56 | error/invalid-request)] 57 | (assoc req ::authcode authcode))) 58 | 59 | (defn refresh-token-valid? [req] 60 | (let [client-id (:id (::client req))] 61 | (f/attempt-all [secret (get-in req [:params :refresh_token] error/invalid-request) 62 | rtoken (or (token/find-refresh-token client-id secret) error/invalid-token) 63 | valid? (or (= client-id (:client-id rtoken)) error/invalid-token)] 64 | (assoc req ::refresh-token rtoken)))) 65 | 66 | (defn bearer-valid? [req] 67 | (f/attempt-all [authorization (get-in req [:headers "authorization"] error/unauthorized) 68 | bearer (or (second (.split ^String authorization " ")) error/unauthorized) 69 | token (or (token/find-access-token bearer) error/invalid-token) 70 | valid? (or (not (helpers/expired? token)) error/invalid-token) 71 | login (:login token) 72 | user (user/find-user login) 73 | enabled? (or 74 | 75 | ;; in client_credentials scenario no user login is stored. 76 | ;; all other scenarios should have user login passed in a token. 77 | (nil? login) 78 | 79 | ;; consider enabled users only 80 | (:enabled? user) 81 | 82 | ;; no such a user or user disabled? 83 | (error/bad-request "User disabled"))] 84 | 85 | (let [scopes (helpers/str->coll (:scope token))] 86 | (assoc req 87 | ::client {:id (:client-id token) 88 | :scopes scopes} 89 | ::user {:id (:user-id token) 90 | :login login 91 | :name (:name user) 92 | :roles (:roles user)})))) 93 | 94 | (defn user-valid? [req] 95 | (let [login (:login (::authcode req))] 96 | (f/attempt-all [user (or (user/find-user login) (error/bad-request "Invalid user")) 97 | enabled? (or (:enabled? user) (error/bad-request "User disabled"))] 98 | (assoc req ::user user)))) 99 | 100 | (defn client-valid? [req] 101 | (f/attempt-all [client-id (get-in req [:params :client_id] (error/bad-request "No client_id provided")) 102 | client (or (client/find-client client-id) (error/bad-request "Invalid client")) 103 | valid? (or (:enabled? client) (error/bad-request "Client disabled"))] 104 | (assoc req ::client client))) 105 | 106 | (defn client-authenticated? [req] 107 | (f/attempt-all [auth (or (basic-authentication-credentials req) error/unauthorized) 108 | client (or (client/find-client (first auth)) (error/bad-request "Invalid client")) 109 | valid? (or (= (second auth) (:secret client)) (error/bad-request "Invalid secret"))] 110 | (assoc req ::client client))) 111 | 112 | (defn user-authenticated? [req] 113 | (let [user (user/find-user (-> req :session :login))] 114 | (or (and (:enabled? user) 115 | (assoc req ::user (select-keys user [:id :login :name :roles :enabled?]))) 116 | error/unauthorized))) 117 | 118 | (defn user-password-valid? [req ^cerber.oauth2.authenticator.Authenticator authenticator] 119 | (f/attempt-all [username (get-in req [:params :username] error/invalid-request) 120 | password (get-in req [:params :password] error/invalid-request) 121 | user (or (.authenticate authenticator username password) error/unauthorized)] 122 | (assoc req ::user user))) 123 | 124 | (defn request-approved? [req] 125 | (if (or (::approved? req) 126 | (:approved? (::client req))) 127 | req 128 | (assoc error/unapproved 129 | :client (::client req) 130 | :scopes (::scopes req)))) 131 | 132 | (defn approve-authorization [req] 133 | (assoc req ::approved? true)) 134 | -------------------------------------------------------------------------------- /test/cerber/stores/token_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.token-test 2 | (:require [cerber.helpers :as helpers] 3 | [cerber.oauth2.core :as core] 4 | [cerber.stores.token :refer [create-token generate-access-token]] 5 | [cerber.test-utils :refer [has-secret instance-of create-test-user create-test-client with-stores]] 6 | [midje.sweet :refer :all] 7 | [cerber.oauth2.settings :as settings]) 8 | (:import cerber.stores.token.Token 9 | cerber.error.HttpError)) 10 | 11 | (def redirect-uri "http://localhost") 12 | (def scope "photo:read") 13 | 14 | (tabular 15 | (fact "Generated access-token contains required fields." 16 | (with-stores ?store 17 | 18 | ;; given 19 | (let [user (create-test-user) 20 | client (create-test-client redirect-uri :scope scope) 21 | token (generate-access-token client user scope true)] 22 | 23 | ;; then 24 | token => (has-secret :access_token) 25 | token => (contains {:scope scope 26 | :token_type "Bearer" 27 | :expires_in (settings/token-valid-for)})))) 28 | 29 | ?store :in-memory :redis :sql) 30 | 31 | (tabular 32 | (fact "Access-token points to matching refresh-token." 33 | (with-stores ?store 34 | 35 | ;; given 36 | (let [user (create-test-user) 37 | client (create-test-client redirect-uri :scope scope) 38 | token (generate-access-token client user scope true) 39 | refresh (first (core/find-refresh-tokens (:id client)))] 40 | 41 | ;; then 42 | (helpers/digest (:refresh_token token)) => (:secret refresh)))) 43 | 44 | ?store :in-memory :redis :sql) 45 | 46 | (tabular 47 | (fact "Access- and refresh-tokens are stored in a correct model." 48 | (with-stores ?store 49 | 50 | ;; given 51 | (let [user (create-test-user) 52 | client (create-test-client redirect-uri :scope scope) 53 | token (generate-access-token client user scope true) 54 | access (core/find-access-token (:access_token token)) 55 | refresh (first (core/find-refresh-tokens (:id client)))] 56 | 57 | ;; then 58 | access => (instance-of Token) 59 | access => (has-secret :secret) 60 | access => (contains {:client-id (:id client) 61 | :user-id (:id user) 62 | :login (:login user) 63 | :scope scope}) 64 | 65 | refresh => (instance-of Token) 66 | refresh => (has-secret :secret) 67 | refresh => (contains {:client-id (:id client) 68 | :user-id (:id user) 69 | :login (:login user) 70 | :scope scope}) 71 | 72 | (:expires-at access) =not=> nil 73 | (:expires-at refresh) => nil))) 74 | 75 | ?store :in-memory :redis :sql) 76 | 77 | (tabular 78 | (fact "Revoked access-token is not returned from store." 79 | (with-stores ?store 80 | 81 | ;; given 82 | (let [user (create-test-user) 83 | client (create-test-client redirect-uri :scope scope) 84 | token (generate-access-token client user scope true) 85 | secret (:access_token token)] 86 | 87 | ;; when 88 | (core/find-access-token secret) => (instance-of Token) 89 | (core/revoke-access-token secret) 90 | 91 | ;; then 92 | (core/find-access-token secret) => nil))) 93 | 94 | ?store :in-memory :redis :sql) 95 | 96 | (tabular 97 | (fact "Revoked client tokens are not returned from store." 98 | (with-stores ?store 99 | 100 | ;; given 101 | (let [user (create-test-user) 102 | client (create-test-client redirect-uri :scope scope) 103 | token (generate-access-token client user scope true) 104 | secret (:access_token token)] 105 | 106 | (core/find-access-token secret) => (instance-of Token) 107 | (first (core/find-refresh-tokens (:id client))) => (instance-of Token) 108 | 109 | ;; when 110 | (core/revoke-client-tokens (:id client)) 111 | 112 | ;; then 113 | (core/find-access-token secret) => nil 114 | (core/find-refresh-tokens (:id client)) => (just '())))) 115 | 116 | ?store :in-memory :redis :sql) 117 | 118 | (tabular 119 | (fact "Revoked client tokens for given user are not returned from store." 120 | (with-stores ?store 121 | 122 | ;; given 123 | (let [user1 (create-test-user) 124 | user2 (create-test-user) 125 | client (create-test-client redirect-uri :scope scope) 126 | token1 (generate-access-token client user1 scope true) 127 | token2 (generate-access-token client user2 scope true)] 128 | 129 | (core/find-access-token (:access_token token1)) => (instance-of Token) 130 | (core/find-access-token (:access_token token2)) => (instance-of Token) 131 | 132 | ;; when 133 | (core/revoke-client-tokens (:id client) (:login user1)) 134 | 135 | ;; then 136 | (core/find-access-token (:access_token token1)) => nil 137 | (core/find-access-token (:access_token token2)) =not=> nil 138 | (count (core/find-refresh-tokens (:id client))) => 1))) 139 | 140 | ?store :in-memory :redis :sql) 141 | 142 | (tabular 143 | (fact "Regenerated tokens override and revoke old ones." 144 | (with-stores ?store 145 | 146 | ;; given 147 | (let [user (create-test-user) 148 | client (create-test-client redirect-uri :scope scope) 149 | access-token (generate-access-token client user scope true) 150 | refresh-token (first (core/find-refresh-tokens (:id client)))] 151 | 152 | ;; when 153 | (let [new-token (core/regenerate-tokens (:id client) 154 | (:login user) 155 | scope)] 156 | ;; then 157 | (= (:access_token new-token) (:access_token access-token)) => false 158 | (= (:refresh_token new-token) (:refresh_token access-token)) => false 159 | 160 | (core/find-access-token (:access_token access-token)) => nil 161 | (count (core/find-refresh-tokens (:id client))) => 1)))) 162 | 163 | ?store :in-memory :redis :sql) 164 | 165 | (fact "Tokens cannot be generated for disabled user or client." 166 | (with-stores :in-memory 167 | 168 | ;; given 169 | (let [user (create-test-user) 170 | client (create-test-client redirect-uri :scope scope)] 171 | 172 | ;; and 173 | (core/disable-client (:id client)) 174 | 175 | ;; when 176 | (let [access-token (core/regenerate-tokens (:id client) 177 | (:login user) 178 | scope)] 179 | 180 | ;; then 181 | access-token => (instance-of HttpError) 182 | (first (core/find-refresh-tokens (:id client))) => nil)))) 183 | 184 | (fact "Tokens with expires-at date in the past are considered as expired ones." 185 | (with-stores :in-memory 186 | 187 | ;; given 188 | (let [user (create-test-user) 189 | client (create-test-client redirect-uri :scope scope)] 190 | 191 | ;; when 192 | (helpers/expired? 193 | (create-token :access client user scope -10))) => true)) 194 | -------------------------------------------------------------------------------- /src/cerber/stores/token.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.stores.token 2 | "Functions handling OAuth2 token storage." 3 | (:require [cerber.oauth2.settings :as settings] 4 | [cerber 5 | [db :as db] 6 | [error :as error] 7 | [helpers :as helpers] 8 | [mappers :as mappers] 9 | [store :refer :all]] 10 | [failjure.core :as f])) 11 | 12 | (def token-store (atom :not-initialized)) 13 | 14 | (defrecord Token [client-id user-id login scope secret created-at expires-at]) 15 | 16 | (defrecord SqlTokenStore [expired-tokens-cleaner] 17 | Store 18 | (fetch-one [this [ttype secret client-id login]] 19 | (some-> (db/find-tokens-by-secret {:secret secret :ttype ttype}) 20 | mappers/row->token)) 21 | (fetch-all [this [ttype secret client-id login]] 22 | (map mappers/row->token 23 | (db/find-tokens-by-client {:ttype ttype :client-id client-id}))) 24 | (revoke-one! [this [ttype secret client-id login]] 25 | (db/delete-token-by-secret {:secret secret})) 26 | (revoke-all! [this [ttype secret client-id login]] 27 | (if login 28 | (db/delete-tokens-by-login {:client-id client-id :login login :ttype ttype}) 29 | (db/delete-tokens-by-client {:client-id client-id :ttype ttype}))) 30 | (store! [this k token] 31 | (= 1 (db/insert-token token))) 32 | (purge! [this] 33 | (db/clear-tokens)) 34 | (close! [this] 35 | (db/stop-periodic expired-tokens-cleaner))) 36 | 37 | (defmulti create-token-store (fn [type config] type)) 38 | 39 | (defmethod create-token-store :in-memory [_ _] 40 | (->MemoryStore "tokens" (atom {}))) 41 | 42 | (defmethod create-token-store :redis [_ redis-spec] 43 | (->RedisStore "tokens" redis-spec)) 44 | 45 | (defmethod create-token-store :sql [_ db-conn] 46 | (when db-conn 47 | (db/bind-queries db-conn) 48 | (->SqlTokenStore (db/make-periodic 'cerber.db/clear-expired-tokens 60000)))) 49 | 50 | (defn init-store 51 | "Initializes token store according to given type and configuration." 52 | 53 | [type config] 54 | (reset! token-store (create-token-store type config))) 55 | 56 | (defn create-token 57 | "Creates and retuns new token." 58 | 59 | [ttype client user scope & [ttl access-secret]] 60 | (let [secret (helpers/generate-secret) 61 | token (helpers/reset-ttl 62 | {:client-id (:id client) 63 | :user-id (:id user) 64 | :login (:login user) 65 | :secret secret 66 | :scope scope 67 | :ttype (name ttype) 68 | :created-at (helpers/now) 69 | :access-secret (helpers/digest access-secret)} 70 | (and (= ttype :access) (or ttl (settings/token-valid-for)))) 71 | keyvec (if (= ttype :access) 72 | [:ttype :secret] 73 | [:ttype :secret :client-id :login])] 74 | 75 | (if (store! @token-store keyvec (update token :secret helpers/digest)) 76 | (map->Token token) 77 | (error/internal-error "Cannot create token")))) 78 | 79 | ;; retrieval 80 | 81 | (defn find-by-pattern 82 | "Finds token by vectorized pattern key. Each nil element of key will be 83 | replaced with wildcard specific for underlaying store implementation." 84 | 85 | [key] 86 | (map map->Token (fetch-all @token-store key))) 87 | 88 | (defn find-by-key 89 | "Finds token by vectorized exact key. Each element of key is used to compose 90 | query depending on underlaying store implementation." 91 | 92 | [key] 93 | (when-let [result (fetch-one @token-store key)] 94 | (map->Token result))) 95 | 96 | (defn find-access-token 97 | "Finds access token issued for given client with given secret code." 98 | 99 | [secret] 100 | (find-by-key ["access" (helpers/digest secret)])) 101 | 102 | (defn find-refresh-token 103 | "Finds refresh token issued for given client with given secret code." 104 | 105 | [client-id secret] 106 | (find-by-key ["refresh" (helpers/digest secret)])) 107 | 108 | (defn purge-tokens 109 | "Removes token from store." 110 | 111 | [] 112 | (purge! @token-store)) 113 | 114 | ;; revocation 115 | 116 | (defn revoke-by-pattern 117 | [pattern] 118 | (revoke-all! @token-store pattern) nil) 119 | 120 | (defn revoke-by-key 121 | [key] 122 | (revoke-one! @token-store key) nil) 123 | 124 | (defn revoke-access-token 125 | [secret] 126 | (revoke-by-key ["access" (helpers/digest secret)])) 127 | 128 | (defn revoke-client-tokens 129 | "Revokes access- and refresh-tokens of given client (and user optionally)." 130 | ([client] 131 | (revoke-client-tokens client nil)) 132 | ([client user] 133 | (let [login (:login user) 134 | client-id (:id client)] 135 | 136 | ;; access-tokens are kept in no-sql stores under a key: access: for performance reasons, 137 | ;; but that makes them hard to revoke as there is no client/login information attached to the key. 138 | ;; on the other hand, refresh-tokens are stored under key: refresh::: 139 | ;; which makes them pretty easy to filter. to overcome a problem with not-searchable access-tokens 140 | ;; their secrets are additionally stored along with refresh-tokens (as access-secret field). 141 | ;; this way, having a refresh token for given client/login found, we also immediately have an 142 | ;; access-token, which makes them both easily revokable. 143 | ;; 144 | ;; this is not a case for sql-stores which hold client/login information for both kind of tokens. 145 | ;; concluding, if there is no access-secret found in a refresh-token, it means it was fetched from 146 | ;; sql-store so both tokens may be revoked using `revoke-by-pattern`. 147 | 148 | (let [refresh-tokens (find-by-pattern ["refresh" nil client-id login])] 149 | (when (-> refresh-tokens first :access-secret) 150 | (doseq [token refresh-tokens] 151 | (revoke-by-key ["access" (:access-secret token)])))) 152 | 153 | ;; this won't work for no-sql stores 154 | (revoke-by-pattern ["access" nil client-id login]) 155 | ;; this works for both kind of stores 156 | (revoke-by-pattern ["refresh" nil client-id login])))) 157 | 158 | ;; generation 159 | 160 | (defn generate-access-token 161 | "Generates access-token for given client-login pair within provided scope. 162 | 163 | Additional options (`type` and `refresh?`) override default token type 164 | (Bearer) and generate refresh-token, which is not created by default. 165 | 166 | When called on client-user pair which already had tokens generated, effectively 167 | overrides both tokens revoking the old ones. 168 | 169 | To get tokens generated both client and user need to be enabled. 170 | Otherwise HTTP 400 (invalid request) is returned." 171 | 172 | [client user scope & [refresh? type]] 173 | 174 | (if (and (:enabled? client) 175 | (:enabled? user)) 176 | (do 177 | 178 | ;; revoke all the tokens for given client/user that are still in use. 179 | (revoke-client-tokens client user) 180 | 181 | (let [result (create-token :access client user scope) 182 | {:keys [client-id secret created-at expires-at login]} result] 183 | 184 | (if (f/failed? result) 185 | result 186 | (let [refresh-token (when refresh? (create-token :refresh client user scope nil secret))] 187 | (-> {:access_token secret 188 | :token_type (or type "Bearer") 189 | :created_at created-at 190 | :expires_in (quot (- (.getTime expires-at) 191 | (.getTime created-at)) 1000)} 192 | (cond-> scope 193 | (assoc :scope scope)) 194 | (cond-> (not (or (nil? refresh-token) 195 | (f/failed? refresh-token))) 196 | (assoc :refresh_token (:secret refresh-token)))))))) 197 | 198 | error/invalid-request)) 199 | -------------------------------------------------------------------------------- /src/cerber/oauth2/core.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.core 2 | (:require [cerber.stores 3 | [user :as user] 4 | [token :as token] 5 | [client :as client] 6 | [session :as session] 7 | [authcode :as authcode]] 8 | [cerber.oauth2.settings :as settings])) 9 | 10 | ;; stores 11 | 12 | (defn create-session-store 13 | "Initializes empty session store of given type - :in-memory, :sql or :redis one. 14 | Redis-based session store expects redis connection spec passed in a `config` parameter 15 | whereas SQL-based one requires an initialized database connection." 16 | 17 | [type config] 18 | (session/init-store type config)) 19 | 20 | (defn create-authcode-store 21 | "Initializes empty authcode store of given type - :in-memory, :sql or :redis one. 22 | Redis-based authcode store expects redis connection spec passed in a `config` parameter 23 | whereas SQL-based one requires an initialized database connection." 24 | 25 | [type config] 26 | (authcode/init-store type config)) 27 | 28 | (defn create-token-store 29 | "Initializes empty token store of given type - :in-memory, :sql or :redis one. 30 | Redis-based token store expects redis connection spec passed in a `config` parameter 31 | whereas SQL-based one requires an initialized database connection." 32 | 33 | [type config] 34 | (token/init-store type config)) 35 | 36 | (defn create-user-store 37 | "Initializes empty user store of given type - :in-memory, :sql or :redis one. 38 | Redis-based user store expects redis connection spec passed in a `config` parameter 39 | whereas SQL-based one requires an initialized database connection." 40 | 41 | [type config] 42 | (user/init-store type config)) 43 | 44 | (defn create-client-store 45 | "Initializes empty client store of given type - :in-memory, :sql or :redis one. 46 | Redis-based client store expects redis connection spec passed in a `config` parameter 47 | whereas SQL-based one requires an initialized database connection." 48 | 49 | [type config] 50 | (client/init-store type config)) 51 | 52 | ;; clients 53 | 54 | (defn find-client 55 | "Looks up for client with given identifier." 56 | 57 | [client-id] 58 | (client/find-client client-id)) 59 | 60 | (defn create-client 61 | "Creates new OAuth client. 62 | `grants` : an optional vector of allowed grants: authorization_code, token, password, client_credentials. 63 | at least one grant needs to be provided. 64 | `redirects` : a validated vector of approved redirect-uris. 65 | redirect-uri passed along with token request should match one of these entries. 66 | `info` : optional non-validated info string (typically client's app name or URL to client's homepage) 67 | `scopes` : optional vector of OAuth scopes that client may request an access to 68 | `enabled?` : optional (false by default). should client be automatically enabled? 69 | `approved?` : optional (false by default). should client be auto-approved? 70 | `id` : optional client ID (must be unique), auto-generated if none provided 71 | `secret` : optional client secret (must be hard to guess), auto-generated if none provided 72 | 73 | Example: 74 | 75 | (c/create-client [\"authorization_code\" \"password\"] 76 | [\"http://defunkt.pl/callback\"] 77 | :info \"http://defunkt.pl\" 78 | :scopes [\"photo:read\" \"photo:list\"] 79 | :enabled? true 80 | :approved? true)" 81 | 82 | [grants redirects & {:keys [info scopes enabled? approved? id secret]}] 83 | {:pre [(seq grants) 84 | (seq redirects)]} 85 | (client/create-client grants redirects 86 | {:id id 87 | :secret secret 88 | :info info 89 | :scopes scopes 90 | :enabled? enabled? 91 | :approved? approved?})) 92 | 93 | (defn delete-client 94 | "Removes client from store along with all its access- and refresh-tokens." 95 | 96 | [client-id] 97 | (when-let [client (find-client client-id)] 98 | (= 1 (client/revoke-client client)))) 99 | 100 | (defn disable-client 101 | "Disables client. 102 | 103 | Revokes all client's tokens and prevents from gaining new ones. 104 | When disabled, client is no longer able to request permissions to any resource." 105 | 106 | [client-id] 107 | (when-let [client (find-client client-id)] 108 | (client/disable-client client))) 109 | 110 | (defn enable-client 111 | "Enables client. 112 | 113 | When enabled, client is able to request access to user's resource and (when accepted) 114 | get corresponding access-token in response." 115 | 116 | [client-id] 117 | (when-let [client (find-client client-id)] 118 | (client/enable-client client))) 119 | 120 | ;; users 121 | 122 | (defn find-user 123 | "Looks up for a user with given login." 124 | 125 | [login] 126 | (user/find-user login)) 127 | 128 | (defn create-user 129 | "Creates new user with `login` and `password` and optional details 130 | like descriptive name, email and roles. 131 | 132 | Example: 133 | 134 | (c/create-user \"foobar\" \"secret\" 135 | :name \"Foo Bar\" 136 | :email \"foo@bar.bazz\" 137 | :roles #{\"user/admin\"} 138 | :enabled? true)" 139 | 140 | [login password & {:keys [name email roles enabled?]}] 141 | {:pre [(not (nil? login)) 142 | (not (nil? password))]} 143 | (user/create-user login password 144 | {:name name 145 | :email email 146 | :roles roles 147 | :enabled? enabled?})) 148 | 149 | (defn delete-user 150 | "Removes user from store." 151 | 152 | [login] 153 | (when-let [user (find-user login)] 154 | (= 1 (user/revoke-user user)))) 155 | 156 | (defn disable-user 157 | "Disables user. 158 | 159 | Disabled user is no longer able to authenticate and all access 160 | tokens created based on his grants become immediately invalid." 161 | 162 | [login] 163 | (when-let [user (find-user login)] 164 | (and (user/disable-user user) user))) 165 | 166 | (defn enable-user 167 | "Enables user. 168 | 169 | Enabled user is able to authenticate and approve or deny access to 170 | resources requested by OAuth clients." 171 | 172 | [login] 173 | (when-let [user (find-user login)] 174 | (and (user/enable-user user) user))) 175 | 176 | ;; users/clients helpers 177 | 178 | (defn init-users 179 | "Initializes users-store with predefined collection of users." 180 | 181 | [users] 182 | (doseq [{:keys [login email name roles enabled? password]} users] 183 | (create-user login password 184 | :email email 185 | :name name 186 | :roles roles 187 | :enabled? enabled?))) 188 | 189 | (defn init-clients 190 | "Initializes client-store with predefined collection of clients." 191 | 192 | [clients] 193 | (doseq [{:keys [id secret info redirects grants scopes approved?]} clients] 194 | (create-client grants redirects 195 | :id id 196 | :secret secret 197 | :info info 198 | :scopes scopes 199 | :enabled? true 200 | :approved? approved?))) 201 | 202 | ;; tokens 203 | 204 | (defn find-access-token 205 | "Returns access-token bound to given secret." 206 | 207 | [secret] 208 | (token/find-access-token secret)) 209 | 210 | (defn revoke-access-token 211 | "Revokes single access-token." 212 | 213 | [secret] 214 | (token/revoke-access-token secret)) 215 | 216 | (defn find-refresh-tokens 217 | "Returns list of refresh tokens generated for `client-id` and 218 | optionally - for a `login` user." 219 | 220 | ([client-id] 221 | (find-refresh-tokens client-id nil)) 222 | ([client-id login] 223 | (token/find-by-pattern ["refresh" nil client-id login]))) 224 | 225 | (defn revoke-client-tokens 226 | "Revokes all access- and refresh-tokens bound with `client-id`, 227 | optionally narrowing revoked tokens to given `login` only." 228 | 229 | ([client-id] 230 | (revoke-client-tokens client-id nil)) 231 | ([client-id login] 232 | (when-let [client (find-client client-id)] 233 | (token/revoke-client-tokens client {:login login})))) 234 | 235 | (defn regenerate-tokens 236 | "Generates both access- and refresh-tokens for `client-id` enabling 237 | access to `login`'s resources defined by `scope`. Revokes and overrides 238 | existing tokens issued for client for `login` user if any exist." 239 | 240 | [client-id login scope] 241 | (let [client (find-client client-id) 242 | user (find-user login)] 243 | (when (and client user) 244 | (token/generate-access-token client user scope true)))) 245 | 246 | ;; global settings 247 | 248 | (defn set-realm! 249 | "Sets up a global OAuth2 realm. Returns newly set value." 250 | 251 | [realm] 252 | (settings/realm realm)) 253 | 254 | (defn set-authentication-url! 255 | "Sets up an OAuth2 authentication URL. Returns newly set value." 256 | 257 | [auth-url] 258 | (settings/authentication-url auth-url)) 259 | 260 | (defn set-unauthorized-url! 261 | "Sets up a location that browser should redirect to in case 262 | of HTTP 401 Unauthorized. Returns newly set value." 263 | 264 | [auth-url] 265 | (settings/unauthorized-url auth-url)) 266 | 267 | (defn set-landing-url! 268 | "Sets up a landing URL that browser should redirect to after 269 | successful authentication. Returns newly set value." 270 | 271 | [landing-url] 272 | (settings/landing-url landing-url)) 273 | 274 | (defn set-token-valid-for! 275 | "Sets up a token time-to-live (TTL) which essentially says 276 | how long OAuth2 tokens are valid. Returns newly set value." 277 | 278 | [valid-for] 279 | (settings/token-valid-for valid-for)) 280 | 281 | (defn set-authcode-valid-for! 282 | "Sets up an auth-code time-to-live (TTL) which essentially says 283 | how long OAuth2 authcodes are valid. Returns newly set value." 284 | 285 | [valid-for] 286 | (settings/authcode-valid-for valid-for)) 287 | 288 | (defn set-session-valid-for! 289 | "Sets up a session time-to-live (TTL) which essentially says 290 | how long OAuth2 sessions are valid. Returns newly set value." 291 | 292 | [valid-for] 293 | (settings/session-valid-for valid-for)) 294 | 295 | (defn update-settings 296 | "Bulk update of OAuth2 global settings with provided `settings` map." 297 | 298 | [settings] 299 | (settings/update-settings settings)) 300 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cerber - OAuth2 Provider 2 | 3 | [![Clojars Project](https://img.shields.io/clojars/v/cerber/cerber-oauth2-provider.svg)](https://clojars.org/cerber/cerber-oauth2-provider) 4 | 5 | [Architecture][arch] | [Configuration][conf] | [Usage][use] | [API][api] | [Middlewares][middlewares] | [Development][dev] | [Changelog][log] 6 | 7 | This is a clojurey implementation of [RFC 6749 - The OAuth 2.0 Authorization Framework](https://tools.ietf.org/html/rfc6749). Currently covers all scenarios described by spec: 8 | 9 | * [Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1) 10 | * [Implict Grant](https://tools.ietf.org/html/rfc6749#section-4.2) 11 | * [Resource Owner Password Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.3) 12 | * [Client Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.4) 13 | 14 | Tokens expiration and [refreshing](https://tools.ietf.org/html/rfc6749#section-6) are all in the box as well. 15 | 16 | ## Architecture 17 | 18 | This implementation assumes Authorization Server and Resource Server having same source of knowledge about issued tokens and sessions. 19 | Servers might be horizontally scaled but still need to be connected to the same underlaying database (redis or sql-based). This is also why in-memory storage should be used for development only. It simply does not scale (at least not with current implementation). 20 | 21 | All _NOT RECOMMENDED_ points from specification have been purposely omitted for security reasons. Bearer tokens and client credentials should be passed in HTTP headers. All other ways (like query param or form fields) are ignored and will result in HTTP 401 (Unauthorized) or HTTP 403 (Forbidden) errors. 22 | 23 | _(todo)_ introduce JWT tokens 24 | 25 | ### Users and clients 26 | 27 | Cerber has its own abstraction of [User](./src/cerber/stores/user.clj) ([resource owner](https://tools.ietf.org/html/rfc6749#section-1.1)) and [Client](./src/cerber/stores/client.clj) (application which requests on behalf of User). Instances of both can be easily created with Cerber's API. 28 | 29 | ### Stores 30 | 31 | _Store_ is a base abstraction of storage which, through protocol, exposes simple API to read and write entities (user, client, session, token or authorization code) that all the logic operates on. Cerber stands on a shoulders of 5 stores: 32 | 33 | * users - keeps users details (along with encoded password) 34 | * clients - keeps OAuth clients data (identifiers, secrets, allowed redirect URIs and so on) 35 | * sessions - keeps http session data transmitted back and forth via [ring session](https://github.com/ring-clojure/ring/wiki/Sessions) 36 | * tokens - generated access- and refresh tokens 37 | * authcodes - codes to be exchanged for tokens 38 | 39 | As for now, each store implements following 3 types: 40 | 41 | * `:in-memory` - a store keeping its data straight in `atom`. Ideal for development mode and tests. 42 | * `:redis` - a proxy to Redis. Recommended for production mode. 43 | * `:sql` - a proxy to relational database (eg. MySQL or PostgreSQL). Recommended for production mode. 44 | 45 | To keep maximal flexibility each store can be configured separately, eg. typical configuration might use `:sql` store for users and clients and `:redis` one for sessions / tokens / authcodes. 46 | 47 | When speaking of configuration... 48 | 49 | ## Configuration 50 | 51 | `cerber.oauth2.core` namespace is a central place which exposes all the functions required to initialize stores, users, clients and tinker with global options like realm or token/authcode/session life-times. Stores might seem to be a bit tricky to configure as they depend on underlaying storage and thus may expect additional parameters. To configure session store as redis based one, following expression should make it happen: 52 | 53 | ``` clojure 54 | (require '[cerber.oauth2.core :as core]) 55 | (core/create-session-store :redis {:spec {:host "localhost" 56 | :port 6380}}) 57 | ``` 58 | 59 | and this is how to configure SQL-based store which requires database connection passed in as a parameter: 60 | 61 | ``` clojure 62 | (require '[cerber.oauth2.core :as core]) 63 | (require '[conman.core :as conman]) 64 | 65 | (defonce db-conn 66 | (and (Class/forName "org.postgresql.Driver") 67 | (conman/connect! {:init-size 1 68 | :min-idle 1 69 | :max-idle 4 70 | :max-active 32 71 | :jdbc-url "jdbc:postgresql://localhost:5432/template1?user=postgres"}))) 72 | 73 | (core/create-session-store :sql db-conn) 74 | ``` 75 | 76 | Initialization and tear-down process can be easily handed over to glorious [mount](https://github.com/tolitius/mount): 77 | 78 | ``` clojure 79 | (require '[mount.core :refer [defstate]]) 80 | 81 | (defstate client-store 82 | :start (core/create-client-store :sql db-conn) 83 | :stop (close! client-store)) 84 | 85 | (defstate user-store 86 | :start (core/create-user-store :sql db-conn) 87 | :stop (close! user-store)) 88 | 89 | ...and so on... 90 | ``` 91 | 92 | ### Authorization Grant Types 93 | 94 | Grant types allowed: 95 | 96 | * `authorization_code` for [Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1) 97 | * `token` for [Implict Code Grant](https://tools.ietf.org/html/rfc6749#section-4.2) 98 | * `password` for [Resource Owner Password Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.3) 99 | * `client_credentials` for [Client Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.4) 100 | 101 | ### Scopes 102 | 103 | Scopes are the OAuth way to explicitly manage the power associated with an access token. In nutshell, a scope says what type of access OAuth2 client may need to particular resource. 104 | 105 | Cerber defines scopes as a set of unique strings like `user`, `photo:read` or `profile:write` which may be structurized in kind of hierarchy. For example scopes may be defined as a following: `#{"photo:read" "photo:write"}` which (when permission is granted) allows _reading_ and _writing_ to imaginary photo resoure. A `photo` scope itself is assumed to be a parent of `photo:read` and `photo:write` and implicitly includes both scopes. 106 | 107 | In practice, scopes are auto-simplified, so when client asks for permission to `photo` and `photo:read` scopes, it's being simplified to `photo` only. 108 | 109 | Note, it's perfectly valid to have an empty set of scopes as they are optional in OAuth2 spec. 110 | 111 | ### Roles and permissions 112 | 113 | Although User model contains `roles` field it is not interpreted in any way. It is simply returned for further processing, eg. by custom middleware. 114 | 115 | Please take a look at [cerber-roles](https://github.com/mbuczko/cerber-roles) to make use of roles in more meaningful way. 116 | 117 | ### Forms 118 | 119 | To complete some of OAuth2-flow actions like web based authentication or asking user for approval, Cerber picks up following templates to render corresponding HTML pages: 120 | 121 | * [templates/cerber/login.html](./resources/templates/cerber/login.html) - used to render authentication form. 122 | * [templates/cerber/authorize.html](./resources/templates/cerber/authorize.html) - used to render an user's approval/rejection form to grant (or not) certain permissions. 123 | 124 | Both templates are provided by this library with a very spartan styling, just to expose the most important things inside and should be replaced with own customized ones. 125 | 126 | ## Usage 127 | 128 | Cerber OAuth2 provider defines 7 [ring handlers](https://github.com/ring-clojure/ring/wiki/Concepts) that should be bound to specific routes. It's not done automagically. Some people love [compojure](https://github.com/weavejester/compojure) some love [bidi](https://github.com/juxt/bidi) so Cerber leaves the decision in developer's hands. 129 | 130 | Anyway, this is how bindings would look like with compojure: 131 | 132 | ``` clojure 133 | (require '[cerber.handlers :as handlers]) 134 | 135 | (defroutes oauth-routes 136 | (GET "/authorize" [] handlers/authorization-handler) 137 | (POST "/approve" [] handlers/client-approve-handler) 138 | (GET "/refuse" [] handlers/client-refuse-handler) 139 | (POST "/token" [] handlers/token-handler) 140 | (GET "/login" [] handlers/login-form-handler) 141 | (POST "/login" [] handlers/login-submit-handler) 142 | (GET "/logout" [] handlers/logout-handler)) 143 | ``` 144 | 145 | Having OAuth paths set up, next step is to configure routes to protected resources (assuming here a user's details as such a one): 146 | 147 | ``` clojure 148 | (require '[cerber.oauth2.context :as ctx]) 149 | 150 | (defroutes authorized-routes 151 | (GET "/user/info" [] (fn [req] {:status 200 152 | :body (::ctx/user req)}))) 153 | ``` 154 | Almost there. One missing part not mentioned yet is authorization and the way how token is validated. 155 | 156 | All the magic happens inside `wrap-authorized` middleware which examines both request Cookie (for session identifier) and `Authorization` header (for a token issued by Authorization Server). Once token is found, requestor receives set of privileges it was asking for and request is delegated down into handlers stack. Otherwise 401 Unauthorized is returned. 157 | 158 | ``` clojure 159 | (require '[org.httpkit.server :as web] 160 | [cerber.handlers :refer [wrap-authorized]] 161 | [compojure.core :refer [routes wrap-routes] 162 | [ring.middleware.defaults :refer [api-defaults wrap-defaults]] 163 | [ring.middleware.format :refer [wrap-restful-format]]]) 164 | 165 | (def api-routes 166 | (routes oauth-routes 167 | (-> authorized-routes 168 | (wrap-routes wrap-restful-format :formats [:json-kw]) 169 | (wrap-routes wrap-authorized))) 170 | 171 | ;; final handler passed to HTTP server (HTTP-Kit here) 172 | (web/run-server (wrap-defaults api-routes api-defaults) {:host "localhost" :port 8080}}) 173 | ``` 174 | 175 | ## API 176 | 177 | API functions are all grouped in `cerber.oauth2.core` namespace based on what entity they deal with. 178 | 179 | ### stores 180 | 181 | `(create-user-store [type config])` 182 | 183 | `(create-client-store [type config])` 184 | 185 | `(create-session-store [type config])` 186 | 187 | `(create-authcode-store [type config])` 188 | 189 | `(create-token-store [type config])` 190 | 191 | Functions to initialize empty store of given type - :in-memory, :sql or :redis one. Redis-based store expects redis connection spec 192 | passed in a `config` parameter whereas SQL-based one requires an initialized database connection. 193 | 194 | ### clients 195 | 196 | `(create-client [grants redirects & {:keys [info scopes enabled? approved? id secret]}])` 197 | 198 | Used to create new OAuth client, where: 199 | - `grants` is vector of allowed grant types: "authorization\_code", "token", "password", "client\_credentials". At least one grant needs to be provided. 200 | - `redirects` is a validated vector of approved redirect-uris. Note that for security reasons redirect-uri passed along with token request should match one of these entries. 201 | - `info` is a non-validated info string (typically client's app name or URL to client's homepage) 202 | - `scopes` is vector of OAuth scopes that client may request an access to 203 | - `enabled?` decides whether client should be auto-enabled or not. It's false by default which means client is not able to request for tokens 204 | - `approved?` decides whether client should be auto-approved or not. It's false by default which means that client needs user's approval when requesting access to protected resource 205 | - `id` - optional client-id (must be unique), auto-generated if none provided 206 | - `secret` - optional client-secret (must be hard to guess), auto-generated if none provided 207 | 208 | Example: 209 | 210 | ```clojure 211 | (require '[cerber.oauth2.core :as c]) 212 | 213 | (c/create-client ["authorization_code" "password"] 214 | ["http://defunkt.pl/callback"] 215 | :info "http://defunkt.pl" 216 | :scopes ["photo:read" "photo:list"] 217 | :enabled? true 218 | :approved? false) 219 | ``` 220 | 221 | Each generated client has its own random client-id and a secret which both are used in OAuth flow. 222 | Important thing is to keep the secret codes _really_ secret! Both client-id and secret authorize 223 | client instance and it might be harmful to let attacker know what's your client's secret code is. 224 | 225 | `(find-client [client-id])` 226 | 227 | Looks up for client with given identifier. 228 | 229 | `(delete-client [client])` 230 | 231 | Removes client from store. Note that together with client all its access- and refresh-tokens are revoked as well. 232 | 233 | `(disable-client [client-id])` 234 | 235 | `(enable-client [client-id])` 236 | 237 | Disables or enables client with given identifier. Disabled client is no longer able to receive access/refresh-tokens nor operate on behalf of user in any other way. 238 | 239 | ### users 240 | 241 | `(create-user [login password & {:keys [name email roles enabled?]}])` 242 | 243 | Creates new user with following details: 244 | 245 | - `:login` is a user's login identifier 246 | - `:password` is a user's password 247 | - `:name` is a user's description (like full name) 248 | - `:email` is a user's email 249 | - `:roles` set of optional roles 250 | - `:enabled?` indicates whether user should be enabled. User is enabled by default unless `enabled?` states otherwise. 251 | 252 | `(find-user [login])` 253 | 254 | Looks up for a user with given login. 255 | 256 | `(delete-user [login])` 257 | 258 | Removes from store user with given login. 259 | 260 | `(disable-user [login])` 261 | 262 | `(enable-user [login])` 263 | 264 | Disables or enables user with given given login. Disabled user is no longer able to authenticate and all authorization attempts fail immediately. 265 | 266 | `(init-users [users])` 267 | `(init-clients [clients])` 268 | 269 | Initializes users- and clients-store with predefined collection of users/clients: 270 | 271 | ```clojure 272 | (require '[cerber.oauth2.core :as c]) 273 | 274 | (c/init-users [{:login "admin" 275 | :email "admin@bar.com" 276 | :name "Admin" 277 | :enabled? true 278 | :password "secret" 279 | :roles #{:user/admin}} 280 | {:login "foo" 281 | :email "foo@bar.com" 282 | :name "Foo Bar" 283 | :enabled? true 284 | :password "pass" 285 | :roles #{:user/all}}]) 286 | ``` 287 | 288 | 289 | ### tokens 290 | 291 | `(find-access-token [secret])` 292 | 293 | Returns an access token bound to given secret. 294 | 295 | `(revoke-access-token [secret])` 296 | 297 | Revokes given access-token. 298 | 299 | `(find-refresh-tokens [client-id])` 300 | 301 | `(find-refresh-tokens [client-id login])` 302 | 303 | Returns collection of refresh-tokens for given client (and user optionally). 304 | 305 | `(revoke-client-tokens [client-id])` 306 | 307 | `(revoke-client-tokens [client-id login])` 308 | 309 | Revokes all access- and refresh-tokens bound with given client (and user optionally). 310 | 311 | `(regenerate-tokens [client-id login scope])` 312 | 313 | Refreshes tokens for given client-user pair. Revokes and overrides existing tokens, if any exist. 314 | 315 | ### global options 316 | 317 | `(set-token-valid-for! valid-for)` 318 | 319 | Sets up a token time-to-live (TTL) which essentially says how long OAuth2 tokens are valid. 320 | 321 | `(set-authcode-valid-for! valid-for)` 322 | 323 | Sets up an authcode time-to-live (TTL) which essentially says how long authcodes are valid. 324 | 325 | `(set-session-valid-for! valid-for)` 326 | 327 | Sets up a session time-to-live (TTL) which essentially says how long sessions are valid. 328 | 329 | `(set-landing-url! url)` 330 | 331 | Sets up a location that browser should redirect to in order to authenticate a user. 332 | 333 | `(set-realm! realm)` 334 | 335 | Sets up a realm presented in WWW-Authenticate header in case of 401/403 http error codes. 336 | 337 | `(set-authentication-url! url)` 338 | 339 | Sets up an OAuth2 authentication URL ("/login" by default). 340 | 341 | `(set-unauthorized-url! url)` 342 | 343 | Sets up location where browser redirects in case of `HTTP 401 Unauthorized` ("/login" by default). 344 | 345 | ### errors 346 | 347 | Any errors returned in a response body are formed according to specification as following json: 348 | 349 | ``` json 350 | { 351 | "error": "error code", 352 | "error_description": "human error description", 353 | "state": "optional state" 354 | } 355 | ``` 356 | 357 | ## Middlewares 358 | 359 | Cerber exposes 2 middlewares in `cerber.handlers` namespace: 360 | 361 | `wrap-authorized` 362 | 363 | This one, based on cookie or bearer token conveyed in a request, sets up a context where a subject (authorized user) and OAuth2 client information is stored for a request time-life. 364 | Unauthorized requests result in `HTTP 401 Unauthorized` (in case of invalid token) or redirection to login page (in case of cookie based request). 365 | 366 | `wrap-maybe-authorized` 367 | 368 | Same as `wrap-authorized` but does no redirection or `HTTP 401 Unauthorized` responses in case of unauthorized requests. In this case a request context is simply not created and no user/client information is available. 369 | 370 | ## Development 371 | 372 | Underlaying [midje](https://github.com/marick/Midje) testing framework has been configured to watch for changes and run corresponding tests after each change: 373 | 374 | ``` shell 375 | $ boot tests 376 | ``` 377 | 378 | This library has also built-in [standalone testing server](./src/cerber/oauth2/standalone/server.clj) available in `cerber.oauth2.standalone.server` namespace. All it needs to start up is initialized with mount-based restartable system: 379 | 380 | ``` clojure 381 | (require '[cerber.oauth2.standalone.system :as system]) 382 | 383 | ;; start server 384 | (system/go) 385 | 386 | ;; stops server 387 | (system/stop) 388 | 389 | ;; restart server 390 | (system/reset) 391 | ``` 392 | 393 | Any ideas or bugfixes? PRs nicely welcomed. Be sure that your changes pass all the tests or simply add your own test suites if none covers your code yet. 394 | 395 | ## Changelog 396 | 397 | - `v2.0.0` : internal API reworked. roles are represented by keywords now (instead of strings). 398 | - `v1.1.0` : `wrap-authorized` handler no longer wraps response in `wrap-restful-format` middleware, so response is not returned as json now. from now on, it' up to developer what format response will be transformed to. 399 | 400 | [arch]: #architecture 401 | [conf]: #configuration 402 | [use]: #usage 403 | [api]: #api 404 | [middlewares]: #middlewares 405 | [dev]: #development 406 | [log]: #changelog 407 | -------------------------------------------------------------------------------- /test/cerber/oauth2/authorization_test.clj: -------------------------------------------------------------------------------- 1 | (ns cerber.oauth2.authorization-test 2 | (:require [cerber 3 | [db :as db] 4 | [test-utils :as utils] 5 | [handlers :as handlers]] 6 | [cerber.oauth2.context :as ctx] 7 | [compojure.core :refer [defroutes routes wrap-routes GET POST]] 8 | [midje.sweet :refer :all] 9 | [peridot.core :refer :all] 10 | [ring.middleware.defaults :refer [api-defaults wrap-defaults]] 11 | [cheshire.core :as json])) 12 | 13 | (def redirect-uri "http://localhost") 14 | (def scope "photo:read") 15 | (def state "123ABC") 16 | 17 | (defroutes oauth-routes 18 | (GET "/authorize" [] handlers/authorization-handler) 19 | (POST "/approve" [] handlers/client-approve-handler) 20 | (GET "/refuse" [] handlers/client-refuse-handler) 21 | (POST "/token" [] handlers/token-handler) 22 | (GET "/login" [] handlers/login-form-handler) 23 | (POST "/login" [] handlers/login-submit-handler)) 24 | 25 | (defroutes restricted-routes 26 | (GET "/users/me" [] (fn [req] 27 | {:status 200 28 | :body (::ctx/user req)}))) 29 | 30 | (def app (-> restricted-routes 31 | (wrap-routes handlers/wrap-authorized) 32 | (wrap-defaults api-defaults))) 33 | 34 | (fact "Enabled user with valid password is redirected to landing page when successfully logged in." 35 | (utils/with-stores :sql 36 | (let [user (utils/create-test-user :password "pass") 37 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 38 | (header "Accept" "text/html") 39 | (request "/login") ;; get csrf 40 | (utils/request-secured "/login" 41 | :request-method :post 42 | :params {:username (:login user) 43 | :password "pass"}))] 44 | 45 | (get-in state [:response :status]) => 302 46 | (get-in state [:response :headers "Location"]) => "http://localhost/"))) 47 | 48 | (fact "Enabled user with valid password gets HTTP 200 OK with landing-url in a body when logging in with XHR request." 49 | (utils/with-stores :sql 50 | (let [user (utils/create-test-user :password "pass") 51 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 52 | (header "Accept" "application/json") 53 | (header "X-Requested-With" "XMLHttpRequest") 54 | (request "/login") ;; get csrf 55 | (utils/request-secured "/login" 56 | :request-method :post 57 | :params {:username (:login user) 58 | :password "pass"}))] 59 | 60 | (get-in state [:response :status]) => 200 61 | (get-in state [:response :body]) => {:landing-url "/"}))) 62 | 63 | (fact "Enabled user with wrong credentials is redirected back to login page with failure info provided." 64 | (utils/with-stores :sql 65 | (let [user (utils/create-test-user :password "pass") 66 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 67 | (header "Accept" "text/html") 68 | (request "/login") ;; get csrf 69 | (utils/request-secured "/login" 70 | :request-method :post 71 | :params {:username (:login user) 72 | :password ""}))] 73 | 74 | (get-in state [:response :status]) => 200 75 | (get-in state [:response :body]) => (contains "failed")))) 76 | 77 | (fact "Enabled user with wrong credentials gets HTTP 401 Unauthorized when logging in with XHR request." 78 | (utils/with-stores :sql 79 | (let [user (utils/create-test-user :password "pass") 80 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 81 | (header "Accept" "application/json") 82 | (header "X-Requested-With" "XMLHttpRequest") 83 | (request "/login") ;; get csrf 84 | (utils/request-secured "/login" 85 | :request-method :post 86 | :params {:username (:login user) 87 | :password ""}))] 88 | 89 | (get-in state [:response :status]) => 401))) 90 | 91 | (fact "Inactive user is not able to log in." 92 | (utils/with-stores :sql 93 | (let [user (utils/create-test-user :enabled? false 94 | :password "pass") 95 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 96 | (header "Accept" "text/html") 97 | (request "/login") ;; get csrf 98 | (utils/request-secured "/login" 99 | :request-method :post 100 | :params {:username (:login user) 101 | :password "pass"}))] 102 | 103 | (get-in state [:response :status]) => 200 104 | (get-in state [:response :body]) => (contains "failed")))) 105 | 106 | (fact "Inactive user is not able to log in with XHR request." 107 | (utils/with-stores :sql 108 | (let [user (utils/create-test-user :enabled? false 109 | :password "pass") 110 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 111 | (header "Accept" "application/json") 112 | (header "X-Requested-With" "XMLHttpRequest") 113 | (request "/login") ;; get csrf 114 | (utils/request-secured "/login" 115 | :request-method :post 116 | :params {:username (:login user) 117 | :password "pass"}))] 118 | 119 | (get-in state [:response :status]) => 401))) 120 | 121 | (fact "Unapproved client may receive its token in Authorization Code Grant scenario. Needs user's approval." 122 | (utils/with-stores :sql 123 | (let [client (utils/create-test-client redirect-uri :scope scope) 124 | user (utils/create-test-user :password "pass") 125 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 126 | (header "Accept" "text/html") 127 | (request (str "/authorize?response_type=code" 128 | "&client_id=" (:id client) 129 | "&scope=" scope 130 | "&state=" state 131 | "&redirect_uri=" redirect-uri)) 132 | 133 | ;; login window 134 | (follow-redirect) 135 | (utils/request-secured "/login" 136 | :request-method :post 137 | :params {:username (:login user) 138 | :password "pass"}) 139 | 140 | ;; authorization prompt 141 | (follow-redirect) 142 | (utils/request-secured "/approve" 143 | :request-method :post 144 | :params {:client_id (:id client) 145 | :response_type "code" 146 | :redirect_uri redirect-uri}) 147 | 148 | ;; having access code received - final request for acess-token 149 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 150 | ((fn [s] (request s "/token" 151 | :request-method :post 152 | :params {:grant_type "authorization_code" 153 | :code (utils/extract-access-code s) 154 | :redirect_uri redirect-uri}))))] 155 | 156 | (let [{:keys [status body]} (:response state) 157 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 158 | 159 | status => 200 160 | access_token => truthy 161 | refresh_token => truthy 162 | expires_in => truthy 163 | 164 | ;; authorized request to /users/me should contain user's login 165 | (-> (session app) 166 | (utils/request-authorized "/users/me" access_token) 167 | :login) => (:login user))))) 168 | 169 | (fact "Approved client may receive its token in Authorization Code Grant scenario. Doesn't need user's approval." 170 | (utils/with-stores :sql 171 | (let [client (utils/create-test-client redirect-uri :scope scope :approved? true) 172 | user (utils/create-test-user :password "pass") 173 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 174 | (header "Accept" "text/html") 175 | (request (str "/authorize?response_type=code" 176 | "&client_id=" (:id client) 177 | "&scope=" scope 178 | "&state=" state 179 | "&redirect_uri=" redirect-uri)) 180 | 181 | ;; login window 182 | (follow-redirect) 183 | (utils/request-secured "/login" 184 | :request-method :post 185 | :params {:username (:login user) 186 | :password "pass"}) 187 | ;; follow authorization link 188 | (follow-redirect) 189 | 190 | ;; having access code received - final request for acess-token 191 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 192 | ((fn [s] (request s "/token" 193 | :request-method :post 194 | :params {:grant_type "authorization_code" 195 | :code (utils/extract-access-code s) 196 | :redirect_uri redirect-uri}))))] 197 | 198 | (let [{:keys [status body]} (:response state) 199 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 200 | 201 | status => 200 202 | access_token => truthy 203 | refresh_token => truthy 204 | expires_in => truthy 205 | 206 | ;; authorized request to /users/me should contain user's login 207 | (-> (session app) 208 | (utils/request-authorized "/users/me" access_token) 209 | :login) => (:login user))))) 210 | 211 | (fact "Client is redirected with error message when tries to get an access-token with undefined scope." 212 | (utils/with-stores :sql 213 | (let [client (utils/create-test-client redirect-uri :scope scope) 214 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 215 | (header "Accept" "text/html") 216 | (request (str "/authorize?response_type=code" 217 | "&client_id=" (:id client) 218 | "&scope=profile" 219 | "&state=" state 220 | "&redirect_uri=" redirect-uri)))] 221 | 222 | (let [{:keys [status headers]} (:response state), location (get headers "Location")] 223 | status => 302 224 | location => (contains "error=invalid_scope"))))) 225 | 226 | (fact "Client may provide no scope at all (scope is optional)." 227 | (utils/with-stores :sql 228 | (let [client (utils/create-test-client redirect-uri :scope "") 229 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 230 | (header "Accept" "text/html") 231 | (request (str "/authorize?response_type=code" 232 | "&client_id=" (:id client) 233 | "&state=" state 234 | "&redirect_uri=" redirect-uri)))] 235 | 236 | (let [{:keys [status headers]} (:response state), location (get headers "Location")] 237 | status => 302 238 | location =not=> (contains "error=invalid_scope"))))) 239 | 240 | (fact "Client may receive its token in Implict Grant scenario." 241 | (utils/with-stores :sql 242 | (let [client (utils/create-test-client redirect-uri :scope scope) 243 | user (utils/create-test-user :password "pass") 244 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 245 | (header "Accept" "text/html") 246 | (request (str "/authorize?response_type=token" 247 | "&client_id=" (:id client) 248 | "&scope=" scope 249 | "&state=" state 250 | "&redirect_uri=" redirect-uri)) 251 | 252 | ;; login window 253 | (follow-redirect) 254 | (utils/request-secured "/login" 255 | :request-method :post 256 | :params {:username (:login user) 257 | :password "pass"}) 258 | ;; response with token 259 | (follow-redirect))] 260 | 261 | (let [{:keys [status headers]} (:response state), location (get headers "Location")] 262 | 263 | status => 302 264 | location => (contains "access_token") 265 | location => (contains "expires_in") 266 | location =not=> (contains "refresh_token") 267 | 268 | ;; authorized request to /users/me should contain user's login 269 | (let [token (second (re-find #"access_token=([^\&]+)" location))] 270 | (-> (session app) 271 | (utils/request-authorized "/users/me" token) 272 | :login) => (:login user)))))) 273 | 274 | (fact "Client may receive its token in Resource Owner Password Credentials Grant scenario for enabled user." 275 | (utils/with-stores :sql 276 | (let [client (utils/create-test-client redirect-uri :scope scope) 277 | user (utils/create-test-user :password "pass") 278 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 279 | (header "Accept" "application/json") 280 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 281 | (request "/token" 282 | :request-method :post 283 | :params {:username (:login user) 284 | :password "pass" 285 | :grant_type "password"}))] 286 | 287 | (let [{:keys [status body]} (:response state) 288 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 289 | 290 | status => 200 291 | access_token => truthy 292 | refresh_token => truthy 293 | expires_in => truthy 294 | 295 | ;; authorized request to /users/me should contain user's login 296 | (-> (session app) 297 | (utils/request-authorized "/users/me" access_token) 298 | :login) => (:login user))))) 299 | 300 | (fact "Client cannot receive token in Resource Owner Password Credentials Grant scenario for disabled user." 301 | (utils/with-stores :sql 302 | (let [client (utils/create-test-client redirect-uri :scope scope) 303 | user (utils/create-test-user :enabled? false 304 | :password "pass") 305 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 306 | (header "Accept" "application/json") 307 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 308 | (request "/token" 309 | :request-method :post 310 | :params {:username (:login user) 311 | :password "pass" 312 | :grant_type "password"}))] 313 | 314 | (get-in state [:response :status]) => 401))) 315 | 316 | (fact "Client may receive its token in Client Credentials Grant." 317 | (utils/with-stores :sql 318 | (let [client (utils/create-test-client redirect-uri :scope scope) 319 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 320 | (header "Accept" "application/json") 321 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 322 | (request "/token" 323 | :request-method :post 324 | :params {:grant_type "client_credentials"}))] 325 | 326 | (let [{:keys [status body]} (:response state) 327 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 328 | 329 | status => 200 330 | access_token => truthy 331 | refresh_token => falsey 332 | expires_in => truthy 333 | 334 | ;; authorized request to /users/me should not reveal user's info 335 | (-> (session app) 336 | (utils/request-authorized "/users/me" access_token)) => (contains {:login nil}))))) 337 | 338 | (fact "Active token should be rejected for disabled user." 339 | (utils/with-stores :sql 340 | (let [client (utils/create-test-client redirect-uri :scope scope) 341 | user (utils/create-test-user :password "pass") 342 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 343 | (header "Accept" "application/json") 344 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 345 | (request "/token" 346 | :request-method :post 347 | :params {:username (:login user) 348 | :password "pass" 349 | :grant_type "password"}))] 350 | 351 | (let [{:keys [status body]} (:response state) 352 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 353 | 354 | status => 200 355 | access_token => truthy 356 | refresh_token => truthy 357 | expires_in => truthy 358 | 359 | (utils/disable-test-user (:login user)) 360 | 361 | (-> (session app) 362 | (header "Authorization" (str "Bearer " access_token)) 363 | (request "/users/me") 364 | :response 365 | :status) => 400)))) 366 | 367 | (fact "Active token should be rejected for disabled client." 368 | (utils/with-stores :sql 369 | (let [client (utils/create-test-client redirect-uri :scope scope) 370 | user (utils/create-test-user :password "pass") 371 | state (-> (session (wrap-defaults oauth-routes api-defaults)) 372 | (header "Accept" "application/json") 373 | (header "Authorization" (str "Basic " (utils/base64-auth client))) 374 | (request "/token" 375 | :request-method :post 376 | :params {:username (:login user) 377 | :password "pass" 378 | :grant_type "password"}))] 379 | 380 | (let [{:keys [status body]} (:response state) 381 | {:keys [access_token expires_in refresh_token]} (json/parse-string (slurp body) true)] 382 | 383 | status => 200 384 | access_token => truthy 385 | refresh_token => truthy 386 | expires_in => truthy 387 | 388 | (utils/disable-test-client (:id client)) 389 | 390 | (-> (session app) 391 | (header "Authorization" (str "Bearer " access_token)) 392 | (request "/users/me") 393 | :response 394 | :status) => 400)))) 395 | --------------------------------------------------------------------------------