Skip to content

Commit cc3009e

Browse files
committed
Add support for passkeys (login interface only)
1 parent 87ebe1f commit cc3009e

File tree

5 files changed

+201
-29
lines changed

5 files changed

+201
-29
lines changed

spring-security-advanced-authentication-ui-demo/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,17 @@
33
* Start the [development infrastructure](../dev_infra/)
44
* Run the application
55
* Open ``http://localhost:8080``
6+
7+
## Special Login information
8+
9+
### Username + Password
10+
11+
Example user:
12+
* Username: ``test``
13+
* Password: ``test``
14+
15+
### Passkeys
16+
17+
The browser needs to support passkeys and you also need an appropriate store (usually the OS handles this).
18+
19+
NOTE: Passkeys are lost when rebooting the server

spring-security-advanced-authentication-ui-demo/pom.xml

+7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
<artifactId>spring-boot-starter-oauth2-client</artifactId>
6565
</dependency>
6666

67+
<!-- Required to showcase passkeys -->
68+
<dependency>
69+
<groupId>com.webauthn4j</groupId>
70+
<artifactId>webauthn4j-core</artifactId>
71+
<version>0.28.5.RELEASE</version>
72+
</dependency>
73+
6774
<dependency>
6875
<groupId>org.springframework.boot</groupId>
6976
<artifactId>spring-boot-devtools</artifactId>

spring-security-advanced-authentication-ui-demo/src/main/java/software/xdev/security/MainWebSecurity.java

+33-9
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import java.util.List;
99
import java.util.Map;
1010

11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
1113
import org.springframework.boot.context.properties.EnableConfigurationProperties;
1214
import org.springframework.context.annotation.Bean;
1315
import org.springframework.context.annotation.Configuration;
1416
import org.springframework.security.config.Customizer;
1517
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1618
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
19+
import org.springframework.security.core.userdetails.User;
1720
import org.springframework.security.core.userdetails.UserDetailsService;
1821
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
1922
import org.springframework.security.web.SecurityFilterChain;
@@ -30,12 +33,14 @@
3033
@EnableConfigurationProperties(AdditionalOAuth2ClientProperties.class)
3134
public class MainWebSecurity
3235
{
33-
@Bean(name = "mainSecurityFilterChainBean")
34-
public SecurityFilterChain configure(
36+
private static final Logger LOG = LoggerFactory.getLogger(MainWebSecurity.class);
37+
38+
protected void customizeLogin(
3539
final HttpSecurity http,
3640
final AdditionalOAuth2ClientProperties additionalOAuth2ClientProperties) throws Exception
3741
{
38-
http.with(new AdvancedLoginPageAdapter<>(http), c -> c
42+
http.with(
43+
new AdvancedLoginPageAdapter<>(http), c -> c
3944
.customizePages(p -> p
4045
// No remote communication -> Use local resources
4146
.setHeaderElements(List.of(
@@ -62,27 +67,46 @@ public SecurityFilterChain configure(
6267
+ " href='https://xdev.software' target='_blank'>"
6368
+ " XDEV Software"
6469
+ " </a>"
65-
+ "</p>")))
70+
+ "</p>")));
71+
}
72+
73+
@Bean(name = "mainSecurityFilterChainBean")
74+
public SecurityFilterChain configure(
75+
final HttpSecurity http,
76+
final AdditionalOAuth2ClientProperties additionalOAuth2ClientProperties) throws Exception
77+
{
78+
this.customizeLogin(http, additionalOAuth2ClientProperties);
79+
80+
http
6681
.headers(h -> h
6782
.referrerPolicy(r -> r.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.SAME_ORIGIN))
6883
.contentSecurityPolicy(csp -> csp.policyDirectives(this.getCSP())))
6984
.formLogin(Customizer.withDefaults())
7085
.oneTimeTokenLogin(c -> c.tokenGenerationSuccessHandler(
71-
(request, response, oneTimeToken) -> {
72-
// Do nothing - Dummy
73-
}))
86+
(request, response, oneTimeToken) ->
87+
LOG.info(
88+
"OneTimeToken should be sent for {} with value {}",
89+
oneTimeToken.getUsername(),
90+
oneTimeToken.getTokenValue())))
91+
.webAuthn(c -> c.rpName("Spring Security Localhost Relying Party")
92+
.rpId("localhost")
93+
.allowedOrigins("http://localhost:8080"))
7494
.oauth2Login(c -> c.defaultSuccessUrl("/"))
7595
.authorizeHttpRequests(urlRegistry -> urlRegistry.anyRequest().authenticated())
7696
.requestCache(c -> c.requestCache(new NullRequestCache()));
7797

7898
return http.build();
7999
}
80100

81-
// Required for OTT
82101
@Bean
102+
@SuppressWarnings({"java:S6437", "deprecation"})
83103
public UserDetailsService userDetailsService()
84104
{
85-
return new InMemoryUserDetailsManager();
105+
return new InMemoryUserDetailsManager(User.withDefaultPasswordEncoder()
106+
.username("test")
107+
.password("test")
108+
.roles("USER")
109+
.build());
86110
}
87111

88112
// Example CSP

