@oarepo/vue-popup-login
v3.0.3
Published
A library thant brings authentication and authorization to vuejs applications via login in a popup window. This enables you not to interrupt whatever modifications user has performed on a page.
Downloads
37
Readme
vue-popup-login
A library thant brings authentication and authorization to vuejs applications via login in a popup window. This enables you not to interrupt whatever modifications user has performed on a page.
The library is compatible (with appropriate server support) with openid and shibboleth authentication.
This is 3.x version built for Vue 3 only. Vue-2 compatible version is in the 2.x branch.
Installation
yarn add @oarepo/vue-popup-login@^3.0.0
Demo
The demo is in the src directory. Installation of the plugin is at main.ts, usage examples of route guards in route.ts, application page with protected links at Home.vue, ui callbacks in App.vue.
See the sections below for details. To run the demo:
yarn install
yarn serve
Principle
Login button
The application might use the api in "option" style (that is, via this.$auth
)
or via composition style:
{
setup(props, ctx) {
const api = usePopupLogin()
}
}
When a user clicks on a "login" button, the application should call
this.$auth.login() / api.login()
. This call should be made
preferably in a synchronous method and not in setTimeout or delayed tasks
(doing so might prevent the popup in firefox and with some add blockers).
The process follows as:
- User clicks 'login' button
- App calls
api.login()
. The result of the call is a boolean promise (true meaning logged in) - The library checks if the user is logged in on the server. If yes, the state is synchronized and login finishes.
- The library opens a popup window, passing it a
loginUrl?next=completeUrl
loginUrl
, in most cases implemented by the server, is responsible for the login process- When the login process is finished,
completeUrl
is called - The completion page is responsible for notifying the application that login has finished (see details below)
- The library receives the information and closes the window
- The library checks on the server that the user has indeed logged in
- finally the login promise is resolved and application continues
What can go wrong:
The popup window could not be created - for example browser setting forbids the popup. The default behaviour is to redirect the user to the login page, thus using the current application state.
Application might prevent this by defining an asynchronous
popupFailedHandler
. The popup handler might:- notify the user that this happened, give him guidance how to set the
browser and display
login
button again, calling$auth.login()
and return its result - or return
REDIRECT_LOGIN
to signalize that the library should perform redirection to the login page:- The current window is redirected to
loginUrl?next=completeUrl
as in the previous case - When the login process is finished,
completeUrl
is called. This is always in the form ofcompleteUrl?next=<vue page where the login started>
. If this argument is present, the page should not perform channel-based notification (as there is no opener to listen for the notification) but should redirect the browser to the target page - The vue page is loaded and should call (for example in App or route
guard)
api.check()
that synchronizes the logging state in Vue app with that on the server
- The current window is redirected to
- notify the user that this happened, give him guidance how to set the
browser and display
User can not log in and sticks on the login page. Both pages are open and user must close them both. There is currently no timeout implemented for the login process.
User has logged in but the library failed to close the window (for example, add blocker or browser settings prevent it). The
completeUrl
page should inform the user (after a couple of seconds) that something is not right and ask him to close the page. After returning to the app the user should be logged in.
Login-required route guards
A guard might be configured in the router for pages that require authentication. If a user clicks on a link protected by the guard and is not logged in, the login must happen.
Unfortunately we can not initiate the popup login at this moment - too much time and javascript calls have passed between the time user clicked on the link and route guard was triggered and this would cause the popup be blocked.
Because of that application can register a loginRequiredHandler
that should
display that login is required and provide a login
button
on which the user would click. The click handler should call $auth.login and its
result should be returned as the result of loginRequiredHandler. A sample implementation
is in the sections below.
Unauthorized guards
A guard might be configured that only a subset of users have access - for example, only editors can get in. If a user does not have editor role, we should inform him and give him opportunity to log out / log in as someone else (or switch roles if application allows that).
For a cases like this a noAccessHandler
can be configured that is called
and decides whether to ask for login, continue with the navigation or prevent it.
Configuration
import {createApp} from 'vue'
import router from './router'
import PopupLogin from '@oarepo/vue-popup-login'
createApp(App)
.use(router)
.use(PopupLogin, {
router
// rest of the options
})
.mount('#app')
loginUrl
Default: /auth/login
An url to redirect the user when login
button is clicked. Must accept
the ?next
(or the value of nextQueryParam
) argument with a url where
to redirect the user when login is completed.
In most cases this page is provided by the login backend.
nextQueryParam
Default: next
Name of the query parameter that is used to pass the next page to
login
, logout
, complete
urls.
logoutUrl
Default: /auth/logout
An url to redirect the user when logout
button is clicked. Must accept
the ?next
(or the value of nextQueryParam
) argument with a url where
to redirect the user when logout is completed.
In most cases this page is provided by the login backend.
logoutMethod
Default: 'GET'
HTTP method for logging out. If it is 'GET', browser is redirected to this url.
If it is POST
, browser performs POST request to this url and is redirected to
'/'.
completeUrl
Default: /auth/complete
An url passed as ?next
to the login page. This url MUST be in the same
domain as the Vue application and must:
- if it receives a
?next
parameter it should redirect to this url - Otherwise it should send a message to the frontend app via:
<script>
if (window.location.query.next) {
window.location.href = window.location.query.next
} else {
const bc = new BroadcastChannel('popup-login-channel');
bc.postMessage({
type: "login",
status: "success", // "or error"
message: "Sample ok/error message from the auth server"
})
// after this message vue frontend will close the window.
// if it does not happen:
setTimeout(() => {
alert(`Could not send login data back to the application.
Please close this window manually and reload the application`)
}, 5000)
}
</script>
redirectionCompleteUrl
Default: same as completeUrl
If set this url will be used as completeUrl
in case when could not open
popup and login via same page redirection.
stateUrl
A url implemented by the server that provides login status. It should support HTTP GET method and preferably return:
{
"loggedIn": "true",
"needsProvided": ["viewer", "editor", {
"admin": {
"departments": [110, 115]
}
}]
// any other metadata that server wants to return (for example user name, etc)
}
See "Authorization" section below for the description of needs.
loginStateTransformer
Default: identity function
If stateUrl
above does not return the representation required, this function
has to be provided to convert the response to the format above.
popupFailedHandler
function (): Promise<boolean>
When pop-ups are blocked, this async handler should create an in-page popup
and direct user what to do. It promises to return true
if redirection-based
login should be performed or false
if it handled the situation and user is
being logged in.
The function should explain the situation and suggest the user:
- to enable popups. It should show a button calling
usePopupLogin().login()
function and return Promise resolving to false after the button is clicked - to continue with redirection. On user selection it should return promise resolving to true. This will cause immediate redirection to the login url with the consequence that data entered on the page will be lost.
See the implementation details below for a sample implementation.
loginRequiredHandler
function (extra: { [key: string]: any })
=> Promise<REDIRECT_LOGIN | boolean | CANCEL_NAVIGATION | CONTINUE_NAVIGATION | Location>
This handler is called from route guard when login is required and user is not logged in.
The extra
parameter contains prop route
with the target route where
the user wants to navigate.
The handler should normally display a popup explaining the situation
and a button to "Log in". When the button is clicked, usePopupLogin().login()
should be called as soon as possible and its return value returned.
Alternatively the handler may return:
REDIRECT_LOGIN
constant to start redirection-based loginCANCEL_NAVIGATION
to cancel the navigation and stay on the same pageCONTINUE_NAVIGATION
to bypass the login process and allow non-authenticated user to continue to the target page- router
Location
to navigate to this page instead
noAccessHandler
function (state: AuthenticationState, extra: { [key: string]: any })
=> Promise<REDIRECT_LOGIN | boolean | CANCEL_NAVIGATION | CONTINUE_NAVIGATION | Location>
A route guard may perform permission checks. If these checks fail for a logged-in user, noAccessHandler is called.
The extra
parameter contains prop route
with the target route where
the user wants to navigate.
A sane implementation is to show the user that he has no rights to continue and:
- log the user out and initiate new login via
usePopupLogin().login()
and returning its return value - logout user and return
REDIRECT_LOGIN
constant to start redirection-based login - return
CANCEL_NAVIGATION
to cancel the navigation and stay on the same page - return
CONTINUE_NAVIGATION
to bypass the authorization process and allow non-authorized user to continue to the target page - return router
Location
to navigate to this page instead
For security reasons the first alternative is highly discouraged unless you really know what you are doing. Your application might contain state dependent on logged-in user which in case you forget to clear might bring inconsistencies and unexpected application crashes.
Alternatives 2, 3, 5 or setting window.location.href
directly are much safer alternatives.
Access rights
Access rights are represented as application needs that must be fulfilled by the user.
The application provides a needsRequired
array and user has associated
needsProvided
array.
To evaluate the rights, the library iterates needsRequired
array and checks
if any of those match needsProvided
. If so, the access is allowed.
The matching process of the need:
- if the need is a simple string,
needsProvided
are searched for the same string. If found, access allowed, - if the need is a function, it is executed with
(state: UserAuthenticationState, needsProvided: Need[], extra: any)
and should return true if access allowed, - if the need is an object/array, it is checked if it is contained
(overlaps) in any of the needsProvided. See
lodash.isMatch
for details about the comparison.
Usage
Login/logout button and login state
<button @click="$auth.logout" v-if="loggedIn">Log out</button>
<button @click="$auth.login" v-else>Log in</button>
{
computed: {
loggedIn: () => this.$auth.state.value.loggedIn
}
}
Or, in composition api:
<button @click="logout" v-if="loggedIn">Log out</button>
<button @click="login" v-else>Log in</button>
setup(props, ctx) {
const {state, login, logout} = usePopupLogin()
return {
login,
logout,
loggedIn: computed(() => state.value.loggedIn)
}
}
Routes
To mark that user must be logged in to access a route, add empty meta/authorization object to the route:
routes = [{
path: '/protected',
name: 'protected',
component: () => import('../views/Protected.vue'),
meta: {
authorization: {}
}
}]
You might specify the needs/permissions as well:
routes=[{
path: '/editors',
name: 'editors',
component: () => import('../views/Editors.vue'),
meta: {
authorization: {
needsRequired: [
'editors'
]
}
}
}]
Hiding links with no access
If user is logged in but does not have an access to a page, the link to the page should not be displayed at all. This library provides a helper component to hide the link on no access:
<authorized-link :to="{name: 'editors'}" component="v-btn" color="primary">
This button is shown only to editors
</authorized-link>
This component evaluates :to
prop and if user is allowed to navigate
to this route, it creates a component passed in the component
prop
and passes it all the extra attributes. In the case above it will generate:
<v-btn :to="{name: 'editors'}" color="primary">
This button is shown only to editors
</v-btn>
If user does not have an access, nothing is inserted to the html.
Programmatically checking if user has access rights
To check if the current user has access, call
api.hasAccess(needsRequired, extra)
// or in options mode
this.$auth.hasAccess(needsRequired, extra)
Implementing popup failed handler
The exact implementation of the popup failed handler depends on the framework you are using.
For example, in quasar you can use BottomSheet
plugin (set it inside App's setup to make sure everything is loaded):
api.registerPopupFailedHandler(() => {
return new Promise((resolve) => {
BottomSheet.create({
message: 'Could not log you in because your browser prevents popup windows',
actions: [
{
label: 'Try again',
icon: 'vpn_key',
id: 'again'
},
{
label: 'Leave this page and log in via your login server',
icon: 'login',
id: 'redirect'
}]
}).onOk(action => {
if (action.id === 'again') {
resolve(api.login())
} else {
resolve(REDIRECT_LOGIN)
}
})
})
})
See App.vue for Vuetify example.
Implementing login required handler
Again depends on the framework. In quasar (set it inside App's setup to make sure everything is loaded):
api.registerLoginRequiredHandler(() => {
return new Promise((resolve) => {
BottomSheet.create({
message: 'Authentication required. Click on the button below to log in.',
actions: [{
label: 'Log in',
icon: 'vpn_key',
id: 'log in'
}]
}).onOk(() => {
resolve(api.login())
})
})
})
See App.vue for Vuetify example.
Implementing no access handler
This handler should normally never be called because application should not show links to pages user has no access to. A simple alert with redirection to the homepage might be enough to handle the case gracefully and has already been implemented in the library.
If you like to use your own implementation, feel free to:
api.registerNoAccessHandler(() => {
// ...
})