Merge 03fbebc2813704563b6e7d89c108e95ac5f17d95 into 805968aac613fb3719b5fe8a78cdb2d7c7a7f026

This commit is contained in:
Samuel Oechsler 2024-12-17 08:16:53 +10:00 committed by GitHub
commit 57e53c1ea5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1527 additions and 1310 deletions

View File

@ -84,6 +84,47 @@ module.exports = {
});
},
/**
* @param {Object} data
* @param {String} data.identity
* @param {String} [issuer]
* @returns {Promise}
*/
getTokenFromOAuthClaim: (data) => {
let Token = new TokenModel();
data.scope = 'user';
data.expiry = '1d';
return userModel
.query()
.where('email', data.identity)
.andWhere('is_deleted', 0)
.andWhere('is_disabled', 0)
.first()
.then((user) => {
if (!user) {
throw new error.AuthError(`A user with the email ${data.identity} does not exist. Please contact your administrator.`);
}
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
let iss = 'api',
attrs = { id: user.id },
scope = [ data.scope ],
expiresIn = data.expiry;
return Token.create({ iss, attrs, scope, expiresIn })
.then((signed) => {
return { token: signed.token, expires: expiry.toISOString() };
});
});
},
/**
* @param {Access} access
* @param {Object} [data]

View File

@ -4,7 +4,14 @@ module.exports = () => {
return function (req, res, next) {
res.locals.access = null;
let access = new Access(res.locals.token || null);
access.load()
// Allow unauthenticated access to get the oidc configuration
let oidc_access =
req.url === '/oidc-config' &&
req.method === 'GET' &&
!access.token.getUserId();
access.load(oidc_access)
.then(() => {
res.locals.access = access;
next();

View File

@ -10,5 +10,6 @@ module.exports = {
certbot: new Signale({scope: 'Certbot '}),
import: new Signale({scope: 'Importer '}),
setup: new Signale({scope: 'Setup '}),
ip_ranges: new Signale({scope: 'IP Ranges'})
ip_ranges: new Signale({scope: 'IP Ranges'}),
oidc: new Signale({scope: 'OIDC '})
};

View File

@ -21,6 +21,7 @@
"moment": "^2.29.4",
"mysql2": "^3.11.1",
"node-rsa": "^1.0.8",
"openid-client": "^5.4.0",
"objection": "3.0.1",
"path": "^0.12.7",
"signale": "1.4.0",
@ -44,4 +45,4 @@
"scripts": {
"validate-schema": "node validate-schema.js"
}
}
}

View File

@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => {
router.use('/schema', require('./schema'));
router.use('/tokens', require('./tokens'));
router.use('/oidc', require('./oidc'));
router.use('/users', require('./users'));
router.use('/audit-log', require('./audit-log'));
router.use('/reports', require('./reports'));

168
backend/routes/oidc.js Normal file
View File

@ -0,0 +1,168 @@
const crypto = require('crypto');
const error = require('../lib/error');
const express = require('express');
const jwtdecode = require('../lib/express/jwt-decode');
const logger = require('../logger').oidc;
const oidc = require('openid-client');
const settingModel = require('../models/setting');
const internalToken = require('../internal/token');
let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
router
.route('/')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/oidc
*
* OAuth Authorization Code flow initialisation
*/
.get(jwtdecode(), async (req, res) => {
logger.info('Initializing OAuth flow');
settingModel
.query()
.where({id: 'oidc-config'})
.first()
.then((row) => getInitParams(req, row))
.then((params) => redirectToAuthorizationURL(res, params))
.catch((err) => redirectWithError(res, err));
});
router
.route('/callback')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/oidc/callback
*
* Oauth Authorization Code flow callback
*/
.get(jwtdecode(), async (req, res) => {
logger.info('Processing callback');
settingModel
.query()
.where({id: 'oidc-config'})
.first()
.then((settings) => validateCallback(req, settings))
.then((token) => redirectWithJwtToken(res, token))
.catch((err) => redirectWithError(res, err));
});
/**
* Executes discovery and returns the configured `openid-client` client
*
* @param {Setting} row
* */
let getClient = async (row) => {
let issuer;
try {
issuer = await oidc.Issuer.discover(row.meta.issuerURL);
} catch (err) {
throw new error.AuthError(`Discovery failed for the specified URL with message: ${err.message}`);
}
return new issuer.Client({
client_id: row.meta.clientID,
client_secret: row.meta.clientSecret,
redirect_uris: [row.meta.redirectURL],
response_types: ['code'],
});
};
/**
* Generates state, nonce and authorization url.
*
* @param {Request} req
* @param {Setting} row
* @return { {String}, {String}, {String} } state, nonce and url
* */
let getInitParams = async (req, row) => {
let client = await getClient(row),
state = crypto.randomUUID(),
nonce = crypto.randomUUID(),
url = client.authorizationUrl({
scope: 'openid email profile',
resource: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
state,
nonce,
});
return { state, nonce, url };
};
/**
* Parses state and nonce from cookie during the callback phase.
*
* @param {Request} req
* @return { {String}, {String} } state and nonce
* */
let parseStateFromCookie = (req) => {
let state, nonce;
let cookies = req.headers.cookie.split(';');
for (let cookie of cookies) {
if (cookie.split('=')[0].trim() === 'npm_oidc') {
let raw = cookie.split('=')[1],
val = raw.split('--');
state = val[0].trim();
nonce = val[1].trim();
break;
}
}
return { state, nonce };
};
/**
* Executes validation of callback parameters.
*
* @param {Request} req
* @param {Setting} settings
* @return {Promise} a promise resolving to a jwt token
* */
let validateCallback = async (req, settings) => {
let client = await getClient(settings);
let { state, nonce } = parseStateFromCookie(req);
const params = client.callbackParams(req);
const tokenSet = await client.callback(settings.meta.redirectURL, params, { state, nonce });
let claims = tokenSet.claims();
if (!claims.email) {
throw new error.AuthError('The Identity Provider didn\'t send the \'email\' claim');
} else {
logger.info('Successful authentication for email ' + claims.email);
}
return internalToken.getTokenFromOAuthClaim({ identity: claims.email });
};
let redirectToAuthorizationURL = (res, params) => {
logger.info('Authorization URL: ' + params.url);
res.cookie('npm_oidc', params.state + '--' + params.nonce);
res.redirect(params.url);
};
let redirectWithJwtToken = (res, token) => {
res.cookie('npm_oidc', token.token + '---' + token.expires);
res.redirect('/login');
};
let redirectWithError = (res, error) => {
logger.error('Callback error: ' + error.message);
res.cookie('npm_oidc_error', error.message);
res.redirect('/login');
};
module.exports = router;

