Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ exceptions, verified by grep:
to any field starting with `=`, `+`, `-`, `@`, tab or CR so a student name or free-text answer
cannot become a live formula when the instructor opens the CSV in Excel or Sheets.

### Account enumeration

The login form asks storage whether a service ID already has an account, so it
can offer "Create" rather than a "Login" that cannot succeed
(`AuthService.isRegistered`). That answer is visible to anyone at the keyboard:
typing a service ID reveals whether it is registered for the current release.

This is accepted. The tool is an offline, single-machine classroom aid with no
accounts worth enumerating remotely, and the alternative — leaving a first-time
user staring at a disabled button — was a real usability failure. Anyone who can
type a service ID into the form can already open DevTools and read the whole
IndexedDB database, so the lookup discloses nothing that was otherwise protected.

## 9. Explicitly out of scope

- No network, no server, no accounts: nothing here authenticates a person, only a tab.
Expand Down
17 changes: 9 additions & 8 deletions src/components/qd-login.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export const loginStyles = css`
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
max-width: 480px;
/* Wide enough to keep every control on one row, including the PIN field
with its "PIN (4 digits)" placeholder. The surrounding header has
ample horizontal space; this panel was the constraint, not the page. */
max-width: 560px;
}

.title {
Expand Down Expand Up @@ -52,13 +55,11 @@ export const loginStyles = css`
}

