Skip to content

Commit

Permalink
auth: Split auth code into API and utils
Browse files Browse the repository at this point in the history
  • Loading branch information
goshatch committed Sep 14, 2024
1 parent ef7c4a8 commit 5c3c160
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 127 deletions.
6 changes: 3 additions & 3 deletions src/apossiblespace/parts.clj
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,21 @@
{:post {:handler auth/login}}]
["/logout"
{:post {:handler auth/logout
:middleware [auth/jwt-auth]}}]]
:middleware [middleware/jwt-auth]}}]]
["/account" {:swagger {:tags ["Account"]}}
[""
{:get {:handler account/get-account}
:patch {:handler account/update-account}
:delete {:handler account/delete-account}
:middleware [auth/jwt-auth]}]
:middleware [middleware/jwt-auth]}]
["/register"
{:post {:handler account/register-account}}]]]]
{:data {:middleware [wrap-params
middleware/exception
middleware/logging
[wrap-json-body {:keywords? true}]
wrap-json-response
auth/wrap-jwt-authentication]}})
middleware/wrap-jwt-authentication]}})
(ring/routes
(swagger-ui/create-swagger-ui-handler
{:path "/"
Expand Down
94 changes: 3 additions & 91 deletions src/apossiblespace/parts/api/auth.clj
Original file line number Diff line number Diff line change
@@ -1,65 +1,13 @@
(ns apossiblespace.parts.api.auth
(:require
[buddy.sign.jwt :as jwt]
[buddy.auth :refer [authenticated?]]
[buddy.auth.backends :as backends]
[buddy.auth.middleware :refer [wrap-authentication wrap-authorization]]
[buddy.hashers :as hashers]
[apossiblespace.parts.auth :as auth]
[ring.util.response :as response]
[apossiblespace.parts.db :as db]
[apossiblespace.parts.config :as conf]
[com.brunobonacci.mulog :as mulog])
(:import
[java.time Instant]))

(def secret
(conf/jwt-secret (conf/config)))

(def auth-backend
(backends/jws
{:secret secret
:options {:alg :hs256}
:on-error (fn [_request ex]
(mulog/log ::auth-backend :error (.getMessage ex))
nil)
:token-name "Bearer"
:auth-fn (fn [claims]
(mulog/log ::auth-backend-auth-fn :claims claims)
claims)}))

(defn create-token
"Create a JWT token that will expire in 1 hour"
[user-id]
(let [now (Instant/now)
exp (.plusSeconds now 3600)
claims {:iss "http://localhost:3000/api" ;; TODO: Set this from configuration?
:sub user-id
:aud "http://localhost:3000"
:iat (.getEpochSecond now)
:exp (.getEpochSecond exp)}]
(jwt/sign claims secret {:alg :hs256})))

(defn hash-password
[password]
(hashers/derive password))

(defn check-password
[password hash]
(:valid (hashers/verify password hash)))

(defn authenticate
"Checks if a user represented by EMAIL exists in db, checks their PASSWORD if so"
[{:keys [email password]}]
(when-let [user (db/query-one (db/sql-format {:select [:*]
:from [:users]
:where [:= :email email]}))]
(when (check-password password (:password_hash user))
(create-token (:id user)))))
[com.brunobonacci.mulog :as mulog]))

(defn login
[request]
(let [{:keys [email password]} (:body request)]
(if-let [token (authenticate {:email email :password password})]
(if-let [token (auth/authenticate {:email email :password password})]
(do
(mulog/log ::login :email email :status :success)
(-> (response/response {:token token})
Expand All @@ -78,39 +26,3 @@
[_]
(-> (response/response {:message "Logged out successfully"})
(response/status 200)))

(defn wrap-jwt-authentication
"Middleware adding JWT authentication to a route"
[handler]
(-> handler
(wrap-authentication auth-backend)
(wrap-authorization auth-backend)))

(defn jwt-auth
"Middleware ensuring a route is only accessible to authenticated users"
[handler]
(fn [request]
(if (authenticated? request)
(handler request)
(-> (response/response {:error "Unauthorized"})
(response/status 401)))))

(defn get-user-id-from-token
[request]
(get-in request [:identity :user-id]))

(comment
;; Example usage in REPL
(def user {:email "[email protected]"
:username "testuser"
:display-name "Test User"
:password "password123"
:role "client"})
(register user)

(def token (authenticate {:email "[email protected]" :password "password123"}))

(def invalid-token (authenticate {:email "[email protected]" :password "wrongpassword"}))

(when token (jwt/unsign token secret))
#_())
22 changes: 21 additions & 1 deletion src/apossiblespace/parts/api/middleware.clj
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
(ns apossiblespace.parts.api.middleware
(:require [reitit.ring.middleware.exception :as exception]
[com.brunobonacci.mulog :as mulog]
[clojure.string :as str])
[buddy.auth.middleware :refer [wrap-authentication wrap-authorization]]
[buddy.auth :refer [authenticated?]]
[ring.util.response :as response]
[clojure.string :as str]
[apossiblespace.parts.auth :as auth])
(:import (org.sqlite SQLiteException)))

(defn exception-handler
Expand Down Expand Up @@ -54,3 +58,19 @@
authenticated? (boolean user-id)]
(mulog/log ::request :request request, :authenticated? authenticated? :user-id user-id)
(handler request))))

(defn wrap-jwt-authentication
"Middleware adding JWT authentication to a route"
[handler]
(-> handler
(wrap-authentication auth/backend)
(wrap-authorization auth/backend)))

(defn jwt-auth
"Middleware ensuring a route is only accessible to authenticated users"
[handler]
(fn [request]
(if (authenticated? request)
(handler request)
(-> (response/response {:error "Unauthorized"})
(response/status 401)))))
74 changes: 74 additions & 0 deletions src/apossiblespace/parts/auth.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
(ns apossiblespace.parts.auth
(:require
[buddy.sign.jwt :as jwt]
[buddy.auth.backends :as backends]
[buddy.hashers :as hashers]
[apossiblespace.parts.db :as db]
[apossiblespace.parts.config :as conf]
[com.brunobonacci.mulog :as mulog])
(:import
[java.time Instant]))

(def secret
(conf/jwt-secret (conf/config)))

(def backend
(backends/jws
{:secret secret
:options {:alg :hs256}
:on-error (fn [_request ex]
(mulog/log ::auth-backend :error (.getMessage ex))
nil)
:token-name "Bearer"
:auth-fn (fn [claims]
(mulog/log ::auth-backend-auth-fn :claims claims)
claims)}))

(defn create-token
"Create a JWT token that will expire in 1 hour"
[user-id]
(let [now (Instant/now)
exp (.plusSeconds now 3600)
claims {:iss "http://localhost:3000/api" ;; TODO: Set this from configuration?
:sub user-id
:aud "http://localhost:3000"
:iat (.getEpochSecond now)
:exp (.getEpochSecond exp)}]
(jwt/sign claims secret {:alg :hs256})))

(defn hash-password
[password]
(hashers/derive password))

(defn check-password
[password hash]
(:valid (hashers/verify password hash)))

(defn authenticate
"Checks if a user represented by EMAIL exists in db, checks their PASSWORD if so"
[{:keys [email password]}]
(when-let [user (db/query-one (db/sql-format {:select [:*]
:from [:users]
:where [:= :email email]}))]
(when (check-password password (:password_hash user))
(create-token (:id user)))))

(defn get-user-id-from-token
[request]
(get-in request [:identity :user-id]))

(comment
;; Example usage in REPL
(def user {:email "[email protected]"
:username "testuser"
:display-name "Test User"
:password "password123"
:role "client"})
(register user)

(def token (authenticate {:email "[email protected]" :password "password123"}))

(def invalid-token (authenticate {:email "[email protected]" :password "wrongpassword"}))

(when token (jwt/unsign token secret))
#_())
2 changes: 1 addition & 1 deletion src/apossiblespace/parts/entity/user.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(ns apossiblespace.parts.entity.user
(:require
[apossiblespace.parts.api.auth :as auth]
[apossiblespace.parts.auth :as auth]
[apossiblespace.parts.db :as db]))

(def allowed-update-fields #{:email :display_name :password})
Expand Down
47 changes: 16 additions & 31 deletions test/apossiblespace/parts/api/auth_test.clj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
(ns apossiblespace.parts.api.auth-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[apossiblespace.parts.auth :as auth-utils]
[apossiblespace.parts.api.auth :as auth]
[apossiblespace.parts.db :as db]
[apossiblespace.parts.entity.user :as user]
[apossiblespace.parts.api.account :as account]
[buddy.sign.jwt :as jwt]
Expand All @@ -14,8 +14,8 @@
(deftest test-create-token
(testing "create-token generates a valid JWT"
(let [user-id 1
token (auth/create-token user-id)
secret auth/secret
token (auth-utils/create-token user-id)
secret auth-utils/secret
decoded (jwt/unsign token secret)
now-seconds (.getEpochSecond (Instant/now))]
(is (= user-id (:sub decoded)))
Expand All @@ -25,38 +25,38 @@
(deftest test-hash-password
(testing "hash-password creates a valid hash"
(let [password "secret123"
hash (auth/hash-password password)]
hash (auth-utils/hash-password password)]
(is (not= password hash))
(is (auth/check-password password hash)))))
(is (auth-utils/check-password password hash)))))

(deftest test-check-password
(testing "check-password validates correct password"
(let [password "correct-password"
hash (auth/hash-password password)]
(is (auth/check-password password hash))))
hash (auth-utils/hash-password password)]
(is (auth-utils/check-password password hash))))

(testing "check-password rejects incorrect password"
(let [password "correct-password"
hash (auth/hash-password password)]
(is (not (auth/check-password "wrong-password" hash))))))
hash (auth-utils/hash-password password)]
(is (not (auth-utils/check-password "wrong-password" hash))))))

