23
23
import java .util .Optional ;
24
24
import java .util .function .Consumer ;
25
25
import java .util .stream .Collectors ;
26
+ import java .util .stream .Stream ;
26
27
27
28
import jakarta .servlet .http .HttpServletRequest ;
28
29
33
34
import software .xdev .spring .security .web .authentication .ui .extendable .filters .ExtendableDefaultLoginPageGeneratingFilter ;
34
35
35
36
37
+ @ SuppressWarnings ("unused" ) // This is an API ;)
36
38
public class AdvancedLoginPageGeneratingFilter
37
39
extends ExtendableDefaultLoginPageGeneratingFilter
38
40
implements AdvancedSharedPageGeneratingFilter <AdvancedLoginPageGeneratingFilter >
@@ -51,6 +53,8 @@ public class AdvancedLoginPageGeneratingFilter
51
53
52
54
protected String header = "" ;
53
55
56
+ protected String passKeysWebAuthnScriptLocation = "/login/webauthn.js" ;
57
+
54
58
protected String formLoginUsernameText = "Username" ;
55
59
56
60
protected String formLoginPasswordText = "Password" ;
@@ -67,6 +71,10 @@ public class AdvancedLoginPageGeneratingFilter
67
71
68
72
protected String oneTimeTokenSendTokenText = "Send token" ;
69
73
74
+ protected String passkeyLoginHeaderText = "Login with Passkeys" ;
75
+
76
+ protected String passkeySignInSubmitText = "Sign in with a passkey" ;
77
+
70
78
protected String ssoLoginHeaderText = "Login with" ;
71
79
72
80
protected String footer = "" ;
@@ -188,6 +196,18 @@ public AdvancedLoginPageGeneratingFilter oneTimeTokenSendTokenText(final String
188
196
return this ;
189
197
}
190
198
199
+ public AdvancedLoginPageGeneratingFilter passkeyLoginHeaderText (final String passkeyLoginHeaderText )
200
+ {
201
+ this .passkeyLoginHeaderText = passkeyLoginHeaderText ;
202
+ return this ;
203
+ }
204
+
205
+ public AdvancedLoginPageGeneratingFilter passkeySignInSubmitText (final String passkeySignInSubmitText )
206
+ {
207
+ this .passkeySignInSubmitText = passkeySignInSubmitText ;
208
+ return this ;
209
+ }
210
+
191
211
public AdvancedLoginPageGeneratingFilter ssoLoginHeaderText (final String ssoLoginHeaderText )
192
212
{
193
213
this .ssoLoginHeaderText = ssoLoginHeaderText ;
@@ -217,25 +237,94 @@ protected String generateLoginPageHtml(
217
237
final boolean loginError ,
218
238
final boolean logoutSuccess )
219
239
{
240
+ final String contextPath = request .getContextPath ();
241
+
220
242
return "<!DOCTYPE html>"
221
243
+ "<html lang='en' style='height:100%'>"
222
- + this .generateHeader ()
223
- + this .generateBody (request , loginError , logoutSuccess )
244
+ + this .generateHeader (request , contextPath )
245
+ + this .generateBody (request , contextPath , loginError , logoutSuccess )
224
246
+ "</html>" ;
225
247
}
226
248
227
- protected String generateHeader ()
249
+ // region Header
250
+
251
+ protected String generateHeader (
252
+ final HttpServletRequest request ,
253
+ final String contextPath )
254
+ {
255
+ return this .generateHeader (
256
+ this .headerMetas ,
257
+ this .pageTitle ,
258
+ this .requiresAdditionalHeaderElements ()
259
+ ? Stream .concat (
260
+ this .headerElements .stream (),
261
+ this .additionalHeaderElements (request , contextPath ).stream ())
262
+ .toList ()
263
+ : this .headerElements );
264
+ }
265
+
266
+ protected boolean requiresAdditionalHeaderElements ()
267
+ {
268
+ return this .passkeysEnabled ;
269
+ }
270
+
271
+ protected List <String > additionalHeaderElements (
272
+ final HttpServletRequest request ,
273
+ final String contextPath )
274
+ {
275
+ final List <String > additionalHeaderElements = new ArrayList <>();
276
+
277
+ if (this .passkeysEnabled )
278
+ {
279
+ additionalHeaderElements .addAll (this .createPassKeyHeaderElements (request , contextPath ));
280
+ }
281
+
282
+ return additionalHeaderElements ;
283
+ }
284
+
285
+ protected List <String > createPassKeyHeaderElements (
286
+ final HttpServletRequest request ,
287
+ final String contextPath )
288
+ {
289
+ return List .of (
290
+ "<script type=\" text/javascript\" src=\" " + contextPath + this .passKeysWebAuthnScriptLocation
291
+ + "\" ></script>" ,
292
+ this .createPassKeyScript (request , contextPath )
293
+ );
294
+ }
295
+
296
+ protected String createPassKeyScript (
297
+ final HttpServletRequest request ,
298
+ final String contextPath )
299
+ {
300
+ return """
301
+ <script type="text/javascript">
302
+ document.addEventListener("DOMContentLoaded",\
303
+ () => setupLogin(%s, "%s", document.getElementById('passkey-signin')));
304
+ </script>
305
+ """ .formatted (this .renderHeadersForFetchAPI (request ), contextPath );
306
+ }
307
+
308
+ protected String renderHeadersForFetchAPI (final HttpServletRequest request )
228
309
{
229
- return this .generateHeader (this .headerMetas , this .pageTitle , this .headerElements );
310
+ return "{ "
311
+ + this .resolveHeaders .apply (request ).entrySet ()
312
+ .stream ()
313
+ .map (e -> "\" " + e .getKey () + "\" : \" " + e .getValue () + "\" " )
314
+ .collect (Collectors .joining (", " ))
315
+ + " }" ;
230
316
}
231
317
318
+ // endregion
319
+ // region Body
320
+
232
321
protected String generateBody (
233
322
final HttpServletRequest request ,
323
+ final String contextPath ,
234
324
final boolean loginError ,
235
325
final boolean logoutSuccess )
236
326
{
237
327
final String errorMsg = loginError ? this .getLoginErrorMessage (request ) : "Invalid credentials" ;
238
- final String contextPath = request .getContextPath ();
239
328
240
329
return " " + this .createBodyElement ()
241
330
+ " " + this .createContainerElement ()
@@ -245,6 +334,7 @@ protected String generateBody(
245
334
+ this .header
246
335
+ this .createFormLogin (request , contextPath )
247
336
+ this .createOneTimeTokenLogin (request , contextPath )
337
+ + this .createPasskeyFormLogin ()
248
338
+ (this .hasSSOLogin () ? this .createHeaderLoginWith () : "" )
249
339
+ this .createOAuth2LoginPagePart (contextPath )
250
340
+ this .createSaml2LoginPagePart (contextPath )
@@ -347,9 +437,7 @@ protected String createRememberMe(final String paramName)
347
437
348
438
protected String createFormLoginSignInButton ()
349
439
{
350
- return "<button class=\" btn btn-block btn-primary w-100 mt-1\" type=\" submit\" >"
351
- + this .formLoginSignInText
352
- + "</button>" ;
440
+ return this .createFormSubmitButton (this .formLoginSignInText );
353
441
}
354
442
355
443
// endregion
@@ -377,15 +465,40 @@ protected String createOneTimeTokenLogin(final HttpServletRequest request, final
377
465
378
466
protected String createOneTimeTokenLoginHeader ()
379
467
{
380
- return "<h5 class=\" h5 mb-2 fw-normal\" >"
381
- + this .oneTimeTokenHeaderText
382
- + "</h5>" ;
468
+ return this .createLoginMethodHeader (this .oneTimeTokenHeaderText );
383
469
}
384
470
385
471
protected String createOneTimeTokenLoginSignInButton ()
386
472
{
387
- return "<button class=\" btn btn-block btn-primary w-100 mt-1\" type=\" submit\" >"
388
- + this .oneTimeTokenSendTokenText
473
+ return this .createFormSubmitButton (this .oneTimeTokenSendTokenText );
474
+ }
475
+
476
+ // endregion
477
+ // region Render Passkeys
478
+
479
+ protected String createPasskeyFormLogin ()
480
+ {
481
+ if (!this .passkeysEnabled )
482
+ {
483
+ return "" ;
484
+ }
485
+
486
+ return this .createPasskeyFormLoginHeader ()
487
+ // This needs to be a div and not a form
488
+ + "<div id=\" passkey-form\" class=\" mb-3 login-form\" >"
489
+ + this .createPasskeyForSignInButton ()
490
+ + "</div>" ;
491
+ }
492
+
493
+ protected String createPasskeyFormLoginHeader ()
494
+ {
495
+ return this .createLoginMethodHeader (this .passkeyLoginHeaderText );
496
+ }
497
+
498
+ protected String createPasskeyForSignInButton ()
499
+ {
500
+ return "<button id=\" passkey-signin\" class=\" btn btn-block btn-primary w-100 mt-1\" type=\" submit\" >"
501
+ + this .passkeySignInSubmitText
389
502
+ "</button>" ;
390
503
}
391
504
@@ -399,9 +512,7 @@ protected boolean hasSSOLogin()
399
512
400
513
protected String createHeaderLoginWith ()
401
514
{
402
- return "<h5 class=\" h5 mb-2 fw-normal\" >"
403
- + this .ssoLoginHeaderText
404
- + "</h5>" ;
515
+ return this .createLoginMethodHeader (this .ssoLoginHeaderText );
405
516
}
406
517
407
518
protected String createOAuth2LoginPagePart (final String contextPath )
@@ -465,11 +576,25 @@ protected String createSSOLoginPagePart(
465
576
466
577
// endregion
467
578
579
+ protected String createLoginMethodHeader (final String text )
580
+ {
581
+ return "<h5 class=\" h5 mb-2 fw-normal\" >" + text + "</h5>" ;
582
+ }
583
+
584
+ public String createFormSubmitButton (final String text )
585
+ {
586
+ return "<button class=\" btn btn-block btn-primary w-100 mt-1\" type=\" submit\" >"
587
+ + text
588
+ + "</button>" ;
589
+ }
590
+
468
591
protected String renderHiddenInputs (final HttpServletRequest request )
469
592
{
470
593
return this .renderHiddenInputs (this .resolveHiddenInputs .apply (request ).entrySet ());
471
594
}
472
595
596
+ // endregion
597
+
473
598
protected record ButtonBuildingData (
474
599
String url ,
475
600
String name ,
0 commit comments