├── .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 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/forms/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/resources/templates/cerber/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 | [](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 |
--------------------------------------------------------------------------------