Skip to content

Commit 16e2a88

Browse files
committed
Added password change and recovery
1 parent 8d1f6cb commit 16e2a88

8 files changed

Lines changed: 191 additions & 18 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ application {
2424
}
2525

2626
dependencies {
27-
implementation("com.codeheadsystems:hofmann-dropwizard:1.3.0")
27+
implementation("com.codeheadsystems:hofmann-dropwizard:1.3.1")
2828
implementation("io.dropwizard:dropwizard-core:5.0.1")
2929
implementation("io.dropwizard:dropwizard-auth:5.0.1")
3030
implementation("io.dropwizard:dropwizard-assets:5.0.1")

frontend/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "tsc --noEmit && vite build"
99
},
1010
"dependencies": {
11-
"@codeheadsystems/hofmann-typescript": "latest"
11+
"@codeheadsystems/hofmann-typescript": "1.3.1"
1212
},
1313
"devDependencies": {
1414
"typescript": "^5.4.0",

frontend/src/auth.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,43 @@ type OnLoginSuccess = (token: string, username: string) => void;
55
export function initAuthView(onLoginSuccess: OnLoginSuccess): void {
66
const tabLogin = document.getElementById('tab-login')! as HTMLButtonElement;
77
const tabRegister = document.getElementById('tab-register')! as HTMLButtonElement;
8+
const tabRecover = document.getElementById('tab-recover')! as HTMLButtonElement;
89
const form = document.getElementById('auth-form')! as HTMLFormElement;
10+
const recoveryForm = document.getElementById('recovery-form')! as HTMLFormElement;
911
const submitBtn = document.getElementById('auth-submit')! as HTMLButtonElement;
1012
const messageEl = document.getElementById('auth-message')!;
1113

1214
let mode: 'login' | 'register' = 'login';
1315

16+
function setActiveTab(tab: HTMLButtonElement) {
17+
tabLogin.classList.remove('active');
18+
tabRegister.classList.remove('active');
19+
tabRecover.classList.remove('active');
20+
tab.classList.add('active');
21+
clearMessage();
22+
}
23+
1424
tabLogin.addEventListener('click', () => {
1525
mode = 'login';
16-
tabLogin.classList.add('active');
17-
tabRegister.classList.remove('active');
26+
setActiveTab(tabLogin);
27+
form.classList.remove('hidden');
28+
recoveryForm.classList.add('hidden');
1829
submitBtn.textContent = 'Log In';
19-
clearMessage();
2030
});
2131

2232
tabRegister.addEventListener('click', () => {
2333
mode = 'register';
24-
tabRegister.classList.add('active');
25-
tabLogin.classList.remove('active');
34+
setActiveTab(tabRegister);
35+
form.classList.remove('hidden');
36+
recoveryForm.classList.add('hidden');
2637
submitBtn.textContent = 'Register';
27-
clearMessage();
38+
});
39+
40+
tabRecover.addEventListener('click', () => {
41+
setActiveTab(tabRecover);
42+
form.classList.add('hidden');
43+
recoveryForm.classList.remove('hidden');
44+
resetRecoveryForm();
2845
});
2946

3047
form.addEventListener('submit', async (e) => {
@@ -38,8 +55,6 @@ export function initAuthView(onLoginSuccess: OnLoginSuccess): void {
3855
submitBtn.textContent = mode === 'login' ? 'Logging in...' : 'Registering...';
3956

4057
try {
41-
// OpaqueHttpClient.create() fetches /api/opaque/config and configures
42-
// the cipher suite and KSF parameters automatically.
4358
const client = await OpaqueHttpClient.create('/api');
4459

4560
if (mode === 'register') {
@@ -58,6 +73,57 @@ export function initAuthView(onLoginSuccess: OnLoginSuccess): void {
5873
}
5974
});
6075

76+
// ── Recovery flow ──────────────────────────────────────────────────────────
77+
78+
let recoveryStep: 'start' | 'verify' = 'start';
79+
80+
function resetRecoveryForm() {
81+
recoveryStep = 'start';
82+
const codeField = document.getElementById('recovery-code-field')!;
83+
const pwField = document.getElementById('recovery-password-field')!;
84+
const submitBtn = document.getElementById('recovery-submit')! as HTMLButtonElement;
85+
codeField.classList.add('hidden');
86+
pwField.classList.add('hidden');
87+
submitBtn.textContent = 'Send Recovery Code';
88+
(document.getElementById('recovery-username') as HTMLInputElement).value = '';
89+
(document.getElementById('recovery-code') as HTMLInputElement).value = '';
90+
(document.getElementById('recovery-password') as HTMLInputElement).value = '';
91+
}
92+
93+
recoveryForm.addEventListener('submit', async (e) => {
94+
e.preventDefault();
95+
clearMessage();
96+
97+
const username = (document.getElementById('recovery-username') as HTMLInputElement).value.trim();
98+
const recoverSubmitBtn = document.getElementById('recovery-submit')! as HTMLButtonElement;
99+
recoverSubmitBtn.disabled = true;
100+
101+
try {
102+
const client = await OpaqueHttpClient.create('/api');
103+
104+
if (recoveryStep === 'start') {
105+
await client.recoveryStart(username);
106+
showMessage('Recovery code sent! Check the server console log for the code.', 'success');
107+
recoveryStep = 'verify';
108+
document.getElementById('recovery-code-field')!.classList.remove('hidden');
109+
document.getElementById('recovery-password-field')!.classList.remove('hidden');
110+
recoverSubmitBtn.textContent = 'Verify & Set New Password';
111+
(document.getElementById('recovery-username') as HTMLInputElement).readOnly = true;
112+
} else {
113+
const code = (document.getElementById('recovery-code') as HTMLInputElement).value.trim();
114+
const newPassword = (document.getElementById('recovery-password') as HTMLInputElement).value;
115+
await client.recoverAndReRegister(username, code, newPassword);
116+
showMessage('Account recovered! You can now log in with your new password.', 'success');
117+
resetRecoveryForm();
118+
tabLogin.click();
119+
}
120+
} catch (err: unknown) {
121+
showMessage(err instanceof Error ? err.message : String(err), 'error');
122+
} finally {
123+
recoverSubmitBtn.disabled = false;
124+
}
125+
});
126+
61127
function showMessage(msg: string, type: 'error' | 'success'): void {
62128
messageEl.textContent = msg;
63129
messageEl.className = `message ${type}`;

frontend/src/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ <h1>Notes</h1>
319319
<div class="tabs">
320320
<button id="tab-login" class="tab active">Log In</button>
321321
<button id="tab-register" class="tab">Register</button>
322+
<button id="tab-recover" class="tab">Recover</button>
322323
</div>
323324
<div id="auth-message"></div>
324325
<form id="auth-form">
@@ -332,6 +333,22 @@ <h1>Notes</h1>
332333
</div>
333334
<button type="submit" id="auth-submit" class="btn-primary">Log In</button>
334335
</form>
336+
<!-- Recovery form (hidden by default) -->
337+
<form id="recovery-form" class="hidden">
338+
<div class="field">
339+
<label for="recovery-username">Username</label>
340+
<input type="text" id="recovery-username" placeholder="you@example.com" required />
341+
</div>
342+
<div id="recovery-code-field" class="field hidden">
343+
<label for="recovery-code">Recovery Code</label>
344+
<input type="text" id="recovery-code" placeholder="6-digit code from server log" />
345+
</div>
346+
<div id="recovery-password-field" class="field hidden">
347+
<label for="recovery-password">New Password</label>
348+
<input type="password" id="recovery-password" />
349+
</div>
350+
<button type="submit" id="recovery-submit" class="btn-primary">Send Recovery Code</button>
351+
</form>
335352
</div>
336353
</div>
337354

@@ -345,6 +362,14 @@ <h1>Notes</h1>
345362
</div>
346363
</header>
347364
<main>
365+
<section class="create-section">
366+
<div class="section-label">Change Password</div>
367+
<form id="change-password-form">
368+
<input type="password" id="new-password" placeholder="New password" required style="margin-bottom: 10px" />
369+
<div id="change-password-message" class="message hidden"></div>
370+
<button type="submit" class="btn-primary">Change Password</button>
371+
</form>
372+
</section>
348373
<section class="create-section">
349374
<div class="section-label">New Note</div>
350375
<form id="create-form">

frontend/src/notes.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { OpaqueHttpClient } from '@codeheadsystems/hofmann-typescript';
2+
13
interface NoteResponse {
24
id: string;
35
title: string;
@@ -6,6 +8,8 @@ interface NoteResponse {
68
}
79

810
export function initNotesView(token: string, username: string, onLogout: () => void): void {
11+
let currentToken = token;
12+
913
const loggedInUser = document.getElementById('logged-in-user')!;
1014
const logoutBtn = document.getElementById('logout-btn')! as HTMLButtonElement;
1115
const createForm = document.getElementById('create-form')! as HTMLFormElement;
@@ -20,6 +24,39 @@ export function initNotesView(token: string, username: string, onLogout: () => v
2024
onLogout();
2125
});
2226

27+
// ── Change Password ────────────────────────────────────────────────────────
28+
29+
const changePasswordForm = document.getElementById('change-password-form')! as HTMLFormElement;
30+
const changePasswordMsg = document.getElementById('change-password-message')!;
31+
32+
changePasswordForm.addEventListener('submit', async (e) => {
33+
e.preventDefault();
34+
changePasswordMsg.classList.add('hidden');
35+
36+
const newPasswordInput = document.getElementById('new-password') as HTMLInputElement;
37+
const newPassword = newPasswordInput.value;
38+
const submitBtn = changePasswordForm.querySelector('button[type="submit"]') as HTMLButtonElement;
39+
submitBtn.disabled = true;
40+
submitBtn.textContent = 'Changing...';
41+
42+
try {
43+
const client = await OpaqueHttpClient.create('/api');
44+
await client.changePassword(username, newPassword, currentToken);
45+
// Re-authenticate with the new password to get a fresh token
46+
const newToken = await client.authenticate(username, newPassword);
47+
currentToken = newToken;
48+
newPasswordInput.value = '';
49+
changePasswordMsg.textContent = 'Password changed successfully!';
50+
changePasswordMsg.className = 'message success';
51+
} catch (err) {
52+
changePasswordMsg.textContent = err instanceof Error ? err.message : String(err);
53+
changePasswordMsg.className = 'message error';
54+
} finally {
55+
submitBtn.disabled = false;
56+
submitBtn.textContent = 'Change Password';
57+
}
58+
});
59+
2360
createForm.addEventListener('submit', async (e) => {
2461
e.preventDefault();
2562
createError.classList.add('hidden');
@@ -50,7 +87,7 @@ export function initNotesView(token: string, username: string, onLogout: () => v
5087

5188
async function apiGet<T>(path: string): Promise<T> {
5289
const r = await fetch(path, {
53-
headers: { 'Authorization': `Bearer ${token}` },
90+
headers: { 'Authorization': `Bearer ${currentToken}` },
5491
});
5592
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
5693
return r.json() as Promise<T>;
@@ -61,7 +98,7 @@ export function initNotesView(token: string, username: string, onLogout: () => v
6198
method: 'POST',
6299
headers: {
63100
'Content-Type': 'application/json',
64-
'Authorization': `Bearer ${token}`,
101+
'Authorization': `Bearer ${currentToken}`,
65102
},
66103
body: JSON.stringify(body),
67104
});
@@ -72,7 +109,7 @@ export function initNotesView(token: string, username: string, onLogout: () => v
72109
async function apiDelete(path: string): Promise<void> {
73110
const r = await fetch(path, {
74111
method: 'DELETE',
75-
headers: { 'Authorization': `Bearer ${token}` },
112+
headers: { 'Authorization': `Bearer ${currentToken}` },
76113
});
77114
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
78115
}

src/main/java/com/codeheadsystems/hofmann/example/ExampleApplication.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.codeheadsystems.hofmann.example.dagger.AppComponent;
55
import com.codeheadsystems.hofmann.example.dagger.AppModule;
66
import com.codeheadsystems.hofmann.example.dagger.DaggerAppComponent;
7+
import com.codeheadsystems.hofmann.example.recovery.DemoRecoveryChallenger;
78
import com.codeheadsystems.hofmann.example.store.MutableCredentialStoreHolder;
89
import com.codeheadsystems.hofmann.example.store.SqlCredentialStore;
910
import com.codeheadsystems.hofmann.server.store.InMemorySessionStore;
@@ -37,7 +38,8 @@ public void initialize(Bootstrap<ExampleConfiguration> bootstrap) {
3738
bootstrap.addBundle(new AssetsBundle("/frontend/", "/", "index.html", "frontend"));
3839
// Use the credential holder so the bundle wires up before the database is available,
3940
// but all actual credential access happens during request processing (after run()).
40-
bootstrap.addBundle(new HofmannBundle<>(credentialStoreHolder, new InMemorySessionStore(), null));
41+
bootstrap.addBundle(new HofmannBundle<>(credentialStoreHolder, new InMemorySessionStore(), null)
42+
.withRecovery(new DemoRecoveryChallenger()));
4143
}
4244

4345
@Override
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.codeheadsystems.hofmann.example.recovery;
2+
3+
import com.codeheadsystems.hofmann.server.recovery.RecoveryChallenger;
4+
import java.nio.charset.StandardCharsets;
5+
import java.security.MessageDigest;
6+
import java.security.SecureRandom;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
/**
12+
* Demo recovery challenger that generates 6-digit codes and logs them to the console.
13+
* In a real application, you would send these via email, SMS, or another out-of-band channel.
14+
*/
15+
public class DemoRecoveryChallenger implements RecoveryChallenger {
16+
17+
private static final Logger log = LoggerFactory.getLogger(DemoRecoveryChallenger.class);
18+
19+
private final SecureRandom random = new SecureRandom();
20+
private final ConcurrentHashMap<String, String> pendingChallenges = new ConcurrentHashMap<>();
21+
22+
@Override
23+
public void sendChallenge(byte[] credentialIdentifier) {
24+
String code = String.format("%06d", random.nextInt(1_000_000));
25+
String credId = new String(credentialIdentifier, StandardCharsets.UTF_8);
26+
pendingChallenges.put(credId, code);
27+
log.info("╔══════════════════════════════════════════╗");
28+
log.info("║ RECOVERY CODE for {}: {} ║", credId, code);
29+
log.info("╚══════════════════════════════════════════╝");
30+
}
31+
32+
@Override
33+
public boolean verifyResponse(byte[] credentialIdentifier, String challengeResponse) {
34+
String credId = new String(credentialIdentifier, StandardCharsets.UTF_8);
35+
String expected = pendingChallenges.remove(credId);
36+
if (expected == null) {
37+
return false;
38+
}
39+
return MessageDigest.isEqual(
40+
expected.getBytes(StandardCharsets.UTF_8),
41+
challengeResponse.getBytes(StandardCharsets.UTF_8));
42+
}
43+
}

0 commit comments

Comments
 (0)