This module serves as a starting point for adding authentication and RBAC (Role-Based Access Control) to your application. Before using the module for the first time, we recommend reading the official platformOS documentation on User Authentication Basics and on Example Application.
This module follows the platformOS DevKit best practices and includes the core module as a dependency, enabling you to implement patterns such as Commands and Events.
For more information,
- read the documentation about the built-in User table,
- learn about how platformOS manages sessions,
- and gain a high-level overview of authentication strategies in platformOS.
The platformOS User Module is available on the Partner Portal Modules Marketplace.
Before installing the module, please ensure you have pos-cli installed. This tool is essential for managing and deploying platformOS projects.
The platformOS User Module is fully compatible with platformOS Check, a linter and language server that supports any IDE with Language Server Protocol (LSP) integration. For Visual Studio Code users, you can enhance your development experience by installing the VSCode platformOS Check Extension.
-
Navigate to your project directory where you want to install the User Module.
-
Run the installation command:
pos-cli modules install user
pos-cli modules download userThis command installs the User Module along with its dependencies (such as pos-module-core and pos-module-common-styling and updates or creates the app/pos-modules.json file in your project directory to track module configurations.
-
Install the module using the pos-cli.
-
Configure the common-styling to include default styles. It is recommended that you familiarize with the common-styling module by reading its README file. At ensures that your Layout includes:
a) pos-app class in the root html tag
b) css files from the common-styling module in the head section
c) js code to create a global pos namespace in the head section
d) liquid code which displays built-in notifications after {{ content_for_layout }} in the body section
Navigate to app/views/layouts/application.liquid to see a complete example of a layout file with the common-styling and user module stylesheets and js code included.
To generate a new migration, can use pos-cli migrations generate command. Remember to change env to your environment that you've used for the pos-cli env add command. If you don't remember it, check the .pos file.
pos-cli migrations generate <env> setup_user_default_role
Add the following line of code to the newly created migration file to set the default role to member:
{% liquid
function result = 'modules/core/commands/variable/set', name: 'USER_DEFAULT_ROLE', value: 'member'
log result, type: 'setup_user_default_role result'
%}
Don’t forget to deploy your code to invoke the newly created migration:
pos-cli deploy <env>
- Overwrite default views that you would like to customize by following the guide on overwriting a module file. This allows you to add functionality based on your project requirements, such as extending the registration form with additional fields. At a minimum, you should overwrite the permissions file, where you will configure RBAC authorization roles and permissions for your application:
mkdir -p app/modules/user/public/lib/queries/role_permissions
cp modules/user/public/lib/queries/role_permissions/permissions.liquid app/modules/user/public/lib/queries/role_permissions/permissions.liquid
- Create a superadmin using the GraphQL Explorer.
- To create a new user, use the user_create mutation. The query to create a user with the email
admin@example.comand passwordpasswordwould look as follows:
mutation user_create {
user: user_create(user: {
email: "admin@example.com",
password: "password"
}) {
id
email
}
}
The default behavior of modules is that the files are never deleted. It is assumed that developers might not have access to all of the files, and thanks to this feature, they can still overwrite some of the module's files without breaking them. Since the User Module is fully public, it is recommended to delete files on deployment. To do this, ensure your app/config.yml includes the User Module and its dependencies in the list modules_that_allow_delete_on_deploy:
modules_that_allow_delete_on_deploy:
- core
- userWe recommend creating a new Instance and deploying this module as an application to get a better understanding of the basics and the functionality this module provides. When you install the module using pos-cli modules install user, only the contents of the modules/user will be available in your project. The app directory serves as an example of how you could incorporate the User Module into your application. When analyzing the code in the app directory, pay attention to the following files:
app/config.ymlandapp/user.yml: These files are defined according to the Setup instructions.app/views/page/index.liquid: Implements the example application homepage, displaying information about the currently logged-in user if available, or providing links for registration, sign-in, and password reset functionality for unauthenticated users.app/views/pages/admin/index.liquid: Implements the/adminpage, demonstrating permission-checking functionality.app/modules/user/public/lib/queries/role_permissions/permissions.liquid: Demonstrates how to configure permissions in your application by overwriting a module file.
Once the module is installed and you have completed the setup, you will immediately gain access to the new endpoints created by this module. For example, you can navigate to /users/new for the registration form or /sessions/new for the login form. This section describes all the functionalities provided by this module and includes a roadmap for potential future enhancements.
- Registration:
Provides CRUD operations (Create, Read, Update, Delete) for user management and implements the necessary endpoints for user registration. These views are located in themodules/user/public/views/pages/users/directory. This handles essential user registration processes in platformOS. - Session-Based Authentication:
This feature enables Sign In and Sign Out forms, providing session-based authentication. It includes endpoints and forms for securely managing user sessions on the platform. - Reset Password:
Enables users to reset their password through defined endpoints in themodules/user/public/views/pages/passwords/directory. Note that on the staging environment, email notifications require additional configuration to send email. - RBAC Authorization: Implements Role-Based Access Control (RBAC), allowing fine-grained authorization management based on user roles. This lets you define who can access specific parts of your platform based on assigned roles.
- Impersonation - logging as another user: This feature allows logging in as another user, providing access to all of their functionalities. It comes with a dedicated logout process which logs user back to their original profile. It includes security consideration that only superadmin can impersonate another superadmin user.
- OAuth Module Integration:
This feature allows users to authenticate using external identity providers (such as Google, Facebook, or GitHub). - 2FA Authentication:
Adds Two-Factor Authentication (2FA), including:- Displaying OTP secret QR code for setting up 2FA. More details can be found in the OTP secret object documentation.
- OTP Verification: After setting up 2FA, the user will need to verify the code. Refer to the OTP verification documentation.
TODO (Upcoming Features)
- Mandatory Email Verification (Feature Flag):
Adds a feature flag to enforce mandatory email verification, ensuring users validate their email addresses before gaining full access to the platform. - Mandatory SMS Verification (Feature Flag):
Similar to email verification, this feature flag will enforce mandatory SMS verification during the registration process to improve account security. - JWT Authentication:
Enables JWT (JSON Web Token) authentication, allowing for secure, stateless authentication through token-based authentication mechanisms. - Built-in CAPTCHA Protection:
Adding CAPTCHA to various user authentication forms to prevent spam and automated sign-ups, improving platform security.
The User module is fully functional and requires no modifications to implement a basic registration process. This module provides essential CRUD operations for the built-in User object in platformOS, enabling you to manage users.
With these CRUD commands, you can handle typical user management operations such as registering new users, updating user information, and managing user data through platformOS’s API.
The table below outlines the resourceful routes provided for registration functionality:
| HTTP method | slug | page file path | description |
|---|---|---|---|
| GET | /users/new | modules/user/public/views/pages/users/new.liquid |
Renders a registration form with inputs for email and password. |
| POST | /users | modules/user/public/views/pages/users/create.liquid |
Adds a new User to the database and logs the user in or re-renders the form if validation errors occur. You can modify the redirect path using the redirect_to param or by setting return_to Session field. |
The User module provides basic CRUD (Create, Read, Update, Delete) functionality for managing users in platformOS. You can run commands such as creating or deleting users.
You can create:
function result = 'modules/user/commands/user/create', email: 'admin@example.com', password: 'password', roles: []Note
The command will also automatically invoke modules/user/commands/profile/create to create a corresponding profile for the user. To follow seucity best parctices, behind the scenes, platformOS will apply bcrypt hashing function to the password before saving it to the database.
load:
function user = 'modules/user/queries/user/load', id: '1'Note
Usually, you will want to load the currently authenticated user to perform authorization. You will want to leverage function profile = 'modules/user/helpers/current_profile' helper for that and fetch profile, not the user. However, if for some reason you would like to load currently authenticated user, you can achieve this by providing [context.current_user.id](https://documentation.platformos.com/api-reference/liquid/platformos-objects#context-current_user) as the ID {% function user = 'modules/user/queries/user/load', id: context.current_user.id %}`
update:
function user = 'modules/user/queries/user/update', id: '1', email: 'admin@example.com', password: 'password'
Note
Behind the scenes, platformOS will apply bcrypt hashing function to the password before saving it to the database.
and delete:
function result = 'modules/user/commands/user/delete', id: '1'
Note
Please note that for every User, pos-module-user also creates a corresponding Profile. If you really want to delete a user, will need to take care of it associations as well, which at minimum will be this user's profile.`
The POST /users endpoint defined in modules/user/public/views/pages/users/create.liquid assigns the default role specified by the constant DEFAULT_USER_ROLE—see Setup for more information.
The following table outlines the resourceful routes for sign-in and sign-out functionality:
| HTTP method | slug | page file path | description |
|---|---|---|---|
| GET | /sessions/new | modules/user/public/views/pages/sessions/new.liquid |
Renders a sign-in form with inputs for user's email and password. |
| POST | /sessions | modules/user/public/views/pages/sessions/create.liquid |
Creates a session for the authenticated user based on password authentication or re-renders the sign in form if credentials do not match. You can modify the redirect path with a param called redirect_to or by setting return_to Session field. |
| DELETE | /sessions | modules/user/public/views/pages/sessions/delete.liquid |
Invalidates the current session and logging the user out. |
You can log the user in (which creates a new session) using the following command:
function res = 'modules/user/commands/session/create', email: 'email@example.com', password: 'password'To access information about the currently logged-in user, use the following command provided by the module:
function profile = 'modules/user/helpers/current_profile'This command is implemented in modules/user/public/lib/helpers/current_profile.liquid. When you investigate the file, you'll notice that it not only loads the user's profile information from the database but also extends the profile's roles. If the user is logged in, the helper adds the authenticated role. If not, it adds the anonymous role instead. The user object is also available under profile.user when the user is logged in.
In most applications, you will have a layout with a navigation bar, where you might want to display different links depending on the user's state - for example, a “Log in” link for unauthenticated users, or a list of user-specific links for logged-in users. To avoid invoking modules/user/helpers/current_profile twice — once in a Page and once in a Layout — the helper uses the export Liquid tag. This tag makes the current profile easily accessible via context.exports.current_profile (see implementation).
As a result, you can include logic like the following in your app/views/layouts/application.liquid file:
{% liquid
if context.current_user
assign current_profile = context.exports.current_profile
unless current_profile
function current_profile = 'modules/user/helpers/current_profile'
endunless
endif
%}It triggers the current_profile helper only if it hasn't already been triggered in a Page. You can then build the navigation and check permissions based on the current profile's roles as follows:
{% liquid
if context.current_user
assign current_profile = context.exports.current_profile
unless current_profile
function current_profile = 'modules/user/helpers/current_profile'
endunless
endif
%}
<nav>
<a href="/">Home</a>
<ul>
{% if current_profile %}
<li>Welcome, {{ current_profile.email }}</li>
{% function can_view_admin = 'modules/user/helpers/can_do', requester: current_profile, do: 'admin_pages.view' %}
{% if can_view_admin %}
<li><a href="/admin">Admin</a></li>
{% endif %}
<form method="post" action="/sessions">
<input type="hidden" name="authenticity_token" value="{{ context.authenticity_token }}">
<input type="hidden" name="_method" value="delete">
<button class="pos-button" type="submit">Logout</button>
</form>
{% else %}
<li><a href="/sessions/new">Login</a></li>
{% endif %}
</ul>
</nav>You can use the can_do helper to check if the currently logged-in user has permission to view certain pages.
If you want to add a button to your web site to log out the logged in user, you can create a form that sends a DELETE request to the /sessions endpoint provided by the module. Here is an example of how to implement this:
{% if context.current_user %}
<form method="post" action="/sessions">
<input type="hidden" name="authenticity_token" value="{{ context.authenticity_token }}">
<input type="hidden" name="_method" value="delete">
<button type="submit">Logout</button>
</form>
{% endif %}If you want to log out the user in your own endppint, run the following command provided by the module:
function res = 'modules/user/commands/session/destroy'It’s possible to skip password validation and create a session by setting the validate_password boolean argument to false when calling the modules/user/commands/session/create command. In this case, the user_id argument must be provided.
function res = 'modules/user/commands/session/create', user_id: '1', validate_password: falseThe reset password functionality consists of two resources: password and authentication_link.
The table below contains the resourceful routes provided for the reset password functionality, ordered based on the flow. The process begins with GET /passwords/reset and ends at POST /passwords/create, which updates the password and redirects the user to the sign-in page.
| HTTP method | slug | page file path | description |
|---|---|---|---|
| GET | /passwords/reset | modules/user/public/views/pages/passwords/reset.liquid |
Renders a reset password form. |
| POST | /authentication_links | modules/user/public/views/pages/authentication_links/create.liquid |
Generates a link with temporary token and sends an email using the modules/user/commands/emails/auth-link command to the email address provided by the user in the reset password step. |
| GET | /passwords/new | modules/user/public/views/pages/passwords/new.liquid |
This endpoint authenticates the user using the temporary token using modules/user/helpers/user_from_temporary_token helper and, if successful, it renders a form where the user can provide their new password. |
| POST | /passwords/create | modules/user/public/views/pages/passwords/create.liquid |
Overwrites the existing user's password with the new password and redirects the user to the GET /sessions/new endpoint, so they can log in. |
To create or update a given user's password:
function result = 'modules/user/commands/passwords/create', object: objectTo create an authentication link that points to the GET /passwords/edit endpoint, which will be sent to the user's email, use the following command. The authentication link will include a temporary token that authenticates a user based on their email only:
function object = 'modules/user/commands/authentication_links/create', email: "john@doe.com", host: context.location.hostThe module provides the foundation for implementing Role-Based Access Control (RBAC) in your platformOS application. As described in the registration section, all users initially receive the role defined by the DEFAULT_USER_ROLE constant.
There are three built-in roles that are provided out of the box by the module:
This role is artificially granted in modules/user/public/lib/queries/user/current.liquid. It represents an anonymous user, for which you might want to still want to check some permission. For example, only anonymous users should be able to sign in or register into the system. This is why you will see built-in permissions in modules/user/public/lib/queries/role_permissions/permissions.liquid for users.register and sessions.create, which are checked in Endpoints for Sign-In and Endpoints for the registration.
A typical use case for the anonymous user role is on an eCommerce website, where anonymous users can purchase items without registering.
This role is artificially granted in modules/user/public/lib/queries/user/current.liquid.It represents an authenticated user. The typical use case for this role is to restrict access to certain areas for anonymous users, without assigning a specific role to the user yet. For example, authenticated users might access free content, but to access paid content, they would need to subscribe, after which the system would grant them an additional role like member or subscriber with extra permissions.
Any user with the superadmin role will have immediate access to any permission checked in the Authorization Commands.
The user module automatically creates a profile for each registered user. Each profile can be assigned a set of roles.
You can append a role using the append command:
function result = 'modules/user/commands/profiles/roles/append', id: 1, role: "admin"You can remove a role using the remove command:
function result = 'modules/user/commands/profiles/roles/remove', id: 1, role: "admin"You can set multiple roles at once using the set command:
assign roles = '["member", "admin"]' | parse_json
function result = 'modules/user/commands/profiles/roles/set', id: 1, roles: rolesYou can fetch all defined roles in the system by using modules/user/queries/roles/all query:
function roles = 'modules/user/queries/roles/all'You can fetch all custom roles (without authenticated and anonymous) defined in the system by using modules/user/queries/roles/custom query:
function roles = 'modules/user/queries/roles/custom'The module offers several helper commands to authorize users:
This command returns true or false depending on whether the user has permission to perform the operation defined by the do argument. It is useful for modifying the UI based on permissions, ensuring that functionalities a user does not have access to are not displayed.
function current_profile = 'modules/user/helpers/current_profile'
function can = 'modules/user/helpers/can_do', requester: current_profile, do: 'admin_pages.view'It can also accept additional entity and access_callback arguments, which allow you to [write your own authorization rules](#creating your-own-authorization-commands) cleanly.
If the user does not have permission, the system renders a 403 Unauthorized page, and the flow stops. Optionally, it accepts redirect_anonymous_to_login (boolean) parameter - if set to true, if the user is unauthenticated, instead of rendering 403, it will redirect the user to /sessions/new endpoint and upon login redirect back to anonymous_return_to parameter (defaults to current page - context.location.href).
This command uses the deprecated include tag to work with the break Liquid tag properly - we do not want to execute code past this point if the user has no permission.
function current_profile = 'modules/user/helpers/current_profile'
# platformos-check-disable ConvertIncludeToRender, UnreachableCode
include 'modules/user/helpers/can_do_or_unauthorized', requester: current_profile, do: 'users.register', redirect_anonymous_to_login: true
# platformos-check-enable ConvertIncludeToRender, UnreachableCodeThe platformos-check-disable and platformos-check-enable tags are used to prevent the platformOS-check from reporting a warning for using the include tag instead of the recommended render tag.
If the user does not have permission, they will be redirected to the URL provided as an argument. This command uses the deprecated include tag to work with the break Liquid tag properly - we do not want to execute code past this point if the user has no permission.
function current_profile = 'modules/user/helpers/current_profile'
# platformos-check-disable ConvertIncludeToRender, UnreachableCode
include 'modules/user/helpers/can_do_or_redirect', requester: current_profile, do: 'users.register', return_url: '/sessions/new'
# platformos-check-enable ConvertIncludeToRender, UnreachableCodeThe platformos-check-disable and platformos-check-enable will let platformos-check to not report a warning for using the include tag instead of the render tag.
You can leverage existing commands while providing custom authorization rules by using the access_callback and entity arguments. Consider the following complex real-life example:
{% assign order = '{"buyer_id": 1, "seller_id": 2}' | parse_json %}
function can = 'modules/user/helpers/can_do', requester: user, do: 'orders.confirm', entity: order, access_callback: 'can/orders'In this example, the access_callback is set to can/orders. We recommend placing all your authorization rules in one location: app/lib/can, naming the file after the relevant Resource.
The entity represents the object for which you want to check if the requester has access. In this example, it’s an eCommerce order.
The example implementation of the app/lib/can/orders.liquid might be as follows:
{% liquid
assign order = entity
if order == blank
return false
endif
# For simplicity, there is a special `orders.manage.all` permission that allows the requester to do anything with orders.
# Because it leverages the `can_do` helper, it will always return true for superadmins.
function can_manage_all = 'modules/user/helpers/can_do', requester: requester, do: 'orders.manage.all'
if can_manage_all
return true
endif
# Check if the requester has a role that grants permission equal to `do`
function can = 'modules/user/helpers/can_do', requester: requester, do: do
if can
case do
when 'orders.confirm'
if requester.id == order.seller_id
return true
endif
when 'orders.buyer_cancel'
if requester.id == order.buyer_id
return true
endif
endcase
endif
return can
%}The rules can be summarized as follows:
- If the requester has access to the
orders.manage.allpermission, do not check granular permissions (likeorders.confirmin the example), just grant access to all actions related to orders. - If the requester does not have
orders.manage.all, check whether they have permission for the specific action represented bydo( likeorders.confirmin the example). - If the requester has the
dopermission, check if they should have access to that particular order. Having theorders.confirmpermission does not imply access to confirm all orders - sellers should only be able to confirm their own orders. In this example, theseller_idmust match therequester.id. In more complex scenarios, you may need to perform additional GraphQL queries to enrich therequesterororderdata, ensuring accurate authorization.
Roles and their permissions are defined in the modules/user/public/lib/queries/role_permissions/permissions.liquid file in a simple JSON format. You can modify this file to suit your application’s requirements. For example, if you want to add a new role, such as foo, with permissions foo.show and foo.manage, you can extend the JSON as follows:
"foo": [
"foo.show",
"site.manage"
]
If you receive a 500 error after modifying the permissions.liquid file, check the logs for hints. The error might be due to an invalid JSON format, such as a missing or extra comma. For example, the following modification will raise an error due to a trailing comma:
"foo": [
"foo.show",
"site.manage", <- this comma will cause an error
]
-
- Naming Convention for Permissions: Use the format
<resource>.<do>for naming your permissions. For example:
article.createarticle.editcomment.delete
- Naming Convention for Permissions: Use the format
- Managing Permissions for Administrators/Moderators: Use
<resource>.manage.allfor permissions granted to administrators or moderators. Regular users should typically have permission only for the entities they create. For example, in a blog application that allows users to write comments, users should be able to edit and delete their own comments. In contrast, administrators and moderators need permission to manage all comments. We recommend creating a<resource>.manage.allpermission for this purpose and handling it as described in the Creating Your Own Authorization Commands example.
This feature allows users to authenticate using external identity providers. Each integration can be encapsulated in its own module. As of today, we have created three modules as examples, which you can also use in production. Those are:
Please follow the instructions provided by each installed module to configure it.
To implement a custom OAuth2 provider, you must provide two helper methods:
get_redirect_url - which generates a redirect URL to begin the OAUTH2 flow.
get_user_info - which returns a dictionary:
| Key | Value |
|---|---|
| sub | User's id in the external system |
| first_name | User's first name. |
| last_name | User's last name. |
| User's email. | |
| valid | A boolean indicating whether the flow was successful or not. |
This feature enables the use of a second form of identification to verify a user's identity and grant them access to the account. Users can scan the generated QR code with any authenticator app to be able to generate one-time passwords (OTP).
To use 2FA, users must scan the provider QR code with an authenticator app of their choice, input the generated code (6 digits) and confirm their password. Once successfully enabled, they will be prompted to provide the generated code every time they log in, change their email or if they try to disable 2FA.
This functionality allows a user with users.impersonate permission to log in as another user (unless they are superadmin - in which scenario users.impersonate_superadmin permission is needed). It comes with a dedicated logout process which logs the user back to their original profile.
The table below contains the resourceful routes provided for the logging as functionality, ordered based on the flow.
| HTTP method | slug | page file path | description |
|---|---|---|---|
| POST | sessions/impersonations | modules/user/public/views/pages/sessions/impersonation/create.liquid |
This feature allows an administrator to log in as another based on user_id parameter. Only users with the superadmin role can log in as another superadmin user. The original user ID is stored in the session field named original_user_id |
| DESTROY | /sessions/impersonations | modules/user/public/views/pages/sessions/impersonation/destroy.liquid |
Logging back in as the original user whose id was stored in original_user_id session field. |
You can easily customize the User module by overwriting a module file. This allows you to modify specific functionalities without changing the original code. Below are some common customization examples.
By default, the module creates a Page with the /users/new endpoint for the registration form. If you'd like to change it (e.g., to /sign-up), you can do so by overwriting this page and updating the slug.
Steps:
- Create the necessary nested directories to be able to place your overwrite file there:
mkdir -p app/modules/user/public/views/pages/users/
- Copy the original page file you would like to overwrite:
cp modules/user/public/views/pages/users/new.liquid app/modules/user/public/views/pages/users/new.liquid
-
Edit the overwrite file located in
app/modules/user/public/views/pages/users/new.liquid. Modify the YAML section of the page to change theslug. (Make sure you are modifying the overwrite and not the original file—overwrites are located in the<project root>/app/modulesdirectory, whereas the original files are located in the<project root>/modulesdirectory.) -
Modify the Liquid code - explicitly define
slugproperty tosign-upof theapp/modules/user/public/views/pages/users/new.liquidPage to define/sign-upendpoint:
As a result, the registration form will now be available at /sign-up, and the /users/new URL will render a 404 Not Found error.
If you want to modify the Sign-In form's HTML, you can overwrite the Partial responsible for rendering the form.
Steps:
- Create the directory for the overwrite. Since we are overriding the presentation layer, we should overwrite the Partial.
mkdir -p modules/user/public/views/partials/sessions
- Copy the partial responsible for rendering the sign-in form:
cp modules/user/public/views/partials/sessions/new.liquid app/modules/user/public/views/partials/sessions/new.liquid
Note
Please note that in this example, we are working with the sessions directory, as this is where the Sign-In functionality is located.
Now, you can freely modify the HTML or presentation layer of the sign-in form in the copied file.
The module also provides several useful queries and commands to help you manage users and permissions
function users_count = 'modules/user/queries/user/count'
function current_user = 'modules/user/queries/user/current'
function current_user = 'modules/user/queries/user/load', id: 1
To manage versioning with Git and npm, you can follow these commands:
git fetch origin --tags
npm version major | minor | patch