Xiana framework does not have any login or logout functions, as every application has its own user management logic.
Though Xiana offers all the tools to easily implement them. One of the default interceptors is the session interceptor.
If included, it can validate a request only if the session already exists in session storage. To log in a user, simply
add its session data to the storage. (TODO: where? What is the exact key to modify?). All sessions should have a unique
UUID as session-id. The active session lives under (-> state :session-data)
. On every request, before reaching the
action defined by the route, the interceptor checks [:headers :session-id]
among other things. Which is the id of the
current session. The session is then loaded in session storage. If the id is not found, the execution flow is
interrupted with the response:
{:status 401
:body "Invalid or missing session"}
To implement login, you need to use the session interceptor in
(let [;; Create a unique ID
session-id (UUID/randomUUID)]
;; Store a new session in session storage
(add! session-storage session-id {:session-id session-id})
;; Make sure session-id is part of the response
(assoc-in state [:response :headers :session-id] (str session-id)))
or use the guest-session
interceptor, which creates a guest session for unknown, or missing sessions.
For role-based access control, you need to store the actual user in your session data. First, you'll have to query it from the database. It is best placed in models/user namespace. Here's an example:
(defn fetch-query
[state]
(let [login (-> state :request :body-params :login)]
(-> (select :*)
(from :users)
(where [:and
:is_active
[:or
[:= :email login]
[:= :username login]]]))))
To execute it, place db-access
interceptor in the interceptors list. It injects the query result into the state. If
you already have this injected, you can modify your create session function like this:
(let [;; Get user from database result
user (-> state :response-data :db-data first)
;; Create session
session-id (UUID/randomUUID)]
;; Store the new session in session storage. Notice the addition of user.
(add! session-storage session-id (assoc user :session-id session-id))
;; Make sure session-id is part of the response
(assoc-in state [:response :headers :session-id] (str session-id)))
Be sure to remove user's password and any other sensitive information before storing it:
(let [;; Get user from database result
user (-> state
:response-data
:db-data
first
;; Remove password for session storage
(dissoc :users/password))
;; Create session id
session-id (UUID/randomUUID)]
;; Store the new session in session storage
(add! session-storage session-id (assoc user :session-id session-id))
;; Make sure session-id is part of the response
(assoc-in state [:response :headers :session-id] (str session-id)))
Next, we check if the credentials are correct, so we use an if
statement.
(if (valid-credentials?)
(let [;; Get user from database result
user (-> state
:response-data
:db-data
first
;; Remove password for session storage
(dissoc :users/password))
;; Create session ID
session-id (UUID/randomUUID)]
;; Store the new session in session storage
(add! session-storage session-id (assoc user :session-id session-id))
;; Make sure session-id is part of the response
(assoc-in state [:response :headers :session-id] (str session-id)))
(throw (ex-info "Missing session data"
{:xiana/response
{:body "Login failed"
:status 401}})))
Xiana provides xiana.hash
to check user credentials:
(defn- valid-credentials?
"It checks that the password provided by the user matches the encrypted password from the database."
[state]
(let [user-provided-pass (-> state :request :body-params :password)
db-stored-pass (-> state :response-data :db-data first :users/password)]
(and user-provided-pass
db-stored-pass
(hash/check state user-provided-pass db-stored-pass))))
The login logic is done, but where to place it?
Do you remember the side effect interceptor? It's running after we have the query result from the database, and before the final response is rendered with the view interceptor. The place for the function defined above is in the interceptor chain. How does it go there? Let's see an action
(defn action
[state]
(assoc state :side-effect side-effects/login))
This is the place for injecting the database query, too:
(defn action
[state]
(assoc state :side-effect side-effects/login
:query model/fetch-query))
But some tiny thing is still missing. The definition of the response in the all-ok case. A happy path response.
(defn login-success
[state]
(let [id (-> state :response-data :db-data first :users/id)]
(-> state
(assoc-in [:response :body]
{:view-type "login"
:data {:login "succeed"
:user-id id}})
(assoc-in [:response :status] 200))))
And finally the view is injected in the action function:
(defn action
[state]
(assoc state :side-effect side-effects/login
:view view/login-success
:query model/fetch-query))
To do a logout is much easier than a login implementation. The session-interceptor
does half of the work, and if you
have a running session, then it will not complain. The only thing you should do is to remove the actual session from
the state
and from session storage. Something like this:
(defn logout
[state]
(let [session-store (get-in state [:deps :session-backend])
session-id (get-in state [:session-data :session-id])]
(session/delete! session-store session-id)
(dissoc state :session-data)))
Add the ok
response
(defn logout-view
[state]
(-> state
(assoc-in [:response :body]
{:view-type "logout"
:data {:logout "succeed"}})
(assoc-in [:response :status] 200)))
and use it:
(defn logout
[state]
(let [session-store (get-in state [:deps :session-backend])
session-id (get-in state [:session-data :session-id])]
(session/delete! session-store session-id)
(-> state
(dissoc :session-data)
(assoc :view views/logout-view))))
Session management is done via two components
Basically it's an atom backed session protocol implementation, allows you to fetch
add!
delete!
dump
and erase!
session data, or the whole session storage. It doesn't require any additional configuration, and this is
the default set up for handling session storage. All stored session data is wiped out on system restart.
Instead of atom, it uses a postgresql table to store session data. Has the same protocol as in-memory. Configuration is necessary to use it.
CREATE TABLE sessions (
session_data json not null,
session_id uuid primary key,
modified_at timestamp DEFAULT CURRENT_TIMESTAMP
);
config.edn
files: :xiana/session-backend {:storage :database
:session-table-name :sessions}
in case of
:storage
key, in-memory
session backend will be used:session-table-name
key, :sessions
table will be usedthe database connection can be configured in three ways:
In resolution order
:xiana/session-backend {:storage :database
:session-table-name :sessions
:port 5433
:dbname "app-db"
:host "localhost"
:dbtype "postgresql"
:user "db-user"
:password "db-password"}
Just init the backend after the database connection
(defn ->system
[app-cfg]
(-> (config/config app-cfg)
routes/reset
db-core/connect
db-core/migrate!
session/init-backend
ws/start))
If no datasource is provided on initialization, the init-backend
function merges the database config with the
session backend configuration, and creates a new datasource from the result.
RBAC is a handy way to restrict user actions on different
resources. It's a role-based access control and helps you to implement data ownership control. The rbac/interceptor
should be placed inside db-access.
For tiny-RBAC you should provide a role-set. It's a map which defines the application resources, the actions on it, the roles with the different granted actions, and restrictions for data ownership control. This map must be placed in deps.
Here's an example role-set for an image service:
(def role-set
(-> (b/add-resource {} :image)
(b/add-action :image [:upload :download :delete])
(b/add-role :guest)
(b/add-inheritance :member :guest)
(b/add-permission :guest :image :download :all)
(b/add-permission :member :image :upload :all)
(b/add-permission :member :image :delete :own)))
It defines a role-set with:
:image
resource,:upload :download :delete
actions on :image
resource:guest
role, who can download all the images:member
role, who inherits all of :guest
's roles, can upload :all
images, and delete :own
images.The resource and action can be defined on route definition. The RBAC interceptor will check permissions against what is defined here:
(def routes
[["/api" {:handler handler-fn}
["/image" {:get {:action get-image
:permission :image/download}
:put {:action add-image
:permission :image/upload}
:delete {:action delete-image
:permission :image/delete}}]]])
(def role-set
(-> (b/add-resource {} :image)
(b/add-action :image [:upload :download :delete])
(b/add-role :guest)
(b/add-inheritance :member :guest)
(b/add-permission :guest :image :download :all)
(b/add-permission :member :image :upload :all)
(b/add-permission :member :image :delete :own)))
(def routes
[["/api" {:handler handler-fn}
["/login" {:action login
:interceptors {:except [session/interceptor]}}]
["/image" {:get {:action get-image
:permission :image/download}
:put {:action add-image
:permission :image/upload}
:delete {:action delete-image
:permission :image/delete}}]]])
(defn ->system
[app-cfg]
(-> (config/config)
(merge app-cfg)
session-backend/init-backend
routes/reset
db-core/connect
db-core/migrate!
ws/start))
(def app-cfg
{:routes routes
:role-set role-set
:controller-interceptors [interceptors/params
session/interceptor
rbac/interceptor
interceptors/db-access]})
(defn -main
[& _args]
(->system app-cfg))
Prerequisites:
(-> state :deps :role-set)
:permission
key(-> state :session-data :users/role)
If the :permission
key is missing, all requests are going to be granted. If role-set
or :users/role
is
missing, all requests are going to be denied.
When rbac/interceptor
:enter
is executed, it checks if the user has any permission on the
pre-defined resource/action
pair. If there is any, it collects all of them (including inherited permissions) into a
set of format: :resource/restriction
.
For example:
:image/own
means the given user is granted the permission to do the given action
on :own
:image
resource. This will help you
to implement data ownership functions. This set is associated
in (-> state :request-data :user-permissions)
If user cannot perform the given action on the given resource (neither by inheritance nor by direct permission), the interceptor will interrupt the execution flow with the response:
{:status 403
:body "Forbidden"}
Data ownership control is about restricting database results only to the elements on which the user is able to perform
the given action. In the context of the example above, it means :member
s are able to delete only the owned :image
s.
At this point, you can use the result of the access control from the state. Continuing with the same
example.
From this generic query
{:delete [:*]
:from [:images]
:where [:= :id (get-in state [:params :image-id])]}
you want to switch to something like this:
{:delete [:*]
:from [:images]
:where [:and
[:= :id (get-in state [:params :image-id])]
[:= :owner.id user-id]]}
To achieve this, you can simply provide a restriction function
into (-> state :request-data :restriction-fn)
The user-permissions
is a set, so it can be easily used for making conditions:
(defn restriction-fn
[state]
(let [user-permissions (get-in state [:request-data :user-permissions])]
(cond
(user-permissions :image/own) (let [user-id (get-in state [:session-data :users/id])]
(update state :query sql/merge-where [:= :owner.id user-id]))
:else state)))
And finally, the only missing piece of code: the model, and the action
(defn delete-query
[state]
{:delete [:*]
:from [:images]
:where [:= :id (get-in state [:params :image-id])]})
(defn delete-image
[state]
(-> state
(assoc :query (delete-query state))
(assoc-in [:request-data :restriction-fn] restriction-fn)))
Can you improve this documentation? These fine people already did:
Krisztian Gulyas, Stas Makarov & g-krisztianEdit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close