Skip to content

Commit 7c7b6b6

Browse files
committed
feat(security): introduce one-time authz code for form-based auth
1 parent 8ca3d17 commit 7c7b6b6

18 files changed

+1703
-71
lines changed

docs/src/main/asciidoc/security-authentication-mechanisms.adoc

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,111 @@ The following properties can be used to configure form-based authentication:
217217

218218
include::{generated-dir}/config/quarkus-vertx-http_quarkus.http.auth.adoc[opts=optional, leveloffset=+1]
219219

220+
[[two-factor-auth]]
221+
==== Two-factor authentication
222+
223+
The form-based authentication mechanism supports two-factor authentication (2FA), with the second-factor being a one-time authorization code.
224+
225+
.Enable two-factor authentication
226+
[source,properties]
227+
----
228+
quarkus.http.auth.form.otac.enabled=true
229+
quarkus.http.auth.form.otac.request-redirect-path=/authorization-code-form <1>
230+
----
231+
<1> Once the one-time authorization code has been generated, redirect to a page with an authorization code form.
232+
233+
When user submit a username and password to the `/j_security_check` POST location, they will get relocated to the `/authorization-code-form` page.
234+
The `/authorization-code-form` page should allow users to submit the one-time authorization code sent to them.
235+
236+
.Example form for authentication with a one-time authorization code
237+
[source,html]
238+
----
239+
<form action="/j_security_check" method="post"> <1>
240+
<label>Authorization code</label>
241+
<input type="password" placeholder="Authorization code" name="j_otac" required>
242+
<button type="submit">Login</button>
243+
</form>
244+
----
245+
<1> Send the one-time authorization code to the POST location.
246+
247+
Once Quarkus has generated the one-time authorization code, you need to deliver the code to a user by declaring a CDI bean that implements the `io.quarkus.security.spi.runtime.OneTimeAuthorizationCodeSender` interface.
248+
249+
.Example one-time authorization code sender
250+
[source,java]
251+
----
252+
package org.acme.security;
253+
254+
import io.quarkus.mailer.reactive.ReactiveMailer;
255+
import io.quarkus.security.credential.PasswordCredential;
256+
import io.quarkus.security.identity.SecurityIdentity;
257+
import io.quarkus.security.spi.runtime.OneTimeAuthorizationCodeSender;
258+
import io.smallrye.mutiny.Uni;
259+
import jakarta.inject.ApplicationScoped;
260+
261+
@ApplicationScoped
262+
public final class AuthorizationCodeMailer implements OneTimeAuthorizationCodeSender {
263+
264+
@Inject
265+
ReactiveMailer reactiveMailer;
266+
267+
@Override
268+
public Uni<Void> send(SecurityIdentity securityIdentity, PasswordCredential credential) {
269+
// send the one-time authorization code to the user represented by the security identity
270+
String authorizationCode = new String(oneTimeAuthorizationCode.getPassword());
271+
var mail = Mail.withText("[email protected]", "Your authorization code", authorizationCode).setFrom("[email protected]");
272+
return reactiveMailer.send(mail); <1>
273+
}
274+
275+
}
276+
----
277+
<1> Use the Quarkus Mailer extension to send the email with the authorization code.
278+
See the xref:mailer-reference.adoc[Quarkus Mailer Reference documentation] for more information about the mailer.
279+
280+
NOTE: By default Quarkus stores generated authorization codes in memory.
281+
If you run your application in multiple instances, you need to store the codes in a database, clustered cache or other external storage.
282+
It is possible to implement custom storage by declaring a CDI bean that implements the `io.quarkus.security.spi.runtime.OneTimeAuthorizationCodeAuthenticator` interface.
283+
284+
==== Password recovery using a one-time authorization code
285+
286+
Instead of using the one-time authorization code feature as the second authentication factor, you can enable using the code as the single authentication factor.
287+
Users can still authenticate with the username and password, but for example if they forgot a password, they can generate the one-time authorization code to authenticate and use the session for the password recovery.
288+
289+
.Enable two-factor authentication
290+
[source,properties]
291+
----
292+
quarkus.http.auth.form.otac.enabled=true
293+
quarkus.http.auth.form.otac.request-path=/generate-authorization-code <1>
294+
----
295+
<1> Users can generate the one-time authorization code by sending a username (the `j_username` form param) to the `/generate-authorization-code` POST location.
296+
297+
.Example form for requesting the one-time authorization code
298+
[source,html]
299+
----
300+
<form action="/generate-authorization-code" method="post">
301+
<label>Username</label>
302+
<input type="text" placeholder="Username" name="j_username" required> <1>
303+
<button type="submit">Login</button>
304+
</form>
305+
----
306+
<1> Request the one-time authorization code for a user with specified `username`.
307+
308+
.Example form for authentication with the one-time authorization code
309+
[source,html]
310+
----
311+
<form action="/j_security_check" method="post"> <1>
312+
<label>Authorization code</label>
313+
<input type="password" placeholder="Authorization code" name="j_otac" required>
314+
<button type="submit">Login</button>
315+
</form>
316+
----
317+
<1> Send the one-time authorization code to the POST location.
318+
319+
The one-time authorization code is send in the same fashion as described in the <<two-factor-auth>> section of this guide.
320+
Once users are logged in, you can provide them with a form to change the password.
321+
322+
IMPORTANT: The `/generate-authorization-code` request path may need to access database or execute remote calls depending on the identity provider you are using.
323+
In order to prevent the Denial-of-service attack, you should use load shedding to avoid system overload.
324+
220325
[[mutual-tls]]
221326
=== Mutual TLS authentication
222327

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.quarkus.security.spi.runtime;
2+
3+
import io.quarkus.security.credential.PasswordCredential;
4+
import io.quarkus.security.identity.request.BaseAuthenticationRequest;
5+
6+
/**
7+
* The {@link io.quarkus.security.identity.request.AuthenticationRequest} used for one-time authorization code credentials.
8+
*/
9+
public final class OneTimeAuthZCodeAuthenticationRequest extends BaseAuthenticationRequest {
10+
11+
private final PasswordCredential oneTimeAuthorizationCode;
12+
13+
private OneTimeAuthZCodeAuthenticationRequest(PasswordCredential oneTimeAuthorizationCode) {
14+
this.oneTimeAuthorizationCode = oneTimeAuthorizationCode;
15+
}
16+
17+
private OneTimeAuthZCodeAuthenticationRequest(char[] oneTimeAuthorizationCode) {
18+
this(new PasswordCredential(oneTimeAuthorizationCode));
19+
}
20+
21+
public OneTimeAuthZCodeAuthenticationRequest(String oneTimeAuthorizationCode) {
22+
this(oneTimeAuthorizationCode.toCharArray());
23+
}
24+
25+
public PasswordCredential getCredential() {
26+
return oneTimeAuthorizationCode;
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package io.quarkus.security.spi.runtime;
2+
3+
import java.time.Duration;
4+
5+
import io.quarkus.security.credential.PasswordCredential;
6+
import io.quarkus.security.identity.AuthenticationRequestContext;
7+
import io.quarkus.security.identity.IdentityProvider;
8+
import io.quarkus.security.identity.SecurityIdentity;
9+
import io.smallrye.mutiny.Uni;
10+
11+
/**
12+
* Stores generated one-time authorization code and authenticates {@link SecurityIdentity}
13+
* based on one-time authorization code. Must be implemented as a {@link jakarta.enterprise.context.ApplicationScoped}
14+
* or {@link jakarta.inject.Singleton} CDI bean.
15+
*/
16+
public interface OneTimeAuthorizationCodeAuthenticator extends IdentityProvider<OneTimeAuthZCodeAuthenticationRequest> {
17+
18+
/**
19+
* Event data key that the authenticator can optionally set to the one-time authorization code request
20+
* absolute URL if redirection to the page where user was before authentication has been triggered is enabled.
21+
*/
22+
String REDIRECT_LOCATION_KEY = "io.quarkus.security.spi.runtime.otac#REDIRECT_LOCATION";
23+
24+
/**
25+
* Stores generated one-time authorization code.
26+
* When used in a production, this method encrypts one-time authorization code before it is stored.
27+
* Only one one-time authorization code is allowed per user, if user already had generated the one-time authorization
28+
* code, invocation of this method must replace previous authorization code with a new one.
29+
*
30+
* @param securityIdentity {@link SecurityIdentity}
31+
* @param oneTimeAuthorizationCode one-time authorization code credential
32+
* @param requestInfo contextual information about request to store the code
33+
* @return Uni<Void>; never null
34+
*/
35+
Uni<Void> store(SecurityIdentity securityIdentity, PasswordCredential oneTimeAuthorizationCode, RequestInfo requestInfo);
36+
37+
/**
38+
* Authenticates incoming request based on passed one-time authorization code. Expired one-time authorization codes
39+
* must result in authentication failures.
40+
*
41+
* @param oneTimeAuthZCodeAuthenticationRequest authentication request with one-time authorization code credential
42+
* @param authenticationRequestContext authentication request context; simplifies blocking operations
43+
* @return SecurityIdentity for which the one-time authorization code was generated
44+
*/
45+
Uni<SecurityIdentity> authenticate(OneTimeAuthZCodeAuthenticationRequest oneTimeAuthZCodeAuthenticationRequest,
46+
AuthenticationRequestContext authenticationRequestContext);
47+
48+
/**
49+
* Provides contextual information about incoming request to store one-time authorization code.
50+
*
51+
* @param redirectLocation absolute URL from which request to generate token arrived; nullable
52+
* @param expiresIn code expiration; never null
53+
*/
54+
record RequestInfo(String redirectLocation, Duration expiresIn) {
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.quarkus.security.spi.runtime;
2+
3+
import io.quarkus.security.credential.PasswordCredential;
4+
import io.quarkus.security.identity.SecurityIdentity;
5+
import io.smallrye.mutiny.Uni;
6+
7+
/**
8+
* An interface responsible for delivering of the one-time authorization code to the user.
9+
* This interface should be implemented as {@link jakarta.enterprise.context.ApplicationScoped}
10+
* or {@link jakarta.inject.Singleton} CDI bean.
11+
*/
12+
public interface OneTimeAuthorizationCodeSender {
13+
14+
/**
15+
* Sends one-time authorization code to the user.
16+
*
17+
* @param securityIdentity represents user for which one-time authorization code has been requested
18+
* @param oneTimeAuthorizationCode one-time authorization code credential
19+
* @return {@link Uni}; must not be null
20+
*/
21+
Uni<Void> send(SecurityIdentity securityIdentity, PasswordCredential oneTimeAuthorizationCode);
22+
23+
}

extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,26 @@
4040

4141
import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
4242
import io.quarkus.arc.deployment.BeanContainerBuildItem;
43+
import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem;
4344
import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem;
4445
import io.quarkus.arc.deployment.GeneratedBeanBuildItem;
4546
import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor;
4647
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
48+
import io.quarkus.arc.deployment.UnremovableBeanBuildItem;
4749
import io.quarkus.arc.processor.BeanInfo;
4850
import io.quarkus.builder.item.SimpleBuildItem;
4951
import io.quarkus.deployment.Capabilities;
5052
import io.quarkus.deployment.Capability;
5153
import io.quarkus.deployment.annotations.BuildProducer;
5254
import io.quarkus.deployment.annotations.BuildStep;
55+
import io.quarkus.deployment.annotations.Consume;
5356
import io.quarkus.deployment.annotations.ExecutionTime;
5457
import io.quarkus.deployment.annotations.Produce;
5558
import io.quarkus.deployment.annotations.Record;
5659
import io.quarkus.deployment.builditem.ApplicationIndexBuildItem;
5760
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
61+
import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem;
62+
import io.quarkus.deployment.builditem.ServiceStartBuildItem;
5863
import io.quarkus.deployment.builditem.SystemPropertyBuildItem;
5964
import io.quarkus.gizmo.ClassCreator;
6065
import io.quarkus.gizmo.DescriptorUtils;
@@ -69,6 +74,8 @@
6974
import io.quarkus.security.spi.ClassSecurityAnnotationBuildItem;
7075
import io.quarkus.security.spi.RegisterClassSecurityCheckBuildItem;
7176
import io.quarkus.security.spi.runtime.MethodDescription;
77+
import io.quarkus.security.spi.runtime.OneTimeAuthorizationCodeAuthenticator;
78+
import io.quarkus.security.spi.runtime.OneTimeAuthorizationCodeSender;
7279
import io.quarkus.vertx.http.runtime.VertxHttpBuildTimeConfig;
7380
import io.quarkus.vertx.http.runtime.VertxHttpConfig;
7481
import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig;
@@ -80,6 +87,7 @@
8087
import io.quarkus.vertx.http.runtime.security.HttpAuthorizer;
8188
import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder;
8289
import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.AuthenticationHandler;
90+
import io.quarkus.vertx.http.runtime.security.InMemoryOneTimeAuthZCodeAuthenticator;
8391
import io.quarkus.vertx.http.runtime.security.MtlsAuthenticationMechanism;
8492
import io.quarkus.vertx.http.runtime.security.PathMatchingHttpSecurityPolicy;
8593
import io.quarkus.vertx.http.runtime.security.VertxBlockingSecurityExecutor;
@@ -97,22 +105,50 @@ public class HttpSecurityProcessor {
97105
private static final DotName BASIC_AUTH_ANNOTATION_NAME = DotName.createSimple(BasicAuthentication.class);
98106
private static final String KOTLIN_SUSPEND_IMPL_SUFFIX = "$suspendImpl";
99107

108+
@Produce(ServiceStartBuildItem.class)
109+
@Consume(RuntimeConfigSetupCompleteBuildItem.class)
100110
@BuildStep
101-
@Record(ExecutionTime.STATIC_INIT)
102-
AdditionalBeanBuildItem initFormAuth(
103-
HttpSecurityRecorder recorder,
104-
VertxHttpBuildTimeConfig buildTimeConfig,
105-
BuildProducer<RouteBuildItem> filterBuildItemBuildProducer) {
106-
if (buildTimeConfig.auth().form().enabled()) {
107-
if (!buildTimeConfig.auth().proactive()) {
108-
filterBuildItemBuildProducer
109-
.produce(RouteBuildItem.builder().route(buildTimeConfig.auth().form().postLocation())
110-
.handler(recorder.formAuthPostHandler()).build());
111+
@Record(ExecutionTime.RUNTIME_INIT)
112+
void initFormAuthPathHandlers(VertxWebRouterBuildItem vertxWebRouterBuildItem, HttpSecurityRecorder recorder,
113+
VertxHttpBuildTimeConfig httpBuildTimeConfig, VertxHttpConfig httpConfig,
114+
BeanContainerBuildItem beanContainerBuildItem,
115+
BeanDiscoveryFinishedBuildItem beanDiscoveryResult) {
116+
var authBuildTimeConfig = httpBuildTimeConfig.auth();
117+
if (authBuildTimeConfig.form().enabled()) {
118+
var httpRouter = vertxWebRouterBuildItem.getHttpRouter();
119+
if (!authBuildTimeConfig.proactive()) {
120+
recorder.formAuthPostHandler(httpRouter, httpConfig);
121+
}
122+
if (authBuildTimeConfig.form().oneTimeAuthZCodeEnabled()) {
123+
recorder.oneTimeAuthZCodeRequestHandler(httpRouter, httpConfig, beanContainerBuildItem.getValue());
124+
DotName codeSenderInterfaceName = DotName.createSimple(OneTimeAuthorizationCodeSender.class);
125+
if (beanDiscoveryResult.beanStream().stream().noneMatch(bi -> bi.hasType(codeSenderInterfaceName))) {
126+
throw new ConfigurationException(
127+
"One-time authorization code feature is enabled, but no '%s' interface has been found"
128+
.formatted(codeSenderInterfaceName),
129+
Set.of("quarkus.http.auth.form.otac.enabled"));
130+
}
111131
}
112-
return AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(FormAuthenticationMechanism.class)
113-
.setDefaultScope(SINGLETON).build();
114132
}
115-
return null;
133+
}
134+
135+
@BuildStep
136+
List<AdditionalBeanBuildItem> registerFormAuthMechanismBeans(VertxHttpBuildTimeConfig httpBuildTimeConfig,
137+
BuildProducer<UnremovableBeanBuildItem> unremovableBeanProducer) {
138+
if (httpBuildTimeConfig.auth().form().enabled()) {
139+
var formAuthMechanismBean = AdditionalBeanBuildItem.builder().setUnremovable()
140+
.addBeanClass(FormAuthenticationMechanism.class).setDefaultScope(SINGLETON).build();
141+
if (httpBuildTimeConfig.auth().form().oneTimeAuthZCodeEnabled()) {
142+
unremovableBeanProducer
143+
.produce(UnremovableBeanBuildItem.beanTypes(OneTimeAuthorizationCodeSender.class,
144+
OneTimeAuthorizationCodeAuthenticator.class));
145+
var defaultOneTimePasswordAuthenticator = AdditionalBeanBuildItem
146+
.unremovableOf(InMemoryOneTimeAuthZCodeAuthenticator.class);
147+
return List.of(formAuthMechanismBean, defaultOneTimePasswordAuthenticator);
148+
}
149+
return List.of(formAuthMechanismBean);
150+
}
151+
return List.of();
116152
}
117153

118154
@BuildStep

0 commit comments

Comments
 (0)