(deftest test-authenticate
(testing "authenticate succeeds with correct credentials"
(let [user-data (factory/create-test-user)
{:keys [email password]} user-data]
(user/create! user-data)
(let [token (auth/authenticate {:email email :password password})]
(let [token (auth-utils/authenticate {:email email :password password})]
(is (string? token))
(is (jwt/unsign token auth/secret)))))
(is (jwt/unsign token auth-utils/secret)))))

(testing "authenticate fails with incorrect password"
(let [user-data (factory/create-test-user)
{:keys [email]} user-data]
(user/create! user-data)
(is (nil? (auth/authenticate {:email email :password "wrongpassword"})))))
(is (nil? (auth-utils/authenticate {:email email :password "wrongpassword"})))))

(testing "authenticate fails with non-existent user"
(is (nil? (auth/authenticate {:email "[email protected]" :password "anypassword"})))))
(is (nil? (auth-utils/authenticate {:email "[email protected]" :password "anypassword"})))))

(deftest test-login
(testing "login succeeds with correct credentials"
Expand All @@ -67,7 +67,7 @@
(let [response (auth/login {:body {:email email :password password}})]
(is (= 200 (:status response)))
(is (:token (:body response)))
(is (jwt/unsign (:token (:body response)) auth/secret)))))
(is (jwt/unsign (:token (:body response)) auth-utils/secret)))))

