User authentication #
Users in Penpot may register via several different methods (if enabled in the configuration of the Penpot instance). We have implemented this as a series of "authentication backends" in our code:
- penpot: internal registration with email and password.
- ldap: authentication over an external LDAP directory.
- oidc, google, github, gitlab: authentication over an external service using the OpenID Connect protocol. We have a generic handler, and other ones already preconfigured for popular services.
The main logic resides in the following files:
backend/src/app/rpc/mutations/profile.clj
backend/src/app/rpc/mutations/ldap.clj
backend/src/app/rpc/mutations/verify-token.clj
backend/src/app/http/oauth.clj
backend/src/app/http/session.clj
frontend/src/app/main/ui/auth/verify-token.cljs
We store in the user profiles in the database the auth backend used to register first time (mainly for audit). A user may login with other methods later, if the email is the same.
Register and login #
The code is organized to try to reuse functions and unify processes as much as possible for the different auth systems.
Penpot backend #
When a user types an email and password in the basic Penpot registration page,
frontend calls :prepare-register-profile
method. It generates a "register
token", a temporary JWT token that includes the login data.
This is used in the second registration page, that finally calls
:register-profile
with the token and the rest of profile data. This function
is reused in all the registration methods, and it's responsible of creating the
user profile in the database. Then, it sends the confirmation email if using
penpot backend, or directly opens a session (see below) for othe methods or if
the user has been invited from a team.
The confirmation email has a link to /auth/verify-token
, that has a handler
in frontend, that is a hub for different kinds of tokens (registration email,
email change and invitation link). This view uses :verify-token
RPC call and
redirects to the corresponding page with the result.
To login with the penpot backend, the user simply types the email and password
and they are sent to :login
method to check and open session.
OIDC backend #
When the user press one of the "Log in with XXX" button, frontend calls
/auth/oauth/:provider
(provider is google, github or gitlab). The handler
generates a request token and redirects the user to the service provider to
authenticate in it.
If succesful, the provider redirects to the/auth/oauth/:provider/callback
.
This verifies the call with the request token, extracts another access token
from the auth response, and uses it to request the email and full name from the
service provider.
Then checks if this is an already registered profile or not. In the first case
it opens a session, and in the second one calls:register-profile
to create a
new user in the sytem.
For the known service providers, the addresses of the protocol endpoints are hardcoded. But for a generic OIDC service, there is a discovery protocol to ask the provider for them, or the system administrator may set them via configuration variables.
LDAP #
Registration is not possible by LDAP (we use an external user directory managed outside of Penpot). Typically when LDAP registration is enabled, the plain user & password login is disabled.
When the user types their user & password and presses "Login with LDAP" button,
the :login-with-ldap
method is called. It connects with the LDAP service to
validate credentials and retrieve email and full name.
Similarly as the OIDC backend, it checks if the profile exists, and calls
:login
or :register-profile
as needed.
Sessions #
User sessions are created when a user logs in via any one of the backends. A session token is generated (a JWT token that does not currently contain any data) and returned to frontend as a cookie.
Normally the session is stored in a DB table with the information of the user profile and the session expiration. But if a frontend connects to the backend in "read only" mode (for example, to debug something in production with the local devenv), sessions are stored in memory (may be lost if the backend restarts).
Team invitations #
The invitation link has a call to /auth/verify-token
frontend view (explained
above) with a token that includes the invited email.
When a user follows it, the token is verified and then the corresponding process
is routed, depending if the email corresponds to an existing account or not. The
:register-profile
or :login
services are used, and the invitation token is
attached so that the profile is linked to the team at the end.
Handling unfinished registrations and bouncing users #
All tokens have an expiration date, and when they are put in a permanent storage, a garbage colector task visits it periodically to cleand old items.
Also our email sever registers email bounces and spam complaint reportings
(see backend/src/app/emails.clj
). When the email of one profile receives too
many notifications, it becames blocked. From this on, the user cannot login or
register with this email, and no message will be sent to it. If it recovers
later, it needs to be unlocked manually in the database.
How to test in devenv #
To test all normal registration process you can use the devenv Mail catcher utility.
To test OIDC, you need to register an application in one of the providers:
The URL of the app will be the devenv frontend: http://localhost:3449.
And then put the credentials in backend/scripts/repl
and
frontend/resources/public/js/config.js
.
Finally, to test LDAP, in the devenv we include a test LDAP
server, that is already configured, and only needs to be enabled in frontend
config.js
:
var penpotFlags = "enable-login-with-ldap";