Compare commits

...

45 Commits

Author SHA1 Message Date
2cae60d5e4 Merge pull request #1 from chutch1122/FEAT/open-id-connect-authentication
Add Cypress tests and documentation
2024-12-11 20:27:27 +01:00
6e41d7b51e Update warning in documentation to be consistent with the rest of the page 2024-12-11 13:25:46 -06:00
529c84f0fd Add UI E2E tests for the login page for OIDC being enabled and when it is disabled 2024-12-11 13:23:31 -06:00
1ed15b3dd9 Add Cypress tests for updating the OIDC configuration 2024-12-10 17:03:40 -06:00
81aa8a4fe6 Make 'Redirect URL' match the name of the field. 2024-12-10 16:23:19 -06:00
e4b87d01f1 Add documentation for configuring SSO with OIDC 2024-12-10 16:07:36 -06:00
637b773fd6 Make the error message for when a user does not exist when attempting to login via OIDC more user-friendly. 2024-12-10 16:06:56 -06:00
eb312cc61d Remove nodemon dependency in package.json
as it is already in devDependencies
2024-10-31 21:23:45 +01:00
1a030a6ddd Enforce token auth for odic config PUT call 2024-10-30 20:35:01 +01:00
7ef52d8ed4 Update yarn.lock 2024-10-30 20:34:16 +01:00
0b126ca546 Add oidc-config to OpenAPI schema 2024-10-30 20:33:26 +01:00
7196dfa1ad Merge remote-tracking branch 'origin/develop' into FEAT/open-id-connect-authentication 2024-10-30 20:30:36 +01:00
25a26d6175 Merge pull request #4112 from prospo/develop
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
feat: Add leaseweb to certbot-dns-plugins
2024-10-30 14:40:20 +10:00
17246e418f Merge pull request #4118 from mitossoft-rd/patch-1
Remove variable usage from proxy_pass directive to fix resolution issues
2024-10-30 14:39:48 +10:00
f7d3ca0b07 Cleaning unused variable. 2024-10-28 15:18:54 +03:00
a55de386e7 Fix URL format 2024-10-28 15:15:08 +03:00
e9d4f5b827 Remove variable usage from proxy_pass directive to fix resolution issues
By using a static URL, the backend server can be accessed reliably, avoiding the common 404 errors or "no resolver defined" issues seen when variables are used.
2024-10-28 02:59:23 +03:00
1c1cee3836 feat: Add leaseweb to certbot-dns-plugins 2024-10-25 13:25:09 +00:00
eaf6335694 Merge pull request #4106 from dreik/develop
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
http2 directive migration
2024-10-25 08:53:08 +10:00
ffe05ebd41 Merge pull request #4108 from chrismaffey/patch-2
Update put.json
2024-10-25 08:06:50 +10:00
2e9a4f1aed Update put.json
Password can be left blank for updates.  Otherwise you have to reenter the password every time you save the auth list
2024-10-24 17:29:16 +13:00
d17c85e4c8 Merge pull request #4107 from chrismaffey/patch-1
Update _access.conf
2024-10-24 11:31:12 +10:00
dad8d0ca00 Update _access.conf
the pass_auth and satisfy_any properties and now boolean true/false, they do not == 1 so the switching in this template breaks
2024-10-24 14:04:17 +13:00
d7e0558a35 http2 directive
to reduce warns in logs
2024-10-24 01:30:14 +03:00
ee41bb5562 Merge pull request #4078 from Guiorgy/patch-1
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
normalize indentations in certbot-dns-plugins.json
2024-10-22 10:14:31 +10:00
0cf6b9caa4 Merge pull request #4084 from ttodua/patch-1
doc(site) - default credentials change
2024-10-22 10:14:11 +10:00
68a9baf206 minor 2024-10-18 15:35:15 +04:00
d92421d098 doc(site) - default credentials change 2024-10-18 15:33:32 +04:00
96c58b203e normalize indentations in certbot-dns-plugins.json 2024-10-17 15:34:04 +04:00
d499e2bfef Push PR and github branch builds to separate docker image
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
2024-10-17 10:00:12 +10:00
8b841176fa Fix configuration template 2024-09-19 19:39:17 +02:00
0b09f03f49 Merge remote-tracking branch 'origin/develop' into FEAT/open-id-connect-authentication 2024-09-19 18:07:45 +02:00
0f588baa3e fix: indentation 2023-03-09 21:24:12 +00:00
6ed64153e7 fix: add oidc logger and replace console logging 2023-03-06 13:01:38 +00:00
d0d36a95ec fix: add oidc-config setting via setup.js rather than migrations 2023-03-06 09:33:01 +00:00
fd49644f21 fix: linter 2023-02-26 13:34:58 +00:00
ef64edd943 fix: add database migration for oidc-config setting 2023-02-26 13:24:47 +00:00
df5ab361e3 chore: update comments, remove debug logging 2023-02-24 22:27:27 +00:00
6f98fa61e4 refactor: satisfy linter requirements 2023-02-24 21:15:17 +00:00
baee4641db chore: improve error handling 2023-02-24 18:54:38 +00:00
bc0b466a8e refactor: improve code structure 2023-02-24 16:30:45 +00:00
8350271e6f chore: add message texts 2023-02-24 15:22:45 +00:00
457d1a75ba chore: improve oidc setting ui 2023-02-24 15:17:48 +00:00
3e2a411dfb chore: add oidc setting db entry during setup 2023-02-24 15:17:23 +00:00
caeb2934f0 FEAT: Add Open ID Connect authentication method
* add `oidc-config` setting allowing an admin user to configure parameters
* modify login page to show another button when oidc is configured
* add dependency `openid-client` `v5.4.0`
* add backend route to process "OAuth2 Authorization Code" flow
  initialisation