input.pin-input {
/* Kept narrow deliberately: the header bar has only a few pixels of
slack at 1280px, and a wider field wraps the Instructor button onto a
second row. The "4 digits" rule is carried by the hint message and the
field's title/aria-label instead. */
width: 45px;
min-width: 45px;
max-width: 45px;
/* Sized to show the "PIN (4 digits)" placeholder in full, so the field
states its own rule rather than reading as an unexplained box. */
width: 88px;
min-width: 88px;
max-width: 88px;
text-align: center;
letter-spacing: 1px;
}
Expand Down
97 changes: 94 additions & 3 deletions src/components/qd-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export class QdLogin extends LitElement {
@state() private lockoutUntil = 0;
/** Whether the "PIN stored" confirmation is shown */
@state() private showPinConfirmation = false;
/**
* Whether the entered service ID already has an account.
* `null` means not looked up yet, or the lookup failed.
*/
@state() private isRegistered: boolean | null = null;
/** First PIN entry, held while the user re-types it to confirm (create flow) */
@state() private pendingPin = '';
/** Whether the help popup is open */
@state() private helpOpen = false;
/** Whether the migration dialog is shown */
Expand All @@ -76,6 +83,9 @@ export class QdLogin extends LitElement {
/** Student authentication service (storage/crypto/rate-limit logic) */
private authService = new AuthService();

/** Pending debounce for the "is this service ID known?" lookup */
private registrationTimer?: number;

static styles = loginStyles;

connectedCallback() {
Expand All @@ -88,6 +98,7 @@ export class QdLogin extends LitElement {

disconnectedCallback() {
super.disconnectedCallback();
window.clearTimeout(this.registrationTimer);
document.removeEventListener('qd:logout', this.handleLogoutEvent);
document.removeEventListener('qd:login', this.handleLoginEvent);
}
Expand Down Expand Up @@ -117,6 +128,8 @@ export class QdLogin extends LitElement {
this.lockoutUntil = 0;
this.showPinConfirmation = false;
this.helpOpen = false;
this.isRegistered = null;
this.pendingPin = '';
this.updateVisibility();
};

Expand Down Expand Up @@ -164,8 +177,7 @@ export class QdLogin extends LitElement {
type="password"
name="pin"
class="pin-input"
placeholder="PIN"
title="4-digit PIN"
placeholder=${this.pendingPin ? 'Re-enter PIN' : 'PIN (4 digits)'}
inputmode="numeric"
pattern="[0-9]*"
maxlength="4"
Expand All @@ -181,7 +193,7 @@ export class QdLogin extends LitElement {
class="login-btn"
?disabled=${this.isSubmitting || !this.isValid() || this.isLockedOut()}
>
Login
${this.submitLabel}
</button>
<qd-instructor-login ?disabled=${this.isSubmitting}></qd-instructor-login>
${this.errorMessage
Expand Down Expand Up @@ -236,6 +248,58 @@ export class QdLogin extends LitElement {
private handleServiceIdInput(e: Event) {
this.serviceId = (e.target as HTMLInputElement).value;
this.errorMessage = '';
// The identity changed, so a PIN captured for the previous one is void
this.pendingPin = '';
this.scheduleRegistrationCheck();
}

/**
* Debounced lookup of whether the entered service ID already has an account,
* used to label the submit button "Login" or "Create".
*/
private scheduleRegistrationCheck(): void {
window.clearTimeout(this.registrationTimer);
this.isRegistered = null;

const serviceId = this.serviceId.trim();
if (!/^[a-zA-Z0-9]{2,10}$/.test(serviceId)) {
return;
}

this.registrationTimer = window.setTimeout(() => {
void this.checkRegistration(serviceId);
}, 300);
}

/** Resolve {@link isRegistered} for one service ID, ignoring stale answers. */
private async checkRegistration(serviceId: string): Promise<void> {
let known: boolean | null = null;
try {
const release = readRelease();
known = release
? await this.authService.isRegistered(serviceId, release, readDbName())
: null;
} catch {
known = null;
}
// Discard a late answer for a service ID the user has since edited
if (serviceId === this.serviceId.trim()) {
this.isRegistered = known;
}
}

/**
* Label for the submit button.
*
* A first-time user sees "Create" rather than a "Login" that cannot succeed,
* and "Confirm" while re-entering their new PIN. Unknown registration falls
* back to "Login".
*/
private get submitLabel(): string {
if (this.pendingPin) {
return 'Confirm';
}
return this.isRegistered === false ? 'Create' : 'Login';
}
private handlePinInput(e: Event) {
// Filter to digits only using validation helper
Expand All @@ -261,6 +325,9 @@ export class QdLogin extends LitElement {
* untouched or valid
*/
private get validationHint(): string {
if (this.pendingPin) {
return 'Re-enter your PIN to confirm';
}
if (!this.name && !this.serviceId && !this.pin) {
return '';
}
Expand All @@ -279,6 +346,30 @@ export class QdLogin extends LitElement {
return;
}

// The debounced lookup may not have answered yet (fast typist, fast click),
// so settle it here rather than letting timing decide whether an account
// is created with or without confirmation.
if (this.isRegistered === null && !this.pendingPin) {
await this.checkRegistration(this.serviceId.trim());
}

// Creating an account takes the PIN twice, so a typo cannot silently
// become the stored PIN and lock the student out of their own record.
if (this.pendingPin) {
if (this.pin !== this.pendingPin) {
this.pendingPin = '';
this.pin = '';
this.errorMessage = 'PINs did not match. Please enter your PIN again.';
return;
}
this.pendingPin = '';
} else if (this.isRegistered === false) {
this.pendingPin = this.pin;
this.pin = '';
this.errorMessage = '';
return;
}

this.isSubmitting = true;
this.errorMessage = '';

Expand Down
32 changes: 32 additions & 0 deletions src/services/auth/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,38 @@ export class AuthService {
});
}

/**
* Whether a service ID already has an account with a usable PIN.
*
* Used by the login form to label its submit button "Login" or "Create", so
* a first-time user is told they are about to create an account rather than
* guessing why their credentials are not accepted. Accounts are keyed on
* `{release, serviceId}`, so the name is not part of this check.
*
* Never throws: any storage problem resolves to `null` ("unknown"), leaving
* the form to fall back to its neutral label rather than blocking the user.
*
* @param serviceId - Service ID being entered
* @param release - Release the account would belong to
* @param dbName - IndexedDB database name from page config
* @returns true when a PIN-bearing record exists, false when it does not,
* null when the lookup could not be completed
*/
async isRegistered(serviceId: string, release: string, dbName: string): Promise<boolean | null> {
try {
const storage = getStorageAdapter(dbName);
await storage.init();
const existing = await storage.getStudent(release, serviceId);
if (!existing) {
return false;
}
// A record awaiting PIN setup still needs the create-and-confirm flow
return hasPinSet(existing) && !needsMigration(existing);
} catch {
return null;
}
}

/**
* Shared login implementation for both the initial and post-migration paths.
*/
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/encrypted-storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { submitStudentLogin } from './helpers.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -74,7 +75,7 @@ async function loginStudent(page: Page): Promise<void> {
await loginForm.locator('input[name="name"]').fill(STUDENT.name);
await loginForm.locator('input[name="serviceId"]').fill(STUDENT.serviceId);
await loginForm.locator('input[name="pin"]').fill(STUDENT.pin);
await loginForm.locator('button[type="submit"]').click();
await submitStudentLogin(loginForm);
await closePinConfirmationDialog(page);
}

Expand Down
81 changes: 81 additions & 0 deletions tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Shared helpers for the Playwright E2E suites.
*/

import type { Locator } from '@playwright/test';

/** Milliseconds to wait for the login form to reach a settled state. */
const SETTLE_MS = 3000;

/**
* Resolve with `value` only when `promise` succeeds.
*
* A branch that rejects (its `waitFor` timed out) never settles, so it cannot
* win the race below and be mistaken for the outcome that did happen.
*/
function onSuccess<T>(promise: Promise<unknown>, value: T): Promise<T> {
return promise.then(
() => value,
() => new Promise<T>(() => {}),
);
}

/**
* Submit the student login form, completing the confirm step when the form is
* creating a new account.
*
* A service ID with no account offers "Create" and asks for the PIN a second
* time before storing it, so a typo cannot silently become the stored PIN. An
* existing account offers "Login" and needs only the single click. Tests call
* this rather than clicking submit directly, so the two-step flow lives in one
* place instead of in every spec.
*
* @param form - Locator for the `<qd-login>` element (or its wrapper)
* @param pin - PIN to repeat at the confirm step. Defaults to whatever is
* currently in the PIN field, so callers never have to keep the two in sync.
*/
export async function submitStudentLogin(form: Locator, pin?: string): Promise<void> {
// Scope every locator to the login form itself. The component also hosts
// dialogs (PIN confirmation, storage migration) whose buttons and messages
// would otherwise match and make the helper act on the wrong control.
const loginForm = form.locator('form.login-form');
const pinField = loginForm.locator('input[name="pin"]');
const typedPin = pin ?? (await pinField.inputValue());
const submit = loginForm.locator('button.login-btn');

await submit.click();

// The form resolves registration against storage before deciding whether to
// ask for the PIN again, so wait for whichever outcome actually arrives
// rather than guessing a delay.
const confirming = await Promise.race([
// Creating an account: the form is asking us to repeat the PIN
onSuccess(
loginForm
.locator('button.login-btn:has-text("Confirm")')
.waitFor({ state: 'visible', timeout: SETTLE_MS }),
true,
),
// Existing account, correct PIN: logged straight in
onSuccess(
form.page().locator('qd-status').waitFor({ state: 'visible', timeout: SETTLE_MS }),
false,
),
// Existing account, rejected PIN: the form reports it and stays put. Tests
// that deliberately fail a login rely on this returning promptly.
onSuccess(
loginForm
.locator('.error-message, .lockout-message')
.first()
.waitFor({ state: 'visible', timeout: SETTLE_MS }),
false,
),
// Anything else (a dialog took over, for instance): leave the form alone.
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), SETTLE_MS + 200)),
]);

if (confirming) {
await pinField.fill(typedPin);
await submit.click();
}
}
3 changes: 2 additions & 1 deletion tests/e2e/workflows/analysis-capture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { submitStudentLogin } from '../helpers.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -48,7 +49,7 @@ test.describe('Analysis Capture Workflow', () => {
await login.locator('input[name="serviceId"]').fill('TEST001');
await login.locator('input[name="name"]').fill('John Doe');
await login.locator('input[name="pin"]').fill('1234');
await login.locator('button[type="submit"]').click();
await submitStudentLogin(login);
await expect(page.locator('qd-status')).toBeVisible();
});

Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/workflows/answer-styling-reset.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { submitStudentLogin } from '../helpers.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -47,7 +48,7 @@ async function loginStudent(
await loginForm.locator('input[name="serviceId"]').fill(serviceId);
await loginForm.locator('input[name="name"]').fill(name);
await loginForm.locator('input[name="pin"]').fill(pin);
await loginForm.locator('button[type="submit"]').click();
await submitStudentLogin(loginForm);
await closePinConfirmationDialog(page);
await page.locator('qd-status').waitFor({ timeout: 2000 });
}
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/workflows/build-info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { submitStudentLogin } from '../helpers.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -50,7 +51,7 @@ async function loginAsStudent(page: Page): Promise<void> {
await login.locator('input[name="serviceId"]').fill('BUILD001');
await login.locator('input[name="name"]').fill('Build Test');
await login.locator('input[name="pin"]').fill('1234');
await login.locator('button[type="submit"]').click();
await submitStudentLogin(login);
await closePinConfirmationDialog(page);
await expect(page.locator('qd-status')).toBeVisible();
}
Expand Down
Loading
Loading