View File

@ -71,6 +71,18 @@ router
});
})
.then((row) => {
if (row.id === 'oidc-config') {
// Redact oidc configuration via api (unauthenticated get call)
let m = row.meta;
row.meta = {
name: m.name,
enabled: m.enabled === true && !!(m.clientID && m.clientSecret && m.issuerURL && m.redirectURL && m.name)
};
// Remove these temporary cookies used during oidc authentication
res.clearCookie('npm_oidc');
res.clearCookie('npm_oidc_error');
}
res.status(200)
.send(row);
})

View File

@ -29,6 +29,8 @@ router
scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null)
})
.then((data) => {
// clear this temporary cookie following a successful oidc authentication
res.clearCookie('npm_oidc');
res.status(200)
.send(data);
})

View File

@ -14,7 +14,7 @@
"schema": {
"type": "string",
"minLength": 1,
"enum": ["default-site"]
"enum": ["default-site", "oidc-config"]
},
"required": true,
"description": "Setting ID",
@ -27,28 +27,69 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"value": {
"type": "string",
"minLength": 1,
"enum": ["congratulations", "404", "444", "redirect", "html"]
},
"meta": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"redirect": {
"type": "string"
"value": {
"type": "string",
"minLength": 1,
"enum": [
"congratulations",
"404",
"444",
"redirect",
"html"
]
},
"html": {
"type": "string"
"meta": {
"type": "object",
"additionalProperties": false,
"properties": {
"redirect": {
"type": "string"
},
"html": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"meta": {
"type": "object",
"additionalProperties": false,
"properties": {
"clientID": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"issuerURL": {
"type": "string"
},
"name": {
"type": "string"
},
"redirectURL": {
"type": "string"
}
}
}
}
}
}
]
}
}
}