(testing "login fails with incorrect password"
(let [user-data (factory/create-test-user)
Expand All @@ -89,28 +89,13 @@
(is (= 200 (:status response)))
(is (= {:message "Logged out successfully"} (:body response))))))

(deftest test-jwt-auth-middleware
(testing "jwt-auth middleware allows authenticated requests"
(let [handler (auth/jwt-auth (fn [_] {:status 200 :body "Success"}))
request {:identity {:user-id 1}}
response (handler request)]
(is (= 200 (:status response)))
(is (= "Success" (:body response)))))

(testing "jwt-auth middleware blocks unauthenticated requests"
(let [handler (auth/jwt-auth (fn [_] {:status 200 :body "Success"}))
request {}
response (handler request)]
(is (= 401 (:status response)))
(is (= {:error "Unauthorized"} (:body response))))))

(deftest test-get-user-id-from-token
(testing "get-user-id-from-token extracts user-id from request"
(let [request {:identity {:user-id 1}}
user-id (auth/get-user-id-from-token request)]
user-id (auth-utils/get-user-id-from-token request)]
(is (= 1 user-id))))

(testing "get-user-id-from-token returns nil for unauthenticated request"
(let [request {}
user-id (auth/get-user-id-from-token request)]
user-id (auth-utils/get-user-id-from-token request)]
(is (nil? user-id)))))
15 changes: 15 additions & 0 deletions test/apossiblespace/parts/api/middleware_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@
(is (= 409 (:status response)))
(is (= {:error expected-message} (:body response)))))))

(deftest test-jwt-auth-middleware
(testing "jwt-auth middleware allows authenticated requests"
(let [handler (middleware/jwt-auth (fn [_] {:status 200 :body "Success"}))
request {:identity {:user-id 1}}
response (handler request)]
(is (= 200 (:status response)))
(is (= "Success" (:body response)))))

(testing "jwt-auth middleware blocks unauthenticated requests"
(let [handler (middleware/jwt-auth (fn [_] {:status 200 :body "Success"}))
request {}
response (handler request)]
(is (= 401 (:status response)))
(is (= {:error "Unauthorized"} (:body response))))))

;; FIXME: This test suite fails in CI becasue of Mulog's asynchronous nature.
;; The solution seems to be to create a custom publisher that will take the
;; events and store into an atom and then use the atom to verify the
Expand Down
Loading

0 comments on commit 5c3c160

Please sign in to comment.