diff --git a/core.ref b/core.ref index 6b4d597..4139cc4 100644 --- a/core.ref +++ b/core.ref @@ -1 +1 @@ -a8f017b4781c4ffe28870a4e79db69434379c176 +7dfbc3d3b766fb5688903d1943d4050132ad064d diff --git a/src/i18n/de.json b/src/i18n/de.json index 4f3c137..c202d0d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -170,6 +170,18 @@ "quietNoPortal": "Das Portal ist erreichbar, sobald das VPN verbunden ist.", "killSwitchNote": "Hinweis zum Kill-Switch", "saveFailed": "Einstellung konnte nicht gespeichert werden: {{ error }}" + }, + "signin": { + "title": "Mit Firmenkonto anmelden", + "desc": "Statt mit einem Einrichtungscode: mit deinem Konto bei", + "microsoft": "Mit Microsoft anmelden", + "user": "E-Mail oder Benutzername", + "password": "Passwort", + "totp": "Bestätigungscode", + "totpHint": "6 Ziffern aus deiner Authenticator-App", + "submit": "Anmelden", + "running": "Anmeldung läuft …", + "browser": "Bitte melde dich im Browser an. Danach geht es hier von selbst weiter." } }, "vault": { diff --git a/src/i18n/en.json b/src/i18n/en.json index 7cb18b8..22f630f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -170,6 +170,18 @@ "quietNoPortal": "The portal is available once the VPN is connected.", "killSwitchNote": "Kill switch note", "saveFailed": "Setting could not be saved: {{ error }}" + }, + "signin": { + "title": "Sign in with company account", + "desc": "Instead of a setup code: with your account on", + "microsoft": "Sign in with Microsoft", + "user": "Email or username", + "password": "Password", + "totp": "Verification code", + "totpHint": "6 digits from your authenticator app", + "submit": "Sign in", + "running": "Signing in …", + "browser": "Please sign in in your browser. The app continues on its own afterwards." } }, "vault": { diff --git a/src/renderer/index.html b/src/renderer/index.html index 12d66ee..e0e2cfe 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -410,6 +410,32 @@

Server

+ + +

Konfiguration importieren

diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 7e4217c..2b373b5 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -3,7 +3,7 @@ * UI-Logik und State Management */ -const { tunnel, server, config, killSwitch, rdpAllow, autostart, logs, update, services, traffic, dns, shell, peer, permissions, onPortalUrl, portal, getVersion, getDeviceId, window: win, locale, policy: clientPolicy, notify: notifyApi, chat: chatApi, school: schoolApi, gatedrop: gatedropApi, vault: vaultApi } = window.gatecontrol; +const { tunnel, server, config, killSwitch, rdpAllow, autostart, logs, update, services, traffic, dns, shell, peer, permissions, onPortalUrl, portal, getVersion, getDeviceId, window: win, locale, policy: clientPolicy, notify: notifyApi, chat: chatApi, school: schoolApi, gatedrop: gatedropApi, vault: vaultApi, signin: signinApi } = window.gatecontrol; const { t } = window.gatecontrol.i18n; // Aktive Berechtigungen (werden beim Connect geladen) @@ -217,6 +217,9 @@ function navigateTo(page) { // Vault: footer in the status bar, secrets leave the screen with the page if (page === 'vault' && prev !== 'vault') vaultView?.show(); else if (prev === 'vault' && page !== 'vault') vaultView?.hide(); + // Company sign-in: offered for the server in "Server-URL"; a waiting browser sign-in ends with the page + if (page === 'settings' && prev !== 'settings') lookupSignin({ keep: true }); + else if (prev === 'settings' && page !== 'settings') cancelSignin(); } // Navigation aus dem Main Process @@ -609,6 +612,196 @@ function showServerStatus(message, type) { } } +// ── Settings: Company sign-in ("Mit Firmenkonto anmelden") ── +// GateControl ≥ 1.201.0: for the address in "Server-URL" the app asks the +// server which sign-in methods it offers. Older servers (404) or no answer → +// the card stays hidden. Password and Microsoft sign-in end with a setup code +// that the main process redeems through the normal setup-code path; the page +// never sees the code. +const SIGNIN_LOOKUP_DELAY_MS = 500; +let signinOffer = null; // { url, methods } +let signinLookupUrl = ''; +let signinLookupId = 0; +let signinLookupTimer = null; +let signinBusy = null; // null | 'password' | 'browser' + +function signinServerUrl() { + const url = el.serverUrl.value.trim(); + if (!url) return ''; + return /^https?:\/\//i.test(url) ? url : `https://${url}`; +} + +function signinHost(url) { + try { return new URL(url).host; } catch { return url; } +} + +/** keep: do not ask again when the address did not change (opening Settings). */ +function lookupSignin({ keep = false } = {}) { + const url = signinServerUrl(); + if (keep && url === signinLookupUrl) return; + if (signinBusy) return; + clearTimeout(signinLookupTimer); + const id = ++signinLookupId; + signinLookupUrl = url; + signinOffer = null; + $('#signin-card').hidden = true; + if (!url || !signinApi) return; + signinLookupTimer = setTimeout(async () => { + let methods = null; + try { methods = await signinApi.methods(url); } catch { methods = null; } + if (id !== signinLookupId) return; // the address changed meanwhile + if (methods && methods.available) { + signinOffer = { url, methods }; + renderSignin(); + } + }, keep ? 0 : SIGNIN_LOOKUP_DELAY_MS); +} + +function cancelSignin() { + if (signinBusy === 'browser' && signinApi) signinApi.cancel().catch(() => {}); +} + +function renderSignin() { + const card = $('#signin-card'); + if (!signinOffer || policyState.locks?.server) { + card.hidden = true; + return; + } + const { url, methods } = signinOffer; + $('#signin-host').textContent = signinHost(url); + + const ms = $('#signin-ms'); + ms.textContent = ''; + methods.microsoft.forEach((m) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sec'; + btn.dataset.id = String(m.id); + const label = document.createElement('span'); + label.dataset.i18n = 'ui.signin.microsoft'; + label.textContent = t('ui.signin.microsoft'); + btn.appendChild(label); + if (m.label) { + const sub = document.createElement('span'); + sub.className = 'muted'; + sub.textContent = `· ${m.label}`; + btn.appendChild(sub); + } + btn.addEventListener('click', () => signinMicrosoft(m.id)); + ms.appendChild(btn); + }); + ms.hidden = methods.microsoft.length === 0; + + const pw = methods.password.enabled; + $('#signin-user-field').hidden = !pw; + $('#signin-pass-field').hidden = !pw; + $('#btn-signin').hidden = !pw; + const hint = $('#signin-hint'); + hint.textContent = methods.password.hint || ''; + hint.hidden = !methods.password.hint; + $('#signin-totp-field').hidden = true; + $('#signin-totp').value = ''; + $('#signin-status').hidden = true; + card.hidden = false; + setSigninBusy(null); +} + +function updateSigninSubmit() { + const totpOn = !$('#signin-totp-field').hidden; + const ok = $('#signin-user').value.trim() && $('#signin-pass').value + && (!totpOn || /^\d{6}$/.test($('#signin-totp').value.replace(/\s/g, ''))); + $('#btn-signin').disabled = !!signinBusy || !ok; +} + +function setSigninBusy(mode) { + signinBusy = mode; + $$('#signin-ms .btn').forEach((b) => { b.disabled = !!mode; }); + ['#signin-user', '#signin-pass', '#signin-totp'].forEach((sel) => { $(sel).disabled = !!mode; }); + $('#btn-signin-cancel').hidden = mode !== 'browser'; + updateSigninSubmit(); +} + +function showSigninStatus(message, type) { + const status = $('#signin-status'); + status.hidden = false; + status.textContent = message; + status.className = `field-status ${type}`; +} + +function signinDone(result) { + if (result.success) { + const url = signinOffer.url; + el.serverUrl.value = url; + el.apiKey.value = ''; + view.serverUrl = url; + renderOverviewHeader(); + updateSideSub(); + $('#signin-pass').value = ''; + $('#signin-totp').value = ''; + $('#signin-totp-field').hidden = true; + updateSigninSubmit(); + showSigninStatus(t('server.enrolled', { peerId: result.peerId }), 'success'); + return; + } + if (result.cancelled) { + $('#signin-status').hidden = true; + return; + } + if (result.totpRequired) { + const field = $('#signin-totp-field'); + const first = field.hidden; + field.hidden = false; + $('#signin-totp').value = ''; + updateSigninSubmit(); + $('#signin-totp').focus(); + showSigninStatus(result.error, first && result.code === 'totp_required' ? 'info' : 'error'); + return; + } + showSigninStatus(result.error || t('server.signin.failedCode', { error: 'unknown' }), 'error'); +} + +async function signinPassword() { + if (signinBusy || !signinOffer || $('#btn-signin').disabled) return; + const totpOn = !$('#signin-totp-field').hidden; + showSigninStatus(t('ui.signin.running'), 'info'); + setSigninBusy('password'); + let result; + try { + result = await signinApi.password({ + url: signinOffer.url, + username: $('#signin-user').value.trim(), + password: $('#signin-pass').value, + totp: totpOn ? $('#signin-totp').value.replace(/\s/g, '') : undefined, + }); + } catch (err) { + result = { success: false, error: err.message }; + } + setSigninBusy(null); + signinDone(result); +} + +async function signinMicrosoft(id) { + if (signinBusy || !signinOffer) return; + showSigninStatus(t('ui.signin.browser'), 'info'); + setSigninBusy('browser'); + let result; + try { + result = await signinApi.microsoft({ url: signinOffer.url, id }); + } catch (err) { + result = { success: false, error: err.message }; + } + setSigninBusy(null); + signinDone(result); +} + +el.serverUrl.addEventListener('input', () => lookupSignin()); +$('#btn-signin').addEventListener('click', signinPassword); +$('#btn-signin-cancel').addEventListener('click', cancelSignin); +['#signin-user', '#signin-pass', '#signin-totp'].forEach((sel) => { + $(sel).addEventListener('input', updateSigninSubmit); + $(sel).addEventListener('keydown', (e) => { if (e.key === 'Enter') signinPassword(); }); +}); + // ── Settings: Config Import ────────────────────────────── $('#btn-import-file').addEventListener('click', async () => { const result = await config.importFile(); @@ -1584,6 +1777,8 @@ function applyPolicyUi() { const importCard = $('#btn-import-file')?.closest('section'); if (serverCard) serverCard.hidden = !!l.server; if (importCard) importCard.hidden = !!l.server; + if (l.server) cancelSignin(); + $('#signin-card').hidden = !!l.server || !signinOffer; const serverHint = $('#policy-server-hint'); if (serverHint) { serverHint.hidden = !l.server; diff --git a/test/e2e/signin.spec.js b/test/e2e/signin.spec.js new file mode 100644 index 0000000..877b572 --- /dev/null +++ b/test/e2e/signin.spec.js @@ -0,0 +1,166 @@ +'use strict'; + +// "Mit Firmenkonto anmelden" in Settings → Connection (core signin:*, +// GateControl ≥ 1.201.0): the card appears only when the server in +// "Server-URL" offers it; password (+ 2FA) and Microsoft (browser + loopback + +// handoff with PKCE) both end with a setup code that the app redeems at +// /api/v1/client/enroll like a typed code. + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { test, expect } = require('./support/fixtures'); +const { openSettingsTab } = require('./support/ui'); + +const SECRET = 'Sommer-2026!'; + +function get(url) { + return new Promise((resolve, reject) => { + http.get(url, { agent: false }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (c) => { body += c; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }).on('error', reject); + }); +} + +function enrollCodes(mock) { + return mock.requests.filter((r) => r.path === '/api/v1/client/enroll').map((r) => JSON.parse(r.raw.toString('utf8')).code); +} + +/** + * None of the files the app writes itself (its logs, the e2e events, the + * stores in userData, its console output) contains the secrets. Chromium's + * own profile files stay out: Windows keeps them locked while the app runs. + */ +function assertNotWritten(dir, secrets) { + const files = []; + const walk = (d) => { + if (!fs.existsSync(d)) return; + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.isFile()) files.push(p); + } + }; + walk(path.join(dir, 'logs')); + walk(path.join(dir, 'userData', 'logs')); // electron-log (main.log) + for (const name of ['events.jsonl', 'main-process.log']) { + if (fs.existsSync(path.join(dir, name))) files.push(path.join(dir, name)); + } + const userData = path.join(dir, 'userData'); + if (fs.existsSync(userData)) { + for (const e of fs.readdirSync(userData, { withFileTypes: true })) { + if (e.isFile() && e.name.endsWith('.json')) files.push(path.join(userData, e.name)); + } + } + expect(files.some((f) => f.endsWith('.log') && fs.readFileSync(f, 'utf8').includes('Company sign-in')), 'the app log was checked').toBe(true); + for (const file of files) { + const text = fs.readFileSync(file).toString('latin1'); + for (const s of secrets) expect(text.includes(s), `${s} in ${file}`).toBe(false); + } +} + +async function typeServer(page, mock) { + await openSettingsTab(page, 'conn'); + await page.locator('#server-url').fill(mock.url); +} + +test.describe('company sign-in', () => { + test('older server (404): no sign-in card', async ({ launchApp, mock }) => { + const { page } = await launchApp(); + await typeServer(page, mock); + await expect.poll(() => mock.count('/api/v1/client/signin')).toBeGreaterThanOrEqual(1); + await page.waitForTimeout(500); + await expect(page.locator('#signin-card')).toBeHidden(); + }); + + test('password with 2FA sets the device up through the setup-code path', async ({ launchApp, mock }) => { + const signin = mock.setSignin({ totp: '246810' }); + const { page, dir } = await launchApp(); + await typeServer(page, mock); + + await expect(page.locator('#signin-card')).toBeVisible(); + await expect(page.locator('#signin-hint')).toHaveText('Firmenkonto (corp.local): mit dem Windows-Passwort anmelden'); + await expect(page.locator('#signin-ms .btn')).toHaveCount(1); + await expect(page.locator('#signin-ms .btn')).toContainText('Contoso'); + + await page.locator('#signin-user').fill('anna@corp.local'); + await page.locator('#signin-pass').fill('falsch'); + await page.locator('#btn-signin').click(); + await expect(page.locator('#signin-status')).toHaveClass(/error/); + + await page.locator('#signin-pass').fill(SECRET); + await page.locator('#btn-signin').click(); + await expect(page.locator('#signin-totp-field')).toBeVisible(); + await expect(page.locator('#btn-signin')).toBeDisabled(); + await page.locator('#signin-totp').fill('246810'); + await page.locator('#signin-totp').press('Enter'); + + await expect(page.locator('#signin-status')).toHaveClass(/success/); + expect(signin.issued).toHaveLength(1); + expect(enrollCodes(mock)).toEqual(signin.issued); + expect(signin.attempts.map((a) => a.totp)).toEqual([null, null, '246810']); + expect(await page.locator('#signin-pass').inputValue()).toBe(''); + // The same as after "Save & register": token stored for the server. + expect(mock.count('/api/v1/client/register')).toBe(0); + assertNotWritten(dir, [SECRET, '246810', signin.issued[0]]); + }); + + test('Mit Microsoft anmelden: default browser, loopback answer, handoff', async ({ launchApp, mock }) => { + const signin = mock.setSignin({ password: { enabled: false, hint: null }, microsoft: [{ id: 7, label: 'Contoso' }, { id: 9, label: 'Fabrikam' }] }); + const { page, events, dir } = await launchApp(); + await typeServer(page, mock); + await expect(page.locator('#signin-card')).toBeVisible(); + await expect(page.locator('#signin-user-field')).toBeHidden(); + await expect(page.locator('#btn-signin')).toBeHidden(); + await expect(page.locator('#signin-ms .btn')).toHaveCount(2); + + await page.locator('#signin-ms .btn').nth(1).click(); + await expect(page.locator('#btn-signin-cancel')).toBeVisible(); + const opened = () => events().filter((e) => e.type === 'shell.openExternal').map((e) => e.url); + await expect.poll(() => opened().length).toBe(1); + const start = new URL(opened()[0]); + expect(`${start.origin}${start.pathname}`).toBe(`${mock.url}/auth/oidc/9/start`); + const q = Object.fromEntries(start.searchParams); + expect(q.client).toBe('app'); + expect(q.redirect_uri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/signin$/); + + const handoff = signin.issueHandoff(q.challenge); + const back = new URL(q.redirect_uri); + back.searchParams.set('handoff', handoff); + back.searchParams.set('state', q.state); + const res = await get(back.toString()); + expect(res.status).toBe(200); + expect(res.body).toContain('Anmeldung abgeschlossen – du kannst dieses Fenster schließen.'); + + await expect(page.locator('#signin-status')).toHaveClass(/success/); + expect(enrollCodes(mock)).toEqual(signin.issued); + await expect(get(back.toString())).rejects.toThrow(); + assertNotWritten(dir, [handoff, signin.issued[0]]); + }); + + test('Microsoft: a foreign state is refused, cancel ends the wait', async ({ launchApp, mock }) => { + mock.setSignin(); + const { page, events } = await launchApp(); + await typeServer(page, mock); + await expect(page.locator('#signin-card')).toBeVisible(); + + const opened = () => events().filter((e) => e.type === 'shell.openExternal').map((e) => e.url); + await page.locator('#signin-ms .btn').first().click(); + await expect.poll(() => opened().length).toBe(1); + const q = Object.fromEntries(new URL(opened()[0]).searchParams); + expect((await get(`${q.redirect_uri}?handoff=abc&state=not-the-state`)).status).toBe(400); + await expect(page.locator('#signin-status')).toHaveClass(/error/); + expect(mock.count('/api/v1/client/signin/handoff')).toBe(0); + + await page.locator('#signin-ms .btn').first().click(); + await expect.poll(() => opened().length).toBe(2); + await page.locator('#btn-signin-cancel').click(); + await expect(page.locator('#btn-signin-cancel')).toBeHidden(); + await expect(page.locator('#signin-status')).toBeHidden(); + const q2 = Object.fromEntries(new URL(opened()[1]).searchParams); + await expect(get(`${q2.redirect_uri}?handoff=abc&state=${q2.state}`)).rejects.toThrow(); + }); +}); diff --git a/test/e2e/support/mock-server.js b/test/e2e/support/mock-server.js index 5636cc5..b69371e 100644 --- a/test/e2e/support/mock-server.js +++ b/test/e2e/support/mock-server.js @@ -31,6 +31,11 @@ * /nominatim/search, /nominatim/reverse canned addresses (`setGeocode()`) * — the app's MapService points there in e2e mode (GC_E2E_MAP_URL). * + * Company sign-in: without `server.setSignin()` /api/v1/client/signin* + * answers 404 like a server before GateControl 1.201.0. With it, + * support/signin-mock.js answers (methods, password with optional 2FA, + * handoff with PKCE check). + * * The update offer is configurable per test (`server.setUpdate(...)`), all * requests are recorded (`server.requests`). */ @@ -41,6 +46,7 @@ const selfsigned = require('selfsigned'); const { createMessenger } = require('./messenger-mock'); const { createSchool } = require('./school-mock'); const { createVault } = require('./vault-mock'); +const { createSignin } = require('./signin-mock'); const API_TOKEN = 'gc_e2e_test_token'; // 1 × 1 PNG, served for every map tile @@ -104,6 +110,7 @@ async function startMockServer() { let messenger = null; // support/messenger-mock.js, null = no Messenger plugin let school = null; // support/school-mock.js, null = no Schule plugin let vault = null; // support/vault-mock.js, null = no Vault plugin + let signin = null; // support/signin-mock.js, null = server before company sign-in (404) const acks = []; const prefs = []; let geocode = { @@ -217,6 +224,12 @@ async function startMockServer() { return json(404, { ok: false, error: 'not_found' }); } + // Company sign-in: public like enroll, 404 on servers without it. + if (route === 'signin' || route.startsWith('signin/')) { + if (signin && signin.handle({ method: req.method, route, raw }, json)) return undefined; + return json(404, { ok: false, error: 'not_found' }); + } + if (route === 'enroll') { return json(200, { ok: true, token: API_TOKEN, peerId: PEER_ID, config: null }); } @@ -336,6 +349,11 @@ async function startMockServer() { /** Canned Nominatim answers: { search: [results], reverse: result }. */ setGeocode(g) { geocode = { ...geocode, ...g }; }, pushPrefs: () => prefs.slice(), + /** Enables company sign-in (support/signin-mock.js); returns its state (issued codes, issueHandoff). */ + setSignin(opts = {}) { + signin = createSignin(opts); + return signin; + }, count(pathname) { return requests.filter((r) => r.path === pathname).length; }, close: () => new Promise((resolve) => { for (const res of streams) res.end(); diff --git a/test/e2e/support/signin-mock.js b/test/e2e/support/signin-mock.js new file mode 100644 index 0000000..ad9468a --- /dev/null +++ b/test/e2e/support/signin-mock.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Company sign-in of the mock server ("Mit Firmenkonto anmelden", + * GateControl ≥ 1.201.0): + * + * GET /api/v1/client/signin → offered methods + * POST /api/v1/client/signin/password → { username, password, totp? } → { ok, code } + * POST /api/v1/client/signin/handoff → { handoff, verifier } → { ok, code } + * + * The browser part of "Mit Microsoft anmelden" is played by the test: + * issueHandoff(challenge) stands for the server after a Microsoft sign-in, + * the test then calls the app's loopback redirect URI itself. Every issued + * setup code is listed in `issued` (the app redeems it at /enroll). + */ + +const crypto = require('crypto'); + +function createSignin({ + password = { enabled: true, hint: 'Firmenkonto (corp.local): mit dem Windows-Passwort anmelden' }, + microsoft = [{ id: 7, label: 'Contoso' }], + user = 'anna@corp.local', + secret = 'Sommer-2026!', + totp = null, +} = {}) { + const issued = []; + const handoffs = new Map(); // handoff → challenge + const attempts = []; + + const newCode = () => { + const hex = crypto.randomBytes(8).toString('hex').toUpperCase(); + const code = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`; + issued.push(code); + return { ok: true, code, expiresAt: new Date(Date.now() + 600000).toISOString() }; + }; + + function handle({ method, route, raw }, json) { + if (route === 'signin' && method === 'GET') { + json(200, { ok: true, version: 1, password, microsoft }); + return true; + } + const fail = (status, error) => { json(status, { ok: false, error }); return true; }; + let body = {}; + try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { /* empty */ } + if (route === 'signin/password' && method === 'POST') { + attempts.push({ username: body.username, totp: body.totp || null, hostname: body.hostname, platform: body.platform }); + if (body.username !== user || body.password !== secret) return fail(401, 'invalid_credentials'); + if (totp && !body.totp) return fail(401, 'totp_required'); + if (totp && body.totp !== totp) return fail(401, 'totp_invalid'); + json(200, newCode()); + return true; + } + if (route === 'signin/handoff' && method === 'POST') { + const challenge = handoffs.get(body.handoff); + handoffs.delete(body.handoff); + const sent = typeof body.verifier === 'string' ? crypto.createHash('sha256').update(body.verifier).digest('base64url') : null; + if (!challenge || sent !== challenge) return fail(400, 'invalid_or_expired'); + json(200, newCode()); + return true; + } + return false; + } + + return { + handle, + issued, + attempts, + /** One-time handoff bound to the challenge (what the server redirects the browser with). */ + issueHandoff(challenge) { + const handoff = crypto.randomBytes(24).toString('base64url'); + handoffs.set(handoff, challenge); + return handoff; + }, + }; +} + +module.exports = { createSignin };