View File

@ -75,30 +75,56 @@ const setupDefaultUser = () => {
* @returns {Promise}
*/
const setupDefaultSettings = () => {
return settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'default-site'})
.first()
.then((row) => {
if (!row.count) {
settingModel
.query()
.insert({
id: 'default-site',
name: 'Default Site',
description: 'What to show when Nginx is hit with an unknown Host',
value: 'congratulations',
meta: {},
})
.then(() => {
logger.info('Default settings added');
});
}
if (config.debug()) {
logger.info('Default setting setup not required');
}
});
return Promise.all([
settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'default-site'})
.first()
.then((row) => {
if (!row.count) {
settingModel
.query()
.insert({
id: 'default-site',
name: 'Default Site',
description: 'What to show when Nginx is hit with an unknown Host',
value: 'congratulations',
meta: {},
})
.then(() => {
logger.info('Added default-site setting');
});
}
if (config.debug()) {
logger.info('Default setting setup not required');
}
}),
settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'oidc-config'})
.first()
.then((row) => {
if (!row.count) {
settingModel
.query()
.insert({
id: 'oidc-config',
name: 'Open ID Connect',
description: 'Sign in to Nginx Proxy Manager with an external Identity Provider',
value: 'metadata',
meta: {},
})
.then(() => {
logger.info('Added oidc-config setting');
});
}
if (config.debug()) {
logger.info('Default setting setup not required');
}
})
]);
};
/**

File diff suppressed because it is too large Load Diff

View File

@ -144,4 +144,43 @@ Immediately after logging in with this default user you will be asked to modify
INITIAL_ADMIN_PASSWORD: mypassword1
```
## OpenID Connect - Single Sign-On (SSO)
Nginx Proxy Manager supports single sign-on (SSO) with OpenID Connect. This feature allows you to use an external OpenID Connect provider log in.
::: warning
Please note, that this feature requires a user to have an existing account to have been created via the "Users" page in the admin interface.
:::
### Provider Configuration
However, before you configure this feature, you need to have an OpenID Connect provider.
If you don't have one, you can use Authentik, which is an open-source OpenID Connect provider. Auth0 is another popular OpenID Connect provider that offers a free tier.
Each provider is a little different, so you will need to refer to the provider's documentation to get the necessary information to configure a new application.
You will need the `Client ID`, `Client Secret`, and `Issuer URL` from the provider. When you create the application in the provider, you will also need to include the `Redirect URL` in the list of allowed redirect URLs for the application.
Nginx Proxy Manager uses the `/api/oidc/callback` endpoint for the redirect URL.
The scopes requested by Nginx Proxy Manager are `openid`, `email`, and `profile` - make sure your auth provider supports these scopes.
We have confirmed that the following providers work with Nginx Proxy Manager. If you have success with another provider, make a pull request to add it to the list!
- Authentik
- Authelia
- Auth0
### Nginx Proxy Manager Configuration
To enable SSO, log into the management interface as an Administrator and navigate to the "Settings" page.
The setting to configure OpenID Connect is named "OpenID Connect Configuration".
Click the 3 dots on the far right side of the table and then click "Edit".
In the modal that appears, you will see a form with the following fields:
| Field | Description | Example Value | Notes |
|---------------|-----------------------------------------------------------|---------------------------------------------|---------------------------------------------------------------------|
| Name | The name of the OpenID Connect provider | Authentik | This will be shown on the login page (eg: "Sign in with Authentik") |
| Client ID | The client ID provided by the OpenID Connect provider | `xyz...456` | |
| Client Secret | The client secret provided by the OpenID Connect provider | `abc...123` |
| Issuer URL | The issuer URL provided by the OpenID Connect provider | `https://authentik.example.com` | This is the URL that the provider uses to identify itself |
| Redirect URL | The redirect URL to use for the OpenID Connect provider | `https://npm.example.com/api/oidc/callback` | |
After filling in the fields, click "Save" to save the settings. You can now use the "Sign in with Authentik" button on the login page to sign in with your OpenID Connect provider.

View File

@ -59,6 +59,11 @@ function fetch(verb, path, data, options) {
},
beforeSend: function (xhr) {
// Allow unauthenticated access to get the oidc configuration
if (path === 'settings/oidc-config' && verb === "get") {
return;
}
xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null));
},

View File

@ -434,6 +434,11 @@ module.exports = {
App.UI.showModalDialog(new View({model: model}));
});
}
if (model.get('id') === 'oidc-config') {
require(['./main', './settings/oidc-config/main'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
}
},

View File

@ -1,5 +1,5 @@
<div class="page-header">
<h1 class="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
<h1 class="page-title" data-cy="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
</div>
<% if (columns) { %>

View File

@ -1,7 +1,19 @@
<td>
<div><%- i18n('settings', 'default-site') %></div>
<div>
<% if (id === 'default-site') { %>
<%- i18n('settings', 'default-site') %>
<% } %>
<% if (id === 'oidc-config') { %>
<%- i18n('settings', 'oidc-config') %>
<% } %>
</div>
<div class="small text-muted">
<%- i18n('settings', 'default-site-description') %>
<% if (id === 'default-site') { %>
<%- i18n('settings', 'default-site-description') %>
<% } %>
<% if (id === 'oidc-config') { %>
<%- i18n('settings', 'oidc-config-description') %>
<% } %>
</div>
</td>
<td>
@ -9,6 +21,14 @@
<% if (id === 'default-site') { %>
<%- i18n('settings', 'default-site-' + value) %>
<% } %>
<% if (id === 'oidc-config' && meta && meta.name && meta.clientID && meta.clientSecret && meta.issuerURL && meta.redirectURL) { %>
<%- meta.name %>
<% if (!meta.enabled) { %>
(<%- i18n('str', 'disabled') %>)
<% } %>
<% } else if (id === 'oidc-config') { %>
<%- i18n('settings', 'oidc-not-configured') %>
<% } %>
</div>
</td>
<td class="text-right">

View File

@ -0,0 +1,56 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><%- i18n('settings', id) %></h5>
<button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
</div>
<div class="modal-body">
<form>
<div class="row">
<div class="col-sm-12 col-md-12">
<div class="form-group">
<div class="form-label"><%- description %></div>
<div>
<p><%- i18n('settings', 'oidc-config-hint-1') %></p>
<p><%- i18n('settings', 'oidc-config-hint-2') %></p>
</div>
<div class="custom-controls-stacked">
<div class="form-group">
<label class="form-label"><%- i18n('str', 'name') %> <span class="form-required">*</span>
<input class="form-control name-input" name="meta[name]" required type="text" value="<%- meta && typeof meta.name !== 'undefined' ? meta.name : '' %>">
</label>
</div>
<div class="form-group">
<label class="form-label">Client ID <span class="form-required">*</span>
<input class="form-control id-input" name="meta[clientID]" required type="text" value="<%- meta && typeof meta.clientID !== 'undefined' ? meta.clientID : '' %>">
</label>
</div>
<div class="form-group">
<label class="form-label">Client Secret <span class="form-required">*</span>
<input class="form-control secret-input" name="meta[clientSecret]" required type="text" value="<%- meta && typeof meta.clientSecret !== 'undefined' ? meta.clientSecret : '' %>">
</label>
</div>
<div class="form-group">
<label class="form-label">Issuer URL <span class="form-required">*</span>
<input class="form-control issuer-input" name="meta[issuerURL]" required placeholder="https://" type="url" value="<%- meta && typeof meta.issuerURL !== 'undefined' ? meta.issuerURL : '' %>">
</label>
</div>
<div class="form-group">
<label class="form-label">Redirect URL <span class="form-required">*</span>
<input class="form-control redirect-url-input" name="meta[redirectURL]" required placeholder="https://" type="url" value="<%- meta && typeof meta.redirectURL !== 'undefined' ? meta.redirectURL : document.location.origin + '/api/oidc/callback' %>">
</label>
</div>
<div class="form-group">
<div class="form-label"><%- i18n('str', 'enable') %></div>
<input class="form-check enabled-input" name="meta[enabled]" placeholder="" type="checkbox" <%- meta && (typeof meta.enabled !== 'undefined' && meta.enabled === true) || (JSON.stringify(meta) === '{}') ? 'checked="checked"' : '' %> >
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
<button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
</div>
</div>

View File

@ -0,0 +1,46 @@
const Mn = require('backbone.marionette');
const App = require('../../main');
const template = require('./main.ejs');
require('jquery-serializejson');
module.exports = Mn.View.extend({
template: template,
className: 'modal-dialog wide',
ui: {
form: 'form',
buttons: '.modal-footer button',
cancel: 'button.cancel',
save: 'button.save',
},
events: {
'click @ui.save': function (e) {
e.preventDefault();
if (!this.ui.form[0].checkValidity()) {
$('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
return;
}
let view = this;
let data = this.ui.form.serializeJSON();
data.id = this.model.get('id');
if (data.meta.enabled) {
data.meta.enabled = data.meta.enabled === 'on' || data.meta.enabled === 'true';
}
this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
App.Api.Settings.update(data)
.then((result) => {
view.model.set(result);
App.UI.closeModal();
})
.catch((err) => {
alert(err.message);
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
});
}
}
});

View File

@ -5,6 +5,7 @@
"username": "Username",
"password": "Password",
"sign-in": "Sign in",
"sign-in-with": "Sign in with",
"sign-out": "Sign out",
"try-again": "Try again",
"name": "Name",
@ -290,7 +291,12 @@
"default-site-404": "404 Page",
"default-site-444": "No Response (444)",
"default-site-html": "Custom Page",
"default-site-redirect": "Redirect"
"default-site-redirect": "Redirect",
"oidc-config": "Open ID Conncect Configuration",
"oidc-config-description": "Sign in to Nginx Proxy Manager with an external Identity Provider",
"oidc-not-configured": "Not configured",
"oidc-config-hint-1": "Provide configuration for an IdP that supports Open ID Connect Discovery.",
"oidc-config-hint-2": "The 'Redirect URL' must be set to '[base URL]/api/oidc/callback', the IdP must send the 'email' claim and a user with matching email address must exist in Nginx Proxy Manager."
}
}
}

View File

@ -5,7 +5,7 @@
<div class="card-body p-6">
<div class="container">
<div class="row">
<div class="col-sm-12 col-md-6">
<div class="col-sm-12 col-md-6 margin-auto">
<div class="text-center p-6">
<img src="/images/logo-text-vertical-grey.png" alt="Logo" />
<div class="text-center text-muted mt-5">
@ -17,15 +17,22 @@
<div class="card-title"><%- i18n('login', 'title') %></div>
<div class="form-group">
<label class="form-label"><%- i18n('str', 'email-address') %></label>
<input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" required autofocus>
<input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" data-cy="identity" required autofocus>
</div>
<div class="form-group">
<label class="form-label"><%- i18n('str', 'password') %></label>
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
<div class="invalid-feedback secret-error"></div>
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" data-cy="password" required>
<div class="invalid-feedback secret-error" data-cy="password-error"></div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
<button type="submit" class="btn btn-teal btn-block" data-cy="sign-in"><%- i18n('str', 'sign-in') %></button>
</div>
<div class="form-footer login-oidc">
<div class="separator"><slot>OR</slot></div>
<button type="button" id="login-oidc" class="btn btn-teal btn-block" data-cy="oidc-login">
<%- i18n('str', 'sign-in-with') %> <span class="oidc-provider"></span>
</button>
<div class="invalid-feedback oidc-error"></div>
</div>
</div>
</div>

View File

@ -3,17 +3,22 @@ const Mn = require('backbone.marionette');
const template = require('./login.ejs');
const Api = require('../../app/api');
const i18n = require('../../app/i18n');
const Tokens = require('../../app/tokens');
module.exports = Mn.View.extend({
template: template,
className: 'page-single',
ui: {
form: 'form',
identity: 'input[name="identity"]',
secret: 'input[name="secret"]',
error: '.secret-error',
button: 'button'
form: 'form',
identity: 'input[name="identity"]',
secret: 'input[name="secret"]',
error: '.secret-error',
button: 'button[type=submit]',
oidcLogin: 'div.login-oidc',
oidcButton: 'button#login-oidc',
oidcError: '.oidc-error',
oidcProvider: 'span.oidc-provider'
},
events: {
@ -26,10 +31,56 @@ module.exports = Mn.View.extend({
.then(() => {
window.location = '/';
})
.catch(err => {
.catch((err) => {
this.ui.error.text(err.message).show();
this.ui.button.removeClass('btn-loading').prop('disabled', false);
});
},
'click @ui.oidcButton': function() {
this.ui.identity.prop('disabled', true);
this.ui.secret.prop('disabled', true);
this.ui.button.prop('disabled', true);
this.ui.oidcButton.addClass('btn-loading').prop('disabled', true);
// redirect to initiate oauth flow
document.location.replace('/api/oidc/');
},
},
async onRender() {
// read oauth callback state cookies
let cookies = document.cookie.split(';'),
token, expiry, error;
for (cookie of cookies) {
let raw = cookie.split('='),
name = raw[0].trim(),
value = raw[1];
if (name === 'npm_oidc') {
let v = value.split('---');
token = v[0];
expiry = v[1];
}
if (name === 'npm_oidc_error') {
error = decodeURIComponent(value);
}
}
// register a newly acquired jwt token following successful oidc authentication
if (token && expiry && (new Date(Date.parse(decodeURIComponent(expiry)))) > new Date() ) {
Tokens.addToken(token);
document.location.replace('/');
}
// show error message following a failed oidc authentication
if (error) {
this.ui.oidcError.html(error);
}
// fetch oidc configuration and show alternative action button if enabled
let response = await Api.Settings.getById('oidc-config');
if (response && response.meta && response.meta.enabled === true) {
this.ui.oidcProvider.html(response.meta.name);
this.ui.oidcLogin.show();
this.ui.oidcError.show();
}
},

View File

@ -39,4 +39,34 @@ a:hover {
.col-login {
max-width: 48rem;
}
.margin-auto {
margin: auto;
}
.separator {
display: flex;
align-items: center;
text-align: center;
margin-bottom: 1em;
}
.separator::before, .separator::after {
content: "";
flex: 1 1 0%;
border-bottom: 1px solid #ccc;
}
.separator:not(:empty)::before {
margin-right: 0.5em;
}
.separator:not(:empty)::after {
margin-left: 0.5em;
}
.login-oidc {
display: none;
margin-top: 1em;
}

View File

@ -19,6 +19,51 @@ describe('Settings endpoints', () => {
});
});
it('Get oidc-config setting', function() {
cy.task('backendApiGet', {
token: token,
path: '/api/settings/oidc-config',
}).then((data) => {
cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('oidc-config');
});
});
it('OIDC settings can be updated', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: 'Some OIDC Provider',
clientID: 'clientID',
clientSecret: 'clientSecret',
issuerURL: 'https://oidc.example.com',
redirectURL: 'https://redirect.example.com/api/oidc/callback',
enabled: true,
}
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('oidc-config');
expect(data).to.have.property('meta');
expect(data.meta).to.have.property('name');
expect(data.meta.name).to.be.equal('Some OIDC Provider');
expect(data.meta).to.have.property('clientID');
expect(data.meta.clientID).to.be.equal('clientID');
expect(data.meta).to.have.property('clientSecret');
expect(data.meta.clientSecret).to.be.equal('clientSecret');
expect(data.meta).to.have.property('issuerURL');
expect(data.meta.issuerURL).to.be.equal('https://oidc.example.com');
expect(data.meta).to.have.property('redirectURL');
expect(data.meta.redirectURL).to.be.equal('https://redirect.example.com/api/oidc/callback');
expect(data.meta).to.have.property('enabled');
expect(data.meta.enabled).to.be.true;
});
});
it('Get default-site setting', function() {
cy.task('backendApiGet', {
token: token,

View File

@ -0,0 +1,185 @@
/// <reference types="cypress" />
import {TEST_USER_EMAIL, TEST_USER_NICKNAME, TEST_USER_PASSWORD} from "../../support/constants";
describe('Login', () => {
beforeEach(() => {
// Clear all cookies and local storage so we start fresh
cy.clearCookies();
cy.clearLocalStorage();
});
describe('when OIDC is not enabled', () => {
beforeEach(() => {
cy.configureOidc(false);
cy.visit('/');
})
it('should show the login form', () => {
cy.get('input[data-cy="identity"]').should('exist');
cy.get('input[data-cy="password"]').should('exist');
cy.get('button[data-cy="sign-in"]').should('exist');
});
it('should NOT show the button to sign in with an identity provider', () => {
cy.get('button[data-cy="oidc-login"]').should('not.exist');
});
describe('logging in with a username and password', () => {
// These tests are duplicated below. The difference is that OIDC is disabled here.
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
it('should log the user in when the credentials are correct', () => {
// Fill in the form with the test user's email and the correct password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 200 from the backend
cy.get('@login').its('response.statusCode').should('eq', 200);
// Expect the user to be redirected to the dashboard with a welcome message
cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
});
it('should show an error message if the password is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
});
it('should show an error message if the email is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
});
});
});
describe('when OIDC is enabled', () => {
beforeEach(() => {
cy.configureOidc(true);
cy.visit('/');
});
it('should show the login form', () => {
cy.get('input[data-cy="identity"]').should('exist');
cy.get('input[data-cy="password"]').should('exist');
cy.get('button[data-cy="sign-in"]').should('exist');
});
it('should show the button to sign in with the configured identity provider', () => {
cy.get('button[data-cy="oidc-login"]').should('exist');
cy.get('button[data-cy="oidc-login"]').should('contain.text', 'Sign in with ACME OIDC Provider');
});
describe('logging in with a username and password', () => {
// These tests are the same as the ones above, but we need to repeat them here because the OIDC configuration
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
it('should log the user in when the credentials are correct', () => {
// Fill in the form with the test user's email and the correct password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 200 from the backend
cy.get('@login').its('response.statusCode').should('eq', 200);
// Expect the user to be redirected to the dashboard with a welcome message
cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
});
it('should show an error message if the password is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
});
it('should show an error message if the email is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
});
});
describe('logging in with OIDC', () => {
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
// TODO: Create a dummy OIDC provider that we can use for testing so we can test this fully.
});
});
});

View File

@ -10,6 +10,13 @@
//
import 'cypress-wait-until';
import {
DEFAULT_ADMIN_EMAIL,
DEFAULT_ADMIN_PASSWORD,
TEST_USER_EMAIL,
TEST_USER_NAME,
TEST_USER_NICKNAME, TEST_USER_PASSWORD
} from "./constants";
Cypress.Commands.add('randomString', (length) => {
var result = '';
@ -40,13 +47,118 @@ Cypress.Commands.add('validateSwaggerSchema', (method, code, path, data) => {
}).should('equal', null);
});
/**
* Configure OIDC settings in the backend, so we can test scenarios around OIDC being enabled or disabled.
*/
Cypress.Commands.add('configureOidc', (enabled) => {
cy.getToken().then((token) => {
if (enabled) {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: 'ACME OIDC Provider',
clientID: 'clientID',
clientSecret: 'clientSecret',
// TODO: Create dummy OIDC provider for testing
issuerURL: 'https://oidc.example.com',
redirectURL: 'https://redirect.example.com/api/oidc/callback',
enabled: true,
}
},
})
} else {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: '',
clientID: '',
clientSecret: '',
issuerURL: '',
redirectURL: '',
enabled: false,
}
},
})
}
});
});
/**
* Create a new user in the backend for testing purposes.
*
* The created user will have a name, nickname, email, and password as defined in the constants file (TEST_USER_*).
*
* @param {boolean} withPassword Whether to create the user with a password or not (default: true)
*/
Cypress.Commands.add('createTestUser', (withPassword) => {
if (withPassword === undefined) {
withPassword = true;
}
cy.getToken().then((token) => {
cy.task('backendApiPost', {
token: token,
path: '/api/users',
data: {
name: TEST_USER_NAME,
nickname: TEST_USER_NICKNAME,
email: TEST_USER_EMAIL,
roles: ['admin'],
is_disabled: false,
auth: withPassword ? {
type: 'password',
secret: TEST_USER_PASSWORD
} : {}
}
})
});
});
/**
* Delete the test user from the backend.
* The test user is identified by the email address defined in the constants file (TEST_USER_EMAIL).
*
* This command will only attempt to delete the test user if it exists.
*/
Cypress.Commands.add('deleteTestUser', () => {
cy.getToken().then((token) => {
cy.task('backendApiGet', {
token: token,
path: '/api/users',
}).then((data) => {
// Find the test user
const testUser = data.find(user => user.email === TEST_USER_EMAIL);
// If the test user doesn't exist, we don't need to delete it
if (!testUser) {
return;
}
// Delete the test user
cy.task('backendApiDelete', {
token: token,
path: `/api/users/${testUser.id}`,
});
});
});
});
/**
* Get a new token from the backend.
* The token will be created using the default admin email and password defined in the constants file (DEFAULT_ADMIN_*).
*/
Cypress.Commands.add('getToken', () => {
// login with existing user
cy.task('backendApiPost', {
path: '/api/tokens',
data: {
identity: 'admin@example.com',
secret: 'changeme'
identity: DEFAULT_ADMIN_EMAIL,
secret: DEFAULT_ADMIN_PASSWORD
}
}).then(res => {
cy.wrap(res.token);

View File

@ -0,0 +1,16 @@
// Description: Constants used in the tests.
/**
* The default admin user is used to get tokens from the backend API to make requests.
* It is also used to create the test user.
*/
export const DEFAULT_ADMIN_EMAIL = Cypress.env('DEFAULT_ADMIN_EMAIL') || 'admin@example.com';
export const DEFAULT_ADMIN_PASSWORD = Cypress.env('DEFAULT_ADMIN_PASSWORD') || 'changeme';
/**
* The test user is created and deleted by the tests using `cy.createTestUser()` and `cy.deleteTestUser()`.
*/
export const TEST_USER_NAME = 'Robert Ross';
export const TEST_USER_NICKNAME = 'Bob';
export const TEST_USER_EMAIL = 'bob@ross.com';
export const TEST_USER_PASSWORD = 'changeme';