spring-security-advanced-authentication-ui/src/main/java/software/xdev/spring/security/web/authentication/ui/advanced/filters/AdvancedLoginPageGeneratingFilter.java

+141-16
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Optional;
2424
import java.util.function.Consumer;
2525
import java.util.stream.Collectors;
26+
import java.util.stream.Stream;
2627

2728
import jakarta.servlet.http.HttpServletRequest;
2829

@@ -33,6 +34,7 @@
3334
import software.xdev.spring.security.web.authentication.ui.extendable.filters.ExtendableDefaultLoginPageGeneratingFilter;
3435

3536

37+
@SuppressWarnings("unused") // This is an API ;)
3638
public class AdvancedLoginPageGeneratingFilter
3739
extends ExtendableDefaultLoginPageGeneratingFilter
3840
implements AdvancedSharedPageGeneratingFilter<AdvancedLoginPageGeneratingFilter>
@@ -51,6 +53,8 @@ public class AdvancedLoginPageGeneratingFilter
5153

5254
protected String header = "";
5355

56+
protected String passKeysWebAuthnScriptLocation = "/login/webauthn.js";
57+
5458
protected String formLoginUsernameText = "Username";
5559

5660
protected String formLoginPasswordText = "Password";
@@ -67,6 +71,10 @@ public class AdvancedLoginPageGeneratingFilter
6771

6872
protected String oneTimeTokenSendTokenText = "Send token";
6973

74+
protected String passkeyLoginHeaderText = "Login with Passkeys";
75+
76+
protected String passkeySignInSubmitText = "Sign in with a passkey";
77+
7078
protected String ssoLoginHeaderText = "Login with";
7179

7280
protected String footer = "";
@@ -188,6 +196,18 @@ public AdvancedLoginPageGeneratingFilter oneTimeTokenSendTokenText(final String
188196
return this;
189197
}
190198

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+
191211
public AdvancedLoginPageGeneratingFilter ssoLoginHeaderText(final String ssoLoginHeaderText)
192212
{
193213
this.ssoLoginHeaderText = ssoLoginHeaderText;
@@ -217,25 +237,94 @@ protected String generateLoginPageHtml(
217237
final boolean loginError,
218238
final boolean logoutSuccess)
219239
{
240+
final String contextPath = request.getContextPath();
241+
220242
return "<!DOCTYPE html>"
221243
+ "<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)
224246
+ "</html>";
225247
}
226248

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)
228309
{
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+
+ " }";
230316
}
231317

318+
// endregion
319+
// region Body
320+
232321
protected String generateBody(
233322
final HttpServletRequest request,
323+
final String contextPath,
234324
final boolean loginError,
235325
final boolean logoutSuccess)
236326
{
237327
final String errorMsg = loginError ? this.getLoginErrorMessage(request) : "Invalid credentials";
238-
final String contextPath = request.getContextPath();
239328

240329
return " " + this.createBodyElement()
241330
+ " " + this.createContainerElement()
@@ -245,6 +334,7 @@ protected String generateBody(
245334
+ this.header
246335
+ this.createFormLogin(request, contextPath)
247336
+ this.createOneTimeTokenLogin(request, contextPath)
337+
+ this.createPasskeyFormLogin()
248338
+ (this.hasSSOLogin() ? this.createHeaderLoginWith() : "")
249339
+ this.createOAuth2LoginPagePart(contextPath)
250340
+ this.createSaml2LoginPagePart(contextPath)
@@ -347,9 +437,7 @@ protected String createRememberMe(final String paramName)
347437

348438
protected String createFormLoginSignInButton()
349439
{
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);
353441
}
354442

355443
// endregion
@@ -377,15 +465,40 @@ protected String createOneTimeTokenLogin(final HttpServletRequest request, final
377465

378466
protected String createOneTimeTokenLoginHeader()
379467
{
380-
return "<h5 class=\"h5 mb-2 fw-normal\">"
381-
+ this.oneTimeTokenHeaderText
382-
+ "</h5>";
468+
return this.createLoginMethodHeader(this.oneTimeTokenHeaderText);
383469
}
384470

385471
protected String createOneTimeTokenLoginSignInButton()
386472
{
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
389502
+ "</button>";
390503
}
391504

@@ -399,9 +512,7 @@ protected boolean hasSSOLogin()
399512

400513
protected String createHeaderLoginWith()
401514
{
402-
return "<h5 class=\"h5 mb-2 fw-normal\">"
403-
+ this.ssoLoginHeaderText
404-
+ "</h5>";
515+
return this.createLoginMethodHeader(this.ssoLoginHeaderText);
405516
}
406517

407518
protected String createOAuth2LoginPagePart(final String contextPath)
@@ -465,11 +576,25 @@ protected String createSSOLoginPagePart(
465576

466577
// endregion
467578

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+
468591
protected String renderHiddenInputs(final HttpServletRequest request)
469592
{
470593
return this.renderHiddenInputs(this.resolveHiddenInputs.apply(request).entrySet());
471594
}
472595

596+
// endregion
597+
473598
protected record ButtonBuildingData(
474599
String url,
475600
String name,

0 commit comments

Comments
 (0)