Updated frontend

This commit is contained in:
2026-07-01 11:10:09 -04:00
parent ee21bdbf22
commit 5644bfa5ec
8104 changed files with 14976 additions and 386337 deletions
+162 -8
View File
@@ -1,12 +1,40 @@
'use strict';
const crypto = require('crypto');
const router = require('express').Router();
const {User} = require('../models/user');
const {Auth, AuthToken} = require('../models/auth');
const {PasswordResetToken} = require('../models/token');
const {PasswordResetToken, ImpersonationToken, OtpToken} = require('../models/token');
const {hashPasswordSSHA512} = require('../models/user_ldap');
const {UserVerification} = require('../models/verification');
const {SMS} = require('../models/sms');
const {Mail} = require('../models/email');
const middleware = require('../middleware/auth');
const rateLimit = require('../middleware/rate_limit');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
async function findUserByLogin(login) {
try {
return await User.get(login);
} catch(e) {
if (e.status === 404 || e.name === 'UserNotFound') {
return await User.get({searchKey: 'mail', searchValue: login});
}
throw e;
}
}
router.post('/login', async function(req, res, next){
router.get('/username-suggestions', async function(req, res, next) {
try {
const { givenName, sn, dob } = req.query;
if (!givenName || !sn) return res.json({ suggestions: [] });
return res.json({ suggestions: await User.usernameSuggestions(givenName, sn, dob) });
} catch(e) { next(e); }
});
router.post('/login', rateLimit.login, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
@@ -31,7 +59,7 @@ router.all('/logout', async function(req, res, next){
}
});
router.post('/resetpassword', async function(req, res, next){
router.post('/resetpassword', rateLimit.passwordReset, async function(req, res, next){
try{
let sent = await User.passwordReset(`${req.protocol}://${req.hostname}`, req.body.mail);
@@ -45,29 +73,35 @@ router.post('/resetpassword', async function(req, res, next){
}
});
router.post('/resetpassword/:token', async function(req, res, next){
router.post('/resetpassword/:token', rateLimit.passwordReset, async function(req, res, next){
try{
let token = await PasswordResetToken.get(req.params.token);
if(token.is_valid && 86400000+Number(token.created_on) > (new Date).getTime()){
let user = await User.get(token.created_by);
await user.setPassword(req.body);
token.update({is_valid: false});
await token.update({is_valid: false});
return res.json({
message: 'Password has been changed.'
});
}
let error = new Error('TokenExpired');
error.name = 'TokenExpired';
error.message = 'Password reset token is invalid or has expired.';
error.status = 401;
throw error;
}catch(error){
next(error);
}
});
router.post('/invite/:token/:mailToken', async function(req, res, next) {
router.post('/invite/:token/:mailToken', rateLimit.invite, async function(req, res, next) {
try{
req.body.token = req.params.token;
req.body.mailToken = req.params.mailToken;
let user = await User.addByInvite(req.body);
let token = await AuthToken.add(user);
let token = await AuthToken.create(user);
return res.json({
user: user.uid,
@@ -80,7 +114,7 @@ router.post('/invite/:token/:mailToken', async function(req, res, next) {
});
router.post('/invite/:token', async function(req, res, next){
router.post('/invite/:token', rateLimit.invite, async function(req, res, next){
try{
let data = {
token: req.params.token,
@@ -95,6 +129,126 @@ router.post('/invite/:token', async function(req, res, next){
}
});
router.post('/otp/request', rateLimit.otpRequest, async function(req, res, next) {
try {
const {login, method} = req.body;
if (!login || !method) {
return res.status(400).json({message: 'login and method are required'});
}
const user = await findUserByLogin(login);
if (method === 'sms' && !user.mobile) {
return res.status(400).json({message: 'No phone number on file for this account'});
}
const otp = await OtpToken.issue(user.uid, method);
if (method === 'email') {
await Mail.sendTemplate(user.mail, 'otp_code', {givenName: user.givenName, code: otp.code});
} else if (method === 'sms') {
try {
await SMS.send(user.mobile, `Your ${conf.name} login code: ${otp.code}`);
} catch (smsErr) {
const err = new Error('SMS delivery failed. Please try email or contact your administrator.');
err.status = 502;
throw err;
}
} else {
return res.status(400).json({message: 'method must be email or sms'});
}
return res.json({message: 'Code sent', method, expires_at: otp.expires_at});
} catch(error) {
next(error);
}
});
router.post('/otp/verify', rateLimit.otpVerify, async function(req, res, next) {
try {
const {login, code} = req.body;
if (!login || !code) {
return res.status(400).json({message: 'login and code are required'});
}
const user = await findUserByLogin(login);
const otp = await OtpToken.verify(user.uid, String(code));
if (!otp) {
const error = new Error('Invalid or expired code');
error.status = 401;
throw error;
}
const verif = await UserVerification.getOrCreate(user.uid);
if (otp.method === 'email') await verif.markEmailVerified();
if (otp.method === 'sms') await verif.markPhoneVerified();
const authToken = await AuthToken.create(user);
return res.json({login: true, token: authToken.token});
} catch(error) {
next(error);
}
});
router.post('/impersonate/:uid', middleware.auth, async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
for (const old of existing) {
if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
}
await old.update({ is_valid: false });
}
const tempPassword = crypto.randomBytes(16).toString('base64url');
const tempHash = hashPasswordSSHA512(tempPassword);
await target.addTempPassword(tempHash);
const token = await ImpersonationToken.add({
admin_uid: req.user.uid,
target_uid: target.uid,
temp_hash: tempHash,
});
return res.json({
uid: target.uid,
temp_password: tempPassword,
expires_at: token.expires_at,
});
} catch(error) {
next(error);
}
});
router.delete('/impersonate/:uid', middleware.auth, async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
let revoked = 0;
for (const token of existing) {
if (token.is_valid) {
try { await target.removeTempPassword(token.temp_hash); } catch(_) {}
await token.update({ is_valid: false });
revoked++;
}
}
return res.json({ message: `Impersonation ended for ${target.uid}`, revoked });
} catch(error) {
next(error);
}
});
module.exports = router;
/*
+5 -4
View File
@@ -32,6 +32,7 @@ router.post('/', async function(req, res, next){
}
});
router.get('/:name', async function(req, res, next){
try{
return res.json({
@@ -50,7 +51,7 @@ router.put('/owner/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: group.addOwner(user),
results: await group.addOwner(user),
message: `Added owner ${req.params.uid} to ${req.params.group} group.`
});
}catch(error){
@@ -66,7 +67,7 @@ router.delete('/owner/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: group.removeOwner(user),
results: await group.removeOwner(user),
message: `Removed Owner ${req.params.uid} from ${req.params.group} group.`
});
}catch(error){
@@ -82,7 +83,7 @@ router.put('/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: group.addMember(user),
results: await group.addMember(user),
message: `Added user ${req.params.uid} to ${req.params.group} group.`
});
}catch(error){
@@ -98,7 +99,7 @@ router.delete('/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: group.removeMember(user),
results: await group.removeMember(user),
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
});
}catch(error){
+76 -12
View File
@@ -1,34 +1,93 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
var express = require('express');
var router = express.Router();
const moment = require('moment');
const {marked} = require('marked');
const {InviteToken, PasswordResetToken} = require('./../models/token');
const conf = require('../conf/conf.js');
const conf = require('@simpleworkjs/conf');
const tosHtml = marked(fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8'));
const { version: buildVersion } = require('../package.json');
let buildHash = 'unknown';
try { buildHash = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); } catch(_) {}
const values ={
title: conf.environment !== 'production' ? `dev` : '',
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
name: conf.name,
buildVersion,
buildHash,
buildYear: new Date().getFullYear(),
}
// List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`)))
});
// Have express server static content( images, CSS, browser JS) from the public
// local folder.
router.use('/static', express.static(path.join(__dirname, '../public')))
router.get('/tos', function(req, res) {
res.render('tos', {...values, tosHtml});
});
router.get('/admin', function(req, res) {
res.render('admin', {...values});
});
router.get('/notifications', function(req, res) {
res.render('notifications', {...values});
});
router.get('/invites', function(req, res) {
res.render('invites', {...values});
});
router.get('/onboarding', function(req, res) {
res.render('onboarding', {...values, tosHtml});
});
router.get('/', async function(req, res, next) {
res.render('home', { title: 'Express', name: conf.name });
res.render('profile', {...values});
});
router.get('/login', function(req, res, next) {
res.render('login', {redirect: req.query.redirect, name: conf.name });
router.get('/users', async function(req, res, next) {
res.render('users', {...values});
});
router.get('/users', function(req, res, next) {
res.render('users', { title: 'Express', name: conf.name });
router.get('/login', async function(req, res, next) {
res.render('login', {...values, redirect: req.query.redirect});
});
router.get('/oauth-clients', function(req, res, next) {
res.render('oauth_clients', {...values});
});
router.get('/users/:uid', function(req, res, next) {
res.render('home', { title: 'Express', name: conf.name });
res.render('profile', {...values});
});
router.get('/groups', function(req, res, next) {
res.render('groups', { title: 'Express', name: conf.name });
res.render('groups', {...values});
});
router.get('/token', function(req, res, next) {
res.render('token', { title: 'Express', name: conf.name });
res.render('token', {...values});
});
@@ -36,7 +95,7 @@ router.get('/login/resetpassword/:token', async function(req, res, next){
let token = await PasswordResetToken.get(req.params.token);
if(token.is_valid && 86400000+Number(token.created_on) > (new Date).getTime()){
res.render('reset_password', {token:token, name: conf.name });
res.render('reset_password', {token:token, ...values });
}else{
next({message: 'token not found', status: 404});
}
@@ -48,7 +107,7 @@ router.get('/login/invite/:token/:mailToken', async function(req, res, next){
let token = await InviteToken.get(req.params.token);
if(token.is_valid && token.mail !== '__NONE__' && token.mail_token === req.params.mailToken){
token.created_on = moment(token.created_on, 'x').fromNow();
res.render('invite', { title: 'Express', invite: token, name: conf.name });
res.render('invite', {invite: token, ...values});
}else{
next({message: 'token not found', status: 404});
}
@@ -63,7 +122,7 @@ router.get('/login/invite/:token', async function(req, res, next){
token.created_on = moment(token.created_on, 'x').fromNow();
if(token.is_valid){
res.render('invite_email', { title: 'Express', invite: token, name: conf.name });
res.render('invite_email', {invite: token, ...values});
}else{
next({message: 'token not found', status: 404});
}
@@ -72,4 +131,9 @@ router.get('/login/invite/:token', async function(req, res, next){
}
});
router.get('/login/*splat', async function(req, res, next) {
res.render('login', {...values, redirect: req.query.redirect});
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
const router = require('express').Router();
const {User} = require('../models/user_ldap');
const {Group} = require('../models/group_ldap');
const {Notification} = require('../models/notification');
const {Mail} = require('../models/email');
const permission = require('../utils/permission');
async function resolveRecipients(filter_type, filter_value, active_only) {
if (filter_type === 'all' || filter_type === 'all_active') {
const users = await User.listDetail();
const active = filter_type === 'all_active' || active_only;
return active ? users.filter(u => !u.pwdAccountLockedTime) : users;
}
if (filter_type === 'group') {
const groupNames = filter_value.split(',').map(s => s.trim()).filter(Boolean);
const groups = await Promise.all(groupNames.map(name => Group.get(name).catch(() => null)));
const dnSet = new Set();
groups.filter(Boolean).forEach(group => {
[].concat(group.member || []).forEach(dn => dnSet.add(dn));
});
const uids = [...dnSet].map(dn => {
const m = dn.match(/^uid=([^,]+)/i);
return m ? m[1] : null;
}).filter(Boolean);
const users = (await Promise.all(uids.map(uid => User.get(uid).catch(() => null)))).filter(Boolean);
return active_only ? users.filter(u => !u.pwdAccountLockedTime) : users;
}
if (filter_type === 'users') {
const uids = JSON.parse(filter_value);
const users = await Promise.all(uids.map(uid => User.get(uid).catch(() => null)));
return users.filter(Boolean);
}
throw Object.assign(new Error('Invalid filter_type'), { status: 400 });
}
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const { subject, message, filter_type, filter_value = '', active_only = false } = req.body;
if (!subject || !message || !filter_type) {
return res.status(400).json({ name: 'ValidationError', message: 'subject, message, and filter_type are required' });
}
const recipients = await resolveRecipients(filter_type, filter_value, active_only);
const record = await Notification.create({
created_by: req.user.uid,
subject,
message,
filter_type,
filter_value: String(filter_value),
active_only: Boolean(active_only),
});
let sent = 0, failed = 0;
for (const user of recipients) {
if (!user.mail) { failed++; continue; }
try {
await Mail.sendTemplate(user.mail, 'notification', {
givenName: user.givenName || user.uid,
subject,
message,
});
sent++;
} catch(e) {
console.error(`Notification send failed for ${user.uid}:`, e.message);
failed++;
}
}
await record.update({ status: 'sent', sent_count: sent, failed_count: failed, sent_at: Date.now() });
return res.json({ results: record });
} catch(e) {
next(e);
}
});
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const list = await Notification.listDetail();
list.sort((a, b) => (b.created_on || 0) - (a.created_on || 0));
return res.json({ results: list });
} catch(e) {
next(e);
}
});
router.get('/:id', async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({ results: await Notification.get(req.params.id) });
} catch(e) {
next(e);
}
});
module.exports = router;
+368
View File
@@ -0,0 +1,368 @@
'use strict';
const crypto = require('crypto');
const { execSync } = require('child_process');
const jwt = require('jsonwebtoken');
const express = require('express');
const conf = require('@simpleworkjs/conf');
const { OAuthClient } = require('../models/oauth_client');
const { OAuthCode, OAuthAccessToken, OAuthRefreshToken } = require('../models/oauth_code');
const { User } = require('../models/user');
const oauthConf = conf.oauth || {};
const issuer = oauthConf.issuer || `http://localhost:${conf.port || 3000}`;
const jwtSecret = oauthConf.jwtSecret || 'change-me-in-secrets';
const { version: buildVersion } = require('../package.json');
let buildHash = 'unknown';
try { buildHash = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); } catch(_) {}
const pageLocals = {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
buildVersion,
buildHash,
buildYear: new Date().getFullYear(),
};
// --- helpers ---
function makeError(name, message, status) {
const error = new Error(name);
error.name = name;
error.message = message;
error.status = status;
return error;
}
function verifyPKCE(code_verifier, code_challenge, method) {
if (method !== 'S256') return false;
const hash = crypto.createHash('sha256').update(code_verifier).digest();
const b64 = hash.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return b64 === code_challenge;
}
function parseClientAuth(req) {
// Support Basic auth header and POST body
const auth = req.headers['authorization'];
if (auth && auth.startsWith('Basic ')) {
const [client_id, client_secret] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
return { client_id, client_secret };
}
return {
client_id: req.body.client_id,
client_secret: req.body.client_secret,
};
}
function buildIdToken(user, client, scope, now) {
const scopes = scope.split(' ');
const claims = {
iss: issuer,
sub: user.uid,
aud: client.client_id,
iat: Math.floor(now / 1000),
exp: Math.floor(now / 1000) + (client.token_lifetime.access_token || 3600),
preferred_username: user.uid,
};
if (scopes.includes('profile')) {
claims.name = `${user.givenName} ${user.sn}`;
claims.given_name = user.givenName;
claims.family_name = user.sn;
}
if (scopes.includes('email')) {
claims.email = user.mail;
}
return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' });
}
function userClaims(user, scope) {
const scopes = scope.split(' ');
const claims = { sub: user.uid };
if (scopes.includes('profile')) {
claims.name = `${user.givenName} ${user.sn}`;
claims.given_name = user.givenName;
claims.family_name = user.sn;
claims.preferred_username = user.uid;
}
if (scopes.includes('email')) {
claims.email = user.mail;
}
return claims;
}
// --- page router (mounted at /oauth) ---
const router = express.Router();
// Consent page — validates OAuth params and renders the consent UI.
// The actual code issuance happens via POST /api/oauth/authorize (authenticated).
router.get('/authorize', async function(req, res, next) {
try {
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
if (response_type !== 'code') {
return next(makeError('UnsupportedResponseType', 'Only response_type=code is supported.', 400));
}
if (!code_challenge) {
return next(makeError('PKCERequired', 'code_challenge is required.', 400));
}
if ((code_challenge_method || 'S256') !== 'S256') {
return next(makeError('UnsupportedMethod', 'Only code_challenge_method=S256 is supported.', 400));
}
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return next(makeError('InvalidClient', 'Unknown client_id.', 400));
}
if (!client.is_valid) {
return next(makeError('InvalidClient', 'Client is disabled.', 400));
}
if (!client.redirect_uris.includes(redirect_uri)) {
return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400));
}
const requestedScopes = (scope || '').split(' ').filter(Boolean);
const allowedScopes = requestedScopes.filter(s => client.scopes.includes(s));
res.render('oauth_authorize', {
...pageLocals,
oauthClient: {
client_id: client.client_id,
name: client.name,
description: client.description,
},
params: {
response_type,
client_id,
redirect_uri,
scope: allowedScopes.join(' '),
state: state || '',
code_challenge,
code_challenge_method: code_challenge_method || 'S256',
},
scopes: allowedScopes,
});
} catch(error) {
next(error);
}
});
// Token endpoint — exchanges auth codes and refresh tokens.
router.post('/token', express.urlencoded({ extended: false }), async function(req, res, next) {
try {
const { grant_type, code, redirect_uri, code_verifier, refresh_token } = req.body;
const { client_id, client_secret } = parseClientAuth(req);
// Authenticate the client
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Unknown client.' });
}
if (!client.is_valid || !(await client.verifySecret(client_secret))) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Client authentication failed.' });
}
const now = Date.now();
if (grant_type === 'authorization_code') {
let authCode;
try {
authCode = await OAuthCode.get(code);
} catch(_) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code not found.' });
}
if (!authCode.is_valid || authCode.isExpired) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code has expired or been used.' });
}
if (authCode.client_id !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code was not issued to this client.' });
}
if (authCode.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri does not match.' });
}
if (authCode.code_challenge && !verifyPKCE(code_verifier, authCode.code_challenge, authCode.code_challenge_method)) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed.' });
}
// Consume the code
await authCode.update({ is_valid: false });
const user = await User.get(authCode.username);
const accessExpires = now + (client.token_lifetime.access_token * 1000);
const refreshExpires = now + (client.token_lifetime.refresh_token * 1000);
const [accessToken, refreshToken] = await Promise.all([
OAuthAccessToken.add({ username: authCode.username, client_id, scope: authCode.scope, expires_at: accessExpires }),
OAuthRefreshToken.add({ username: authCode.username, client_id, scope: authCode.scope, expires_at: refreshExpires }),
]);
const response = {
access_token: accessToken.token,
token_type: 'Bearer',
expires_in: client.token_lifetime.access_token,
refresh_token: refreshToken.token,
};
if (authCode.scope.split(' ').includes('openid')) {
response.id_token = buildIdToken(user, client, authCode.scope, now);
}
return res.json(response);
} else if (grant_type === 'refresh_token') {
let oldRefreshToken;
try {
oldRefreshToken = await OAuthRefreshToken.get(refresh_token);
} catch(_) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token not found.' });
}
if (!oldRefreshToken.is_valid || oldRefreshToken.isExpired) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token has expired or been revoked.' });
}
if (oldRefreshToken.client_id !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token was not issued to this client.' });
}
// Rotate — invalidate old, issue new
await oldRefreshToken.update({ is_valid: false });
const user = await User.get(oldRefreshToken.username);
const accessExpires = now + (client.token_lifetime.access_token * 1000);
const refreshExpires = now + (client.token_lifetime.refresh_token * 1000);
const [accessToken, newRefreshToken] = await Promise.all([
OAuthAccessToken.add({ username: oldRefreshToken.username, client_id, scope: oldRefreshToken.scope, expires_at: accessExpires }),
OAuthRefreshToken.add({ username: oldRefreshToken.username, client_id, scope: oldRefreshToken.scope, expires_at: refreshExpires }),
]);
const response = {
access_token: accessToken.token,
token_type: 'Bearer',
expires_in: client.token_lifetime.access_token,
refresh_token: newRefreshToken.token,
};
if (oldRefreshToken.scope.split(' ').includes('openid')) {
response.id_token = buildIdToken(user, client, oldRefreshToken.scope, now);
}
return res.json(response);
} else {
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `grant_type '${grant_type}' is not supported.` });
}
} catch(error) {
next(error);
}
});
// UserInfo endpoint — accepts a Bearer access token and returns user claims.
router.get('/userinfo', async function(req, res, next) {
try {
const auth = req.headers['authorization'];
if (!auth || !auth.startsWith('Bearer ')) {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ error: 'invalid_token', error_description: 'Bearer token required.' });
}
const tokenStr = auth.slice(7);
let accessToken;
try {
accessToken = await OAuthAccessToken.get(tokenStr);
} catch(_) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error: 'invalid_token', error_description: 'Access token not found.' });
}
if (!accessToken.is_valid || accessToken.isExpired) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error: 'invalid_token', error_description: 'Access token has expired.' });
}
const user = await User.get(accessToken.username);
return res.json(userClaims(user, accessToken.scope));
} catch(error) {
next(error);
}
});
// --- authenticated API router (mounted at /api/oauth with auth middleware) ---
const authRouter = express.Router();
// Issues an authorization code after the user approves the consent form.
authRouter.post('/authorize', async function(req, res, next) {
try {
const { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_type } = req.body;
if (response_type !== 'code') {
return next(makeError('UnsupportedResponseType', 'Only response_type=code is supported.', 400));
}
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return next(makeError('InvalidClient', 'Unknown client_id.', 400));
}
if (!client.is_valid) {
return next(makeError('InvalidClient', 'Client is disabled.', 400));
}
if (!client.redirect_uris.includes(redirect_uri)) {
return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400));
}
const authCode = await OAuthCode.add({
username: req.user.uid,
client_id,
redirect_uri,
scope: scope || 'openid',
code_challenge: code_challenge || '',
code_challenge_method: code_challenge_method || 'S256',
});
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', authCode.token);
if (state) redirectUrl.searchParams.set('state', state);
return res.json({ redirect_url: redirectUrl.toString() });
} catch(error) {
next(error);
}
});
// --- discovery document (used directly in app.js) ---
function discovery(req, res) {
const base = issuer.replace(/\/$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
userinfo_endpoint: `${base}/oauth/userinfo`,
scopes_supported: ['openid', 'profile', 'email'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
});
}
module.exports = { router, authRouter, discovery };
+117
View File
@@ -0,0 +1,117 @@
'use strict';
const router = require('express').Router();
const { OAuthClient } = require('../models/oauth_client');
const permission = require('../utils/permission');
const ADMIN_GROUP = 'app_sso_oauth_admin';
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.listDetail() });
} catch(error) {
next(error);
}
});
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
req.body.created_by = req.user.uid;
// Parse redirect_uris if sent as newline-separated string from the form
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
// Parse scopes if sent as space-separated string
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
req.body.token_lifetime = {
access_token: Number(req.body['token_lifetime[access_token]']) || 3600,
refresh_token: Number(req.body['token_lifetime[refresh_token]']) || 2592000,
};
delete req.body['token_lifetime[access_token]'];
delete req.body['token_lifetime[refresh_token]'];
}
const client = await OAuthClient.add(req.body);
return res.json({
results: client,
client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.get('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.get(req.params.client_id) });
} catch(error) {
next(error);
}
});
router.put('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
return res.json({
results: await client.update(req.body),
message: `OAuth client '${client.name}' updated.`,
});
} catch(error) {
next(error);
}
});
router.delete('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
await client.remove();
return res.json({
client_id: req.params.client_id,
message: `OAuth client '${client.name}' deleted.`,
});
} catch(error) {
next(error);
}
});
router.post('/:client_id/rotate', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
const new_secret = await client.rotateSecret();
return res.json({
client_secret: new_secret,
message: `Client secret rotated for '${client.name}'. Save it — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
module.exports = router;
-2
View File
@@ -23,8 +23,6 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){
try{
console.log(tokens, req.params.name)
return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
});
+208 -36
View File
@@ -1,8 +1,11 @@
'use strict';
const router = require('express').Router();
const {User} = require('../models/user');
const {User} = require('../models/user');
const {Group} = require('../models/group_ldap');
const permission = require('../utils/permission');
const {UserVerification} = require('../models/verification');
const {InviteToken} = require('../models/token');
router.get('/', async function(req, res, next){
try{
@@ -18,10 +21,15 @@ router.get('/', async function(req, res, next){
router.post('/', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'])
req.body.created_by = req.user.uid
return res.json({results: await User.add(req.body)});
const user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
await verif.update(updates);
return res.json({results: user});
}catch(error){
next(error);
}
@@ -44,12 +52,84 @@ router.delete('/:uid', async function(req, res, next){
}
});
router.get('/me', async function(req, res, next){
try{
return res.json(await User.get({uid: req.user.uid}));
}catch(error){
next(error);
}
});
router.post('/accept-tos', async function(req, res, next){
try{
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.markTosAccepted();
User.clearCache();
return res.json({ success: true });
}catch(error){
next(error);
}
});
router.put('/password', async function(req, res, next){
try{
const result = await req.user.setPassword(req.body);
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.update({ password_must_change: false });
User.clearCache();
return res.json({results: result});
}catch(error){
next(error);
}
});
router.put('/:uid/password', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
}
const result = await user.setPassword(req.body);
if(req.params.uid.toLowerCase() !== req.user.uid.toLowerCase()){
const verif = await UserVerification.getOrCreate(user.uid);
await verif.update({ password_must_change: true });
}
return res.json({
results: result,
message: `User ${user.uid} password changed.`
});
}catch(error){
next(error);
}
});
router.put('/:uid/active', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const user = await User.get(req.params.uid);
const active = req.body.active !== false && req.body.active !== 'false';
await user.setActive(active);
return res.json({
uid: req.params.uid,
active,
message: `User ${req.params.uid} ${active ? 'activated' : 'deactivated'}`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
@@ -65,48 +145,82 @@ router.put('/:uid', async function(req, res, next){
}
});
router.get('/me', async function(req, res, next){
router.post('/invite', async function(req, res, next){
try{
return res.json(await User.get({uid: req.user.uid}));
}catch(error){
next(error);
}
});
router.put('/password', async function(req, res, next){
try{
return res.json({results: await req.user.setPassword(req.body)})
}catch(error){
next(error);
}
});
router.put('/:uid/password', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
}
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const { mail, groups = [] } = req.body;
const token = await req.user.invite({
mail,
groups,
url: `${req.protocol}://${req.hostname}`,
});
return res.json({
results: await user.setPassword(req.body),
message: `User ${user.uid} password changed.`
token: token.token,
link: `${req.protocol}://${req.hostname}/login/invite/${token.token}`,
mail_sent: !!mail,
});
}catch(error){
next(error);
}
});
router.post('/invite', async function(req, res, next){
router.get('/invite', async function(req, res, next){
try{
let token = await req.user.invite();
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
const all = await InviteToken.listDetail();
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
const results = visible.map(t => ({ token: t.token, ...t }));
return res.json({ results });
}catch(error){
next(error);
}
});
return res.json({token: token.token});
router.put('/invite/:token', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const token = await InviteToken.get(req.params.token);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
if (!isAdmin && token.created_by !== req.user.uid) {
const err = new Error('Insufficient Permission'); err.status = 401; throw err;
}
if (!token.is_valid) {
const err = new Error('Token is no longer valid'); err.status = 400; throw err;
}
const update = {};
if (req.body.groups !== undefined) {
update.groups = JSON.stringify([].concat(req.body.groups || []));
}
if (req.body.mail !== undefined && req.body.mail !== token.mail) {
if (req.body.mail) {
await User.verifyEmail({ token: token.token, mail: req.body.mail, url: `${req.protocol}://${req.hostname}` });
const refreshed = await InviteToken.get(token.token);
update.mail = refreshed.mail;
update.mail_token = refreshed.mail_token;
} else {
update.mail = '__NONE__';
update.mail_token = '__NONE__';
}
}
if (Object.keys(update).length) await token.update(update);
const updated = await InviteToken.get(req.params.token);
return res.json({ results: { token: updated.token, ...updated } });
}catch(error){
next(error);
}
});
router.delete('/invite/:token', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const token = await InviteToken.get(req.params.token);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
if (!isAdmin && token.created_by !== req.user.uid) {
const err = new Error('Insufficient Permission'); err.status = 401; throw err;
}
await token.update({ is_valid: false });
return res.json({ results: true });
}catch(error){
next(error);
}
@@ -129,6 +243,64 @@ router.post('/key', async function(req, res, next){
});
router.get('/:uid/verification', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const verif = await UserVerification.getOrCreate(req.params.uid);
return res.json({
uid: req.params.uid,
emailVerified: verif.email_verified,
emailVerifiedAt: verif.email_verified_at || null,
phoneVerified: verif.phone_verified,
phoneVerifiedAt: verif.phone_verified_at || null,
tosAccepted: verif.tos_accepted,
tosAcceptedAt: verif.tos_accepted_at || null,
});
}catch(error){
next(error);
}
});
router.get('/stats', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const [users, groups] = await Promise.all([User.listDetail(), Group.list()]);
const active = users.filter(u => !u.pwdAccountLockedTime);
const inactive = users.filter(u => u.pwdAccountLockedTime);
const recent = [...users]
.sort((a, b) => (b.createTimestamp || '').localeCompare(a.createTimestamp || ''))
.slice(0, 10)
.map(u => ({ uid: u.uid, givenName: u.givenName, sn: u.sn, mail: u.mail, createTimestamp: u.createTimestamp }));
return res.json({
totalUsers: users.length,
activeUsers: active.length,
inactiveUsers: inactive.length,
totalGroups: groups.length,
recentSignups: recent,
inactiveList: inactive.map(u => ({ uid: u.uid, givenName: u.givenName, sn: u.sn, mail: u.mail })),
});
}catch(error){
next(error);
}
});
router.get('/export', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const users = await User.listDetail();
const headers = ['uid', 'givenName', 'sn', 'mail', 'mobile', 'uidNumber', 'isActive', 'createTimestamp'];
const rows = users.map(u => headers.map(h => {
const v = u[h] || '';
return `"${String(v).replace(/"/g, '""')}"`;
}).join(','));
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');
return res.send([headers.join(','), ...rows].join('\n'));
}catch(error){
next(error);
}
});
router.get('/:uid', async function(req, res, next){
try{
return res.json({