Limit sign-in to allowed email domains
All checks were successful
Build and push image / build (push) Successful in 13s
All checks were successful
Build and push image / build (push) Successful in 13s
Any Zoho account can approve the client, and an outside account got a session that returned the .env site, group and technician names. ALLOWED_EMAIL_DOMAINS is now required: the callback refuses other domains and revokes their token, and every API call re-checks it so older cookies stop working. /api/names also makes the caller's token read from SDP before returning the shared names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
817fa47edd
commit
2f137afd5f
@ -25,6 +25,7 @@ Copy `src/env.example` to a file named `.env` and fill it in. The region, timing
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Server-based Application credentials. |
|
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Server-based Application credentials. |
|
||||||
| `SESSION_SECRET` | Yes | At least 32 characters. Encrypts the sign-in cookie. Generate one with `openssl rand -hex 32`. Changing it signs everyone out. |
|
| `SESSION_SECRET` | Yes | At least 32 characters. Encrypts the sign-in cookie. Generate one with `openssl rand -hex 32`. Changing it signs everyone out. |
|
||||||
|
| `ALLOWED_EMAIL_DOMAINS` | Yes | Comma-separated email domains allowed to sign in, e.g. `mainecc.edu`. Any Zoho user can approve the client, so this is what keeps outside Zoho accounts out. Exact match: `mainecc.edu` doesn't let in `sub.mainecc.edu`. Checked at sign-in and on every call, so removing a domain signs those people out. The server won't start without it. |
|
||||||
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
|
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
|
||||||
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
|
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
|
||||||
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
||||||
@ -108,6 +109,7 @@ The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
|
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
|
||||||
|
- **Allowed domains:** any Zoho account can complete the Zoho consent screen, so the server only creates a session for emails at `ALLOWED_EMAIL_DOMAINS`, and checks again on every call. A refused sign-in has its Zoho token revoked straight away.
|
||||||
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
|
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
|
||||||
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
|
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
|
||||||
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.
|
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.
|
||||||
|
|||||||
@ -40,6 +40,8 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
|
|||||||
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
||||||
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
||||||
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
||||||
|
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 has this gap. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
||||||
|
- **2026-10-15:** **`/api/names` makes the caller prove SDP access first** (a one-row requests query with their token) before returning the shared cache, so the names never go to a token SDP refuses.
|
||||||
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
|
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
|
||||||
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.
|
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
|
|||||||
|
|
||||||
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
|
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
|
||||||
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
|
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
|
||||||
|
- [ ] **Technician check at sign-in.** Replace or back up `ALLOWED_EMAIL_DOMAINS` by asking SDP whether the user is a technician. Find the scope this needs (users/technicians read); adding it means everyone signs in again. It would also allow real pick lists for technicians.
|
||||||
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
|
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
|
||||||
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
|
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
|
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
|
||||||
CLIENT_ID=
|
CLIENT_ID=
|
||||||
CLIENT_SECRET=
|
CLIENT_SECRET=
|
||||||
|
# required: only Zoho accounts with an email at these domains can sign in (exact match, comma-separated)
|
||||||
|
# any Zoho user can approve the client, so this is what keeps outside accounts out
|
||||||
|
ALLOWED_EMAIL_DOMAINS=mainecc.edu
|
||||||
# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000
|
# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000
|
||||||
PUBLIC_URL=https://dashboard.example.com
|
PUBLIC_URL=https://dashboard.example.com
|
||||||
# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out
|
# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createJsonFile } = require('./store');
|
const { createJsonFile } = require('./store');
|
||||||
const { fetchRecentNames } = require('./tickets');
|
const { fetchRecentNames, checkRequestAccess } = require('./tickets');
|
||||||
|
|
||||||
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
|
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
|
||||||
const LOOKBACK_DAYS = 30;
|
const LOOKBACK_DAYS = 30;
|
||||||
@ -26,8 +26,11 @@ function createNameCache({ dataDir, seed }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the user's token is used to refresh; if SDP says no, the cached names are still returned
|
// The cache is only handed out once the caller's own token has read from SDP, so the names
|
||||||
|
// never reach someone SDP turns away. That token also refreshes the cache when it's due; a
|
||||||
|
// failed refresh still returns the cached names, since the caller has already proved access.
|
||||||
async function list(accessToken) {
|
async function list(accessToken) {
|
||||||
|
await checkRequestAccess(accessToken);
|
||||||
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
|
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
|
||||||
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
|
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
|
||||||
.then(found => {
|
.then(found => {
|
||||||
|
|||||||
@ -736,6 +736,7 @@ const STATUS_TEXT = {
|
|||||||
const SIGNIN_PROBLEMS = {
|
const SIGNIN_PROBLEMS = {
|
||||||
denied: 'Zoho sign-in was cancelled or refused.',
|
denied: 'Zoho sign-in was cancelled or refused.',
|
||||||
expired: 'The sign-in took too long or was started in another tab. Try again.',
|
expired: 'The sign-in took too long or was started in another tab. Try again.',
|
||||||
|
notallowed: "That Zoho account isn't allowed to use this dashboard. Sign in with your MCCS staff account.",
|
||||||
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
|
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,14 @@ function createAuthRoutes({ zoho, sessions }) {
|
|||||||
try {
|
try {
|
||||||
const tokens = await zoho.exchangeCode(String(req.query.code));
|
const tokens = await zoho.exchangeCode(String(req.query.code));
|
||||||
const user = await zoho.getUser(tokens.accessToken);
|
const user = await zoho.getUser(tokens.accessToken);
|
||||||
|
if (!sessions.isAllowed(user)) {
|
||||||
|
console.warn(`Sign-in refused, not an allowed domain: ${user.email || 'no email'} from ${req.ip}`);
|
||||||
|
// they never get a session, so drop the token Zoho just issued
|
||||||
|
await zoho.revokeToken(tokens.refreshToken).catch(err => {
|
||||||
|
console.warn('Could not revoke Zoho token:', err.message);
|
||||||
|
});
|
||||||
|
return res.redirect('/?signin=notallowed');
|
||||||
|
}
|
||||||
sessions.start(res, tokens, user);
|
sessions.start(res, tokens, user);
|
||||||
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
|
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
@ -123,7 +131,15 @@ function createApiRoutes({ sessions, weatherService, settings, timing, userSetti
|
|||||||
|
|
||||||
// suggestions for the settings panel; typing any name still works
|
// suggestions for the settings panel; typing any name still works
|
||||||
router.get('/names', signedOutOnSdp401(async (req, res) => {
|
router.get('/names', signedOutOnSdp401(async (req, res) => {
|
||||||
res.json(await nameCache.list(req.session.accessToken));
|
let names;
|
||||||
|
try {
|
||||||
|
names = await nameCache.list(req.session.accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 401) throw err;
|
||||||
|
console.warn(`Name suggestions refused for ${req.session.user?.email}:`, err.response?.status || err.message);
|
||||||
|
return res.status(403).json({ error: 'ServiceDesk Plus did not allow this request' });
|
||||||
|
}
|
||||||
|
res.json(names);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.get('/closed', signedOutOnSdp401(async (req, res) => {
|
router.get('/closed', signedOutOnSdp401(async (req, res) => {
|
||||||
|
|||||||
@ -18,17 +18,21 @@ const PORT = process.env.PORT || 3000; // default 3000
|
|||||||
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
|
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
|
||||||
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
|
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff
|
||||||
|
const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase());
|
||||||
|
|
||||||
const problems = [
|
const problems = [
|
||||||
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
|
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
|
||||||
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null
|
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null,
|
||||||
|
!allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
problems.forEach(p => console.error(p));
|
problems.forEach(p => console.error(p));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
const technicians = toList(TECHNICIAN_NAMES);
|
const technicians = toList(TECHNICIAN_NAMES);
|
||||||
const statuses = toList(STATUS_NAMES);
|
const statuses = toList(STATUS_NAMES);
|
||||||
// statuses that count as done; everything else shows up in the workload table
|
// statuses that count as done; everything else shows up in the workload table
|
||||||
@ -60,6 +64,11 @@ const sessions = createSessions({
|
|||||||
secret: SESSION_SECRET,
|
secret: SESSION_SECRET,
|
||||||
// the proxy terminates https, so go by the public address rather than the request
|
// the proxy terminates https, so go by the public address rather than the request
|
||||||
secure: PUBLIC_URL.startsWith('https://'),
|
secure: PUBLIC_URL.startsWith('https://'),
|
||||||
|
// exact domain match on the email Zoho reports; no email means no entry
|
||||||
|
isAllowed: user => {
|
||||||
|
const email = (user?.email || '').toLowerCase();
|
||||||
|
return allowedDomains.some(domain => email.endsWith('@' + domain));
|
||||||
|
},
|
||||||
sessionDays: timing.sessionDays,
|
sessionDays: timing.sessionDays,
|
||||||
zoho
|
zoho
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,7 +22,7 @@ function parseCookies(header) {
|
|||||||
// The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated
|
// The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated
|
||||||
// with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing,
|
// with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing,
|
||||||
// so restarts and new image versions don't sign anyone out.
|
// so restarts and new image versions don't sign anyone out.
|
||||||
function createSessions({ secret, secure, sessionDays, zoho }) {
|
function createSessions({ secret, secure, sessionDays, zoho, isAllowed }) {
|
||||||
const key = crypto.createHash('sha256').update(secret).digest();
|
const key = crypto.createHash('sha256').update(secret).digest();
|
||||||
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
|
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
|
||||||
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
|
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
|
||||||
@ -92,7 +92,9 @@ function createSessions({ secret, secure, sessionDays, zoho }) {
|
|||||||
// middleware: puts the signed-in session on req.session, renewing the access token if due
|
// middleware: puts the signed-in session on req.session, renewing the access token if due
|
||||||
async function requireSession(req, res, next) {
|
async function requireSession(req, res, next) {
|
||||||
const session = read(req);
|
const session = read(req);
|
||||||
if (!session) {
|
// checked on every call, not just at sign-in, so cookies issued before the
|
||||||
|
// domain rule (or before a domain is removed) stop working too
|
||||||
|
if (!session || !isAllowed(session.user)) {
|
||||||
clear(res);
|
clear(res);
|
||||||
return res.status(401).json({ error: 'Not signed in' });
|
return res.status(401).json({ error: 'Not signed in' });
|
||||||
}
|
}
|
||||||
@ -132,6 +134,7 @@ function createSessions({ secret, secure, sessionDays, zoho }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isAllowed,
|
||||||
read,
|
read,
|
||||||
start,
|
start,
|
||||||
clear,
|
clear,
|
||||||
|
|||||||
@ -344,7 +344,13 @@ async function fetchRecentNames({ accessToken, days, pages }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// one row, to prove a token can read this portal's requests before handing out cached names
|
||||||
|
async function checkRequestAccess(accessToken) {
|
||||||
|
await getRequests(accessToken, { row_count: 1, fields_required: ['display_id'] });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
checkRequestAccess,
|
||||||
fetchClosedTickets,
|
fetchClosedTickets,
|
||||||
fetchWorkload,
|
fetchWorkload,
|
||||||
fetchUnassigned,
|
fetchUnassigned,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user