diff --git a/src/main/java/iz/_11a/passmetric/PasswordController.java b/src/main/java/iz/_11a/passmetric/PasswordController.java index da64119..7e3d181 100644 --- a/src/main/java/iz/_11a/passmetric/PasswordController.java +++ b/src/main/java/iz/_11a/passmetric/PasswordController.java @@ -1,13 +1,25 @@ package iz._11a.passmetric; +import iz._11a.passmetric.model.PasswordLeakResult; +import iz._11a.passmetric.service.PasswordLeakService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import java.util.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.nio.charset.StandardCharsets; +import org.springframework.web.client.RestTemplate; + @Controller public class PasswordController { + private final PasswordLeakService service; + + public PasswordController(PasswordLeakService service) { + this.service = service; + } @GetMapping("/") public String home() { return "password"; @@ -22,10 +34,12 @@ public class PasswordController { int score = calculateScore(password); String strengthText = strengthText(score); List tips = generateTips(password); + PasswordLeakResult leakResult = service.checkLeakWithCount(password); response.put("strengthText", strengthText); response.put("progress", score * 20); // pasek postępu 0–100% response.put("tips", tips); + response.put("leaked", leakResult.isLeaked() ? "Hasło wyciekło " + String.valueOf(leakResult.getCount()) +" razy" : "Hasło nie występuje w wyciekach"); return response; } diff --git a/src/main/java/iz/_11a/passmetric/model/PasswordLeakResult.java b/src/main/java/iz/_11a/passmetric/model/PasswordLeakResult.java new file mode 100644 index 0000000..2c7dc42 --- /dev/null +++ b/src/main/java/iz/_11a/passmetric/model/PasswordLeakResult.java @@ -0,0 +1,16 @@ +package iz._11a.passmetric.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PasswordLeakResult { + public final boolean leaked; + public final int count; + + public PasswordLeakResult(boolean leaked, int count) { + this.leaked = leaked; + this.count = count; + } +} diff --git a/src/main/java/iz/_11a/passmetric/service/PasswordLeakService.java b/src/main/java/iz/_11a/passmetric/service/PasswordLeakService.java new file mode 100644 index 0000000..feb5ded --- /dev/null +++ b/src/main/java/iz/_11a/passmetric/service/PasswordLeakService.java @@ -0,0 +1,71 @@ +package iz._11a.passmetric.service; + +import iz._11a.passmetric.model.PasswordLeakResult; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.OptionalInt; + +@Service +public class PasswordLeakService { + + private final RestClient restClient; + + public PasswordLeakService(RestClient.Builder builder) { + this.restClient = builder + .baseUrl("https://api.pwnedpasswords.com") + .defaultHeader(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN_VALUE) + .defaultHeader(HttpHeaders.USER_AGENT, "passmetric/1.0") + .build(); + } + + public PasswordLeakResult checkLeakWithCount(String password) { + if (password == null || password.isEmpty()) { + return new PasswordLeakResult(false, 0); + } + String sha1 = sha1Hex(password).toUpperCase(); + String prefix = sha1.substring(0, 5); + String suffix = sha1.substring(5); + + try { + String body = restClient.get() + .uri("/range/{prefix}", prefix) + .retrieve() + .body(String.class); + + if (body == null || body.isEmpty()) { + return new PasswordLeakResult(false, 0); + } + + for (String line : body.split("\n")) { + String[] parts = line.split(":"); + if (parts.length == 2 && parts[0].equalsIgnoreCase(suffix)) { + int count = Integer.parseInt(parts[1].trim()); + return new PasswordLeakResult(true, count); + } + } + return new PasswordLeakResult(false, 0); + } catch (RestClientException ex) { + return new PasswordLeakResult(false, 0); + } + } + + public boolean isLeaked(String password) { + return checkLeakWithCount(password).isLeaked(); + } + + private String sha1Hex(String input) { + try { + var md = java.security.MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-1 not available", e); + } + } +} diff --git a/src/main/resources/static/css/password.css b/src/main/resources/static/css/password.css index e70d734..5f332a7 100644 --- a/src/main/resources/static/css/password.css +++ b/src/main/resources/static/css/password.css @@ -117,7 +117,7 @@ input[type="password"]:focus { } } -#liveMessage { +.message { width: min(92vw, 480px); margin: 12px auto 0; padding: 12px 14px; @@ -129,54 +129,55 @@ input[type="password"]:focus { font-size: 14px; transition: all 200ms ease; opacity: 1; + min-height: 47px; } @media (prefers-color-scheme: dark) { - #liveMessage { + .message { background: rgba(30, 41, 59, 0.75); border-color: rgba(203, 213, 225, 0.2); } } /* Hide when empty */ -#liveMessage:empty { - display: none; +.message:empty { + visibility: hidden; } -#liveMessage.ok { +.message.ok { border-color: var(--ok); background: color-mix(in oklab, var(--ok) 20%, rgba(241, 245, 249, 0.85)); color: #15803d; } @media (prefers-color-scheme: dark) { - #liveMessage.ok { + .message.ok { background: color-mix(in oklab, var(--ok) 18%, rgba(30, 41, 59, 0.75)); color: #86efac; } } -#liveMessage.warn { +.message.warn { border-color: var(--warn); background: color-mix(in oklab, var(--warn) 20%, rgba(241, 245, 249, 0.85)); color: #c2410c; } @media (prefers-color-scheme: dark) { - #liveMessage.warn { + .message.warn { background: color-mix(in oklab, var(--warn) 18%, rgba(30, 41, 59, 0.75)); color: #fdba74; } } -#liveMessage.bad { +.message.bad { border-color: var(--bad); background: color-mix(in oklab, var(--bad) 20%, rgba(241, 245, 249, 0.85)); color: #b91c1c; } @media (prefers-color-scheme: dark) { - #liveMessage.bad { + .message.bad { background: color-mix(in oklab, var(--bad) 18%, rgba(30, 41, 59, 0.75)); color: #fca5a5; } diff --git a/src/main/resources/templates/password.html b/src/main/resources/templates/password.html index a52c609..8148a3e 100644 --- a/src/main/resources/templates/password.html +++ b/src/main/resources/templates/password.html @@ -19,8 +19,10 @@
- -

+
+

+

+
@@ -30,6 +32,7 @@ const out = document.getElementById('liveMessage'); const bar = document.getElementById('strengthBarFill'); const tipsList = document.getElementById('tipsList'); + const leakOut = document.getElementById('leakMessage'); let t; input.addEventListener('input', () => { @@ -38,7 +41,9 @@ if (!val) { out.textContent = ''; - out.className = ''; + out.className = 'message'; + leakOut.textContent = ''; + leakOut.className = 'message'; bar.style.width = "0%"; tipsList.innerHTML = ''; return; @@ -51,17 +56,14 @@ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password: val }) }); - if (!resp.ok) { out.textContent = 'Błąd sprawdzania'; + leakOut.textContent = ''; return; } - const data = await resp.json(); - - // Ustawiamy tekst siły hasła out.textContent = data.strengthText || ''; - out.className = ''; + out.className = 'message'; switch (data.strengthText) { case "Bardzo słabe hasło": @@ -69,23 +71,30 @@ bar.style.background = "var(--bad)"; break; case "Słabe hasło": - out.classList.add("warn"); - bar.style.background = "var(--warn)"; - break; case "Średnie hasło": out.classList.add("warn"); bar.style.background = "var(--warn)"; break; case "Silne hasło": - out.classList.add("ok"); - bar.style.background = "var(--ok)"; - break; case "Bardzo silne hasło": out.classList.add("ok"); bar.style.background = "var(--ok)"; break; } + if (data.leaked) { + leakOut.textContent = data.leaked || ''; + leakOut.className = 'message'; + switch (data.leaked) { + case "Hasło wyciekło!": + leakOut.classList.add("bad"); + break; + case "Hasło nie występuje w wyciekach": + leakOut.classList.add("ok"); + break; + } + } + // Pasek postępu (0–100%) bar.style.width = (data.progress || 0) + "%"; @@ -99,6 +108,7 @@ } catch { out.textContent = 'Błąd sieci'; + leakOut.textContent = ''; } }, 150); });