* add backend route to process callback of above flow
* sign in the authenticated user with internal jwt token if internal
  user with email matching the one retrieved from oauth claims exists

Note: Only Open ID Connect Discovery is supported which most modern
Identity Providers offer.

Tested with Authentik 2023.2.2 and Keycloak 18.0.2
2023-02-24 15:15:17 +00:00
32 changed files with 1571 additions and 1332 deletions

10
Jenkinsfile vendored
View File

@ -43,7 +43,7 @@ pipeline {
steps {
script {
// Defaults to the Branch name, which is applies to all branches AND pr's
buildxPushTags = "-t docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}"
buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}"
}
}
}
@ -203,7 +203,13 @@ pipeline {
}
steps {
script {
npmGithubPrComment("Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/jc21/${IMAGE}) as `jc21/${IMAGE}:github-${BRANCH_LOWER}`\n\n**Note:** ensure you backup your NPM instance before testing this PR image! Especially if this PR contains database changes.", true)
npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on
[DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev)
as `nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}`
**Note:** ensure you backup your NPM instance before testing this image! Especially if there are database changes
**Note:** this is a different docker image namespace than the official image
""", true)
}
}
}

View File

@ -82,6 +82,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",

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

@ -49,8 +49,7 @@
"minLength": 1
},
"password": {
"type": "string",
"minLength": 1
"type": "string"
}
}
}

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,6 +27,8 @@
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"minProperties": 1,
@ -34,7 +36,13 @@
"value": {
"type": "string",
"minLength": 1,
"enum": ["congratulations", "404", "444", "redirect", "html"]
"enum": [
"congratulations",
"404",
"444",
"redirect",
"html"
]
},
"meta": {
"type": "object",
@ -49,6 +57,39 @@
}
}
}
},
{
"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,7 +75,8 @@ const setupDefaultUser = () => {
* @returns {Promise}
*/
const setupDefaultSettings = () => {
return settingModel
return Promise.all([
settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'default-site'})
@ -92,13 +93,38 @@ const setupDefaultSettings = () => {
meta: {},
})
.then(() => {
logger.info('Default settings added');
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');
}
})
]);
};
/**

View File

@ -4,7 +4,7 @@
auth_basic "Authorization required";
auth_basic_user_file /data/access/{{ access_list_id }};
{% if access_list.pass_auth == 0 %}
{% if access_list.pass_auth == 0 or access_list.pass_auth == true %}
proxy_set_header Authorization "";
{% endif %}
@ -17,7 +17,7 @@
deny all;
# Access checks must...
{% if access_list.satisfy_any == 1 %}
{% if access_list.satisfy_any == 1 or access_list.satisfy_any == true %}
satisfy any;
{% else %}
satisfy all;

View File

@ -5,11 +5,16 @@
#listen [::]:80;
{% endif %}
{% if certificate -%}
listen 443 ssl{% if http2_support == 1 or http2_support == true %} http2{% endif %};
listen 443 ssl;
{% if ipv6 -%}
listen [::]:443 ssl{% if http2_support == 1 or http2_support == true %} http2{% endif %};
listen [::]:443 ssl;
{% else -%}
#listen [::]:443;
{% endif %}
{% endif %}
server_name {{ domain_names | join: " " }};
{% if http2_support == 1 or http2_support == true %}
http2 on;
{% else -%}
http2 off;
{% endif %}

View File

@ -7,11 +7,7 @@
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
set $proxy_forward_scheme {{ forward_scheme }};
set $proxy_server "{{ forward_host }}";
set $proxy_port {{ forward_port }};
proxy_pass $proxy_forward_scheme://$proxy_server:$proxy_port{{ forward_path }};
proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }};
{% include "_access.conf" %}
{% include "_assets.conf" %}

File diff suppressed because it is too large Load Diff

View File

@ -137,5 +137,52 @@ Email: admin@example.com
Password: changeme
```
Immediately after logging in with this default user you will be asked to modify your details and change your password.
Immediately after logging in with this default user you will be asked to modify your details and change your password. You can change defaults with:
```
environment:
INITIAL_ADMIN_EMAIL: my@example.com
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">
<% 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,6 +3,7 @@ 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,
@ -13,7 +14,11 @@ module.exports = Mn.View.extend({
identity: 'input[name="identity"]',
secret: 'input[name="secret"]',
error: '.secret-error',
button: 'button'
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

@ -40,3 +40,33 @@ 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

@ -303,6 +303,14 @@
"credentials": "dns_joker_username = <Dynamic DNS Authentication Username>\ndns_joker_password = <Dynamic DNS Authentication Password>\ndns_joker_domain = <Dynamic DNS Domain>",
"full_plugin_name": "dns-joker"
},
"leaseweb": {
"name": "LeaseWeb",
"package_name": "certbot-dns-leaseweb",
"version": "~=1.0.1",
"dependencies": "",
"credentials": "dns_leaseweb_api_token = 01234556789",
"full_plugin_name": "dns-leaseweb"
},
"linode": {
"name": "Linode",
"package_name": "certbot-dns-linode",

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 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', 'No relevant user found');
});
});
});
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 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', 'No relevant user found');
});
});
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';