From 20d0e142c00d61cec6f2197a86f44d658baba470 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Thu, 12 Sep 2024 16:13:56 +0200 Subject: [PATCH] accounts --- pom.xml | 11 ++ .../eu/m724/talkpages/auth/AuthResource.java | 145 ++++++++++++++++++ .../eu/m724/talkpages/auth/AuthService.java | 84 ++++++++++ .../auth/MyHttpAuthenticationMechanism.java | 65 ++++++++ .../eu/m724/talkpages/orm/entity/Account.java | 8 +- .../talkpages/orm/entity/RegisteredUser.java | 42 +++++ .../eu/m724/talkpages/orm/entity/Session.java | 43 ++++++ .../talkpages/template/AuthExtension.java | 20 +++ .../{ => template}/TemplateExtensions.java | 2 +- src/main/resources/application.properties | 2 + .../templates/AuthResource/auth.html | 18 +++ .../templates/AuthResource/authenticated.html | 2 + .../templates/AuthResource/register.html | 13 ++ .../templates/IndexResource/index.html | 5 + 14 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 src/main/java/eu/m724/talkpages/auth/AuthResource.java create mode 100644 src/main/java/eu/m724/talkpages/auth/AuthService.java create mode 100644 src/main/java/eu/m724/talkpages/auth/MyHttpAuthenticationMechanism.java create mode 100644 src/main/java/eu/m724/talkpages/orm/entity/RegisteredUser.java create mode 100644 src/main/java/eu/m724/talkpages/orm/entity/Session.java create mode 100644 src/main/java/eu/m724/talkpages/template/AuthExtension.java rename src/main/java/eu/m724/talkpages/{ => template}/TemplateExtensions.java (95%) create mode 100644 src/main/resources/templates/AuthResource/auth.html create mode 100644 src/main/resources/templates/AuthResource/authenticated.html create mode 100644 src/main/resources/templates/AuthResource/register.html diff --git a/pom.xml b/pom.xml index e4f0c7f..62b7778 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,17 @@ quarkus-junit5 test + + + + io.quarkus + quarkus-security + + + de.mkammerer + argon2-jvm-nolibs + 2.11 + diff --git a/src/main/java/eu/m724/talkpages/auth/AuthResource.java b/src/main/java/eu/m724/talkpages/auth/AuthResource.java new file mode 100644 index 0000000..3bf7e00 --- /dev/null +++ b/src/main/java/eu/m724/talkpages/auth/AuthResource.java @@ -0,0 +1,145 @@ +package eu.m724.talkpages.auth; + +import eu.m724.talkpages.orm.entity.Session; +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +@Path("/auth") +@Produces(MediaType.TEXT_HTML) +public class AuthResource { + @Inject + AuthService authService; + + @Inject + SecurityIdentity identity; + + @CheckedTemplate + public static class Templates { + public static native TemplateInstance auth(String message); + public static native TemplateInstance authenticated(String username); + public static native TemplateInstance register(String username, String message); + } + + @GET + @Path("/") + public TemplateInstance auth(@QueryParam("message") String message) { + if (identity.isAnonymous()) { + return Templates.auth(message); + } else { + String username = identity.getPrincipal().getName(); + return Templates.authenticated(username); + } + } + + @GET + @Path("/register") + public TemplateInstance register(@QueryParam("username") String username, @QueryParam("message") String message) { + return Templates.register(username, message); + } + + @GET + @Path("/logout") + @Authenticated + public Response logout() { + Session session = identity.getAttribute("session"); + authService.logout(session); + + NewCookie cookie = new NewCookie.Builder("session-token") + .value("") + .maxAge(1) + .path("/") + .build(); + + return Response.temporaryRedirect(URI.create("/")).cookie(cookie).build(); + } + + @POST + @Path("/action/login") + public Response actionLogin(MultivaluedMap formData) { + String username = formData.getFirst("username"); + String password = formData.getFirst("password"); + + try { + Session session = authService.authenticate(username, password); + + NewCookie cookie = new NewCookie.Builder("session-token") + .value(session.token) + .maxAge(604800)// TODO make the age respect session's expires + .httpOnly(true) + .path("/") + .build(); + + return Response.temporaryRedirect(URI.create("/auth")).cookie(cookie).build(); + } catch (AuthService.InvalidCredentialsException e) { + String usernameEncoded = URLEncoder.encode(username, StandardCharsets.UTF_8); + + if (e.password) { + return Response + .temporaryRedirect(URI.create("/auth?message=Incorrect+password&username=" + usernameEncoded)) + .status(Response.Status.SEE_OTHER) + .build(); + } else { + NewCookie cookie = new NewCookie.Builder("password") + .value(password) // TODO don't do that + .maxAge(3600) + .httpOnly(true) + .path("/auth/action/register") + .build(); + + return Response + .temporaryRedirect(URI.create("/auth/register?username=" + usernameEncoded)) + .status(Response.Status.SEE_OTHER) + .cookie(cookie) + .build(); + } + } + } + + @POST + @Path("/action/register") + public Response actionRegister(@Context HttpHeaders headers, MultivaluedMap formData) { + String username = formData.getFirst("username"); + String password = formData.getFirst("password"); + String comparedPassword = headers.getCookies().get("password").getValue(); + String usernameEncoded = URLEncoder.encode(username, StandardCharsets.UTF_8); + + if (!password.equals(comparedPassword)) { + return Response + .temporaryRedirect(URI.create("/auth/register?message=Password+does+not+match&username=" + usernameEncoded)) + .status(Response.Status.SEE_OTHER) + .build(); + } + + try { + Session session = authService.register(username, password); + + NewCookie cookie = new NewCookie.Builder("session-token") + .value(session.token) + .maxAge(604800)// TODO make the age respect session's expires + .httpOnly(true) + .path("/") + .build(); + + return Response + .temporaryRedirect(URI.create("/")) + .status(Response.Status.SEE_OTHER) + .cookie(cookie) + .build(); + } catch (AuthService.UsernameExistsException e) { + return Response + .temporaryRedirect(URI.create("/auth/register?message=Username+is+taken&username=" + usernameEncoded)) + .status(Response.Status.SEE_OTHER) + .build(); + } + } +} diff --git a/src/main/java/eu/m724/talkpages/auth/AuthService.java b/src/main/java/eu/m724/talkpages/auth/AuthService.java new file mode 100644 index 0000000..d49f4ff --- /dev/null +++ b/src/main/java/eu/m724/talkpages/auth/AuthService.java @@ -0,0 +1,84 @@ +package eu.m724.talkpages.auth; + +import de.mkammerer.argon2.Argon2; +import de.mkammerer.argon2.Argon2Factory; +import eu.m724.talkpages.orm.entity.Session; +import eu.m724.talkpages.orm.entity.RegisteredUser; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; + +@ApplicationScoped +public class AuthService { + private final Argon2 argon2 = Argon2Factory.create(); + private final SecureRandom random = new SecureRandom(); + + /** + * Register a new {@link RegisteredUser} + * + * @param username username + * @param password password + * @return a new {@link Session} for the new user + * @throws UsernameExistsException if user with same username already exists + */ + @Transactional + Session register(String username, String password) throws UsernameExistsException { + RegisteredUser user = RegisteredUser.find("username", username).firstResult(); + + if (user != null) { + throw new UsernameExistsException(); + } + + String hashedPassword = argon2.hash(10, 65536, 1, password); + user = RegisteredUser.add(username, hashedPassword, "user"); + + return Session.createForUser(user); + } + + @Transactional + void logout(Session session) { + session.delete(); + } + + @Transactional + Session validateSessionToken(String sessionToken) { + Session session = Session.find("token", sessionToken).firstResult(); + + if (session != null) { + if (session.expires.isAfter(LocalDateTime.now())) { + return session; + } else { + session.delete(); + } + } + + return null; + } + + @Transactional + Session authenticate(String username, String password) throws InvalidCredentialsException { + RegisteredUser user = RegisteredUser.find("username", username).firstResult(); + + if (user == null) { + throw new InvalidCredentialsException(false); + } + + if (!argon2.verify(user.password, password)) { + throw new InvalidCredentialsException(true); + } + + return Session.createForUser(user); + } + + public static class InvalidCredentialsException extends Exception { + public final boolean password; + + public InvalidCredentialsException(boolean password) { + this.password = password; + } + } + + public static class UsernameExistsException extends Exception {} +} diff --git a/src/main/java/eu/m724/talkpages/auth/MyHttpAuthenticationMechanism.java b/src/main/java/eu/m724/talkpages/auth/MyHttpAuthenticationMechanism.java new file mode 100644 index 0000000..8d9dfb9 --- /dev/null +++ b/src/main/java/eu/m724/talkpages/auth/MyHttpAuthenticationMechanism.java @@ -0,0 +1,65 @@ +package eu.m724.talkpages.auth; + +import eu.m724.talkpages.orm.entity.Session; +import eu.m724.talkpages.orm.entity.RegisteredUser; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.infrastructure.Infrastructure; +import io.vertx.core.http.Cookie; +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import jakarta.inject.Inject; + +@Alternative +@Priority(1) +@ApplicationScoped +public class MyHttpAuthenticationMechanism implements HttpAuthenticationMechanism { + @Inject + AuthService authService; + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + + return Uni.createFrom().item(() -> { + Cookie cookie = context.request().getCookie("session-token"); + + if (cookie == null) + return (SecurityIdentity) QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + + String sessionToken = context.request().getCookie("session-token").getValue(); + Session session = authService.validateSessionToken(sessionToken); + + if (session != null) { + RegisteredUser user = session.user; + + QuarkusSecurityIdentity identity = QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(user.username)) + .addRoles(user.roles) + .addAttribute("session", session) + .build(); + + return (SecurityIdentity) identity; + } + + return (SecurityIdentity) QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + }).runSubscriptionOn(Infrastructure.getDefaultWorkerPool()); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().nullItem(); + } + + @Override + public Uni sendChallenge(RoutingContext context) { + // Always return false to indicate that no challenge was sent + return Uni.createFrom().item(false); + } +} diff --git a/src/main/java/eu/m724/talkpages/orm/entity/Account.java b/src/main/java/eu/m724/talkpages/orm/entity/Account.java index 53afec9..fc5cd11 100644 --- a/src/main/java/eu/m724/talkpages/orm/entity/Account.java +++ b/src/main/java/eu/m724/talkpages/orm/entity/Account.java @@ -1,10 +1,7 @@ package eu.m724.talkpages.orm.entity; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; @@ -28,4 +25,7 @@ public class Account extends PanacheEntity { */ @OneToMany(cascade = CascadeType.ALL, orphanRemoval = false) public List revisions = new ArrayList<>(); + + @OneToOne + public RegisteredUser user; } diff --git a/src/main/java/eu/m724/talkpages/orm/entity/RegisteredUser.java b/src/main/java/eu/m724/talkpages/orm/entity/RegisteredUser.java new file mode 100644 index 0000000..99add3f --- /dev/null +++ b/src/main/java/eu/m724/talkpages/orm/entity/RegisteredUser.java @@ -0,0 +1,42 @@ +package eu.m724.talkpages.orm.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.*; +import jakarta.transaction.Transactional; + +import java.util.HashSet; +import java.util.Set; + +@Entity +public class RegisteredUser extends PanacheEntityBase { + @Id + public String username; + + public String password; + + public Set roles; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = false) + public Set sessions = new HashSet<>(); + + @OneToOne + public Account account; + + /** + * Adds a new user to the database + * @param username the username + * @param password HASHED password + * @param roles roles + */ + public static RegisteredUser add(String username, String password, String... roles) { + RegisteredUser user = new RegisteredUser(); + + user.username = username; + user.password = password; + user.roles = Set.of(roles); + user.persistAndFlush(); + + return user; + } + +} diff --git a/src/main/java/eu/m724/talkpages/orm/entity/Session.java b/src/main/java/eu/m724/talkpages/orm/entity/Session.java new file mode 100644 index 0000000..e821e46 --- /dev/null +++ b/src/main/java/eu/m724/talkpages/orm/entity/Session.java @@ -0,0 +1,43 @@ +package eu.m724.talkpages.orm.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.transaction.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; + +@Entity +public class Session extends PanacheEntity { + @ManyToOne(cascade = CascadeType.ALL) + public RegisteredUser user; + + public String token; // TODO make this generated + + public LocalDateTime expires; + + /** + * create a {@link Session} for a {@link RegisteredUser}
+ * randomly generated token, expiring in 7 days + * + * @param user the user + * @return the created session + */ + public static Session createForUser(RegisteredUser user) { + byte[] tokenBytes = new byte[64]; + new SecureRandom().nextBytes(tokenBytes); + String token = Base64.getEncoder().encodeToString(tokenBytes); + + Session session = new Session(); + session.user = user; + session.token = token; + session.expires = LocalDateTime.now().plusDays(7); + user.sessions.add(session); + + session.persist(); + return session; + } +} diff --git a/src/main/java/eu/m724/talkpages/template/AuthExtension.java b/src/main/java/eu/m724/talkpages/template/AuthExtension.java new file mode 100644 index 0000000..e01e53d --- /dev/null +++ b/src/main/java/eu/m724/talkpages/template/AuthExtension.java @@ -0,0 +1,20 @@ +package eu.m724.talkpages.template; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.qute.TemplateExtension; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.quarkus.security.identity.SecurityIdentity; + +import java.util.Set; + +@TemplateExtension(namespace = "user") +public class AuthExtension { + + public static String name() { + try (InstanceHandle handle = Arc.container().instance(CurrentIdentityAssociation.class)) { + SecurityIdentity identity = handle.get().getIdentity(); + return identity.isAnonymous() ? null : identity.getPrincipal().getName(); + } + } +} diff --git a/src/main/java/eu/m724/talkpages/TemplateExtensions.java b/src/main/java/eu/m724/talkpages/template/TemplateExtensions.java similarity index 95% rename from src/main/java/eu/m724/talkpages/TemplateExtensions.java rename to src/main/java/eu/m724/talkpages/template/TemplateExtensions.java index 8133c66..a77da61 100644 --- a/src/main/java/eu/m724/talkpages/TemplateExtensions.java +++ b/src/main/java/eu/m724/talkpages/template/TemplateExtensions.java @@ -1,4 +1,4 @@ -package eu.m724.talkpages; +package eu.m724.talkpages.template; import io.quarkus.qute.TemplateExtension; import jakarta.enterprise.context.ApplicationScoped; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 36dcfc0..d5d713d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,6 +2,8 @@ talkpages.homePage=/ talkpages.systemUser.name=System talkpages.createExamplePage=true +quarkus.http.auth.basic=true + quarkus.hibernate-orm.database.generation=drop-and-create quarkus.datasource.db-kind=h2 diff --git a/src/main/resources/templates/AuthResource/auth.html b/src/main/resources/templates/AuthResource/auth.html new file mode 100644 index 0000000..aeadc35 --- /dev/null +++ b/src/main/resources/templates/AuthResource/auth.html @@ -0,0 +1,18 @@ +

Login

+ +

To log in, submit your credentials.

+

To register, submit desired credentials.

+ +{#if message != null} +

{message}

+{/if} + +
+ + +
+ + +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/AuthResource/authenticated.html b/src/main/resources/templates/AuthResource/authenticated.html new file mode 100644 index 0000000..38e56bf --- /dev/null +++ b/src/main/resources/templates/AuthResource/authenticated.html @@ -0,0 +1,2 @@ +

Logged in as {username}

+

Log out

\ No newline at end of file diff --git a/src/main/resources/templates/AuthResource/register.html b/src/main/resources/templates/AuthResource/register.html new file mode 100644 index 0000000..8a371c2 --- /dev/null +++ b/src/main/resources/templates/AuthResource/register.html @@ -0,0 +1,13 @@ +

Registering as {username}

+ +{#if message != null} +

{message}

+{/if} + +
+ + + +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/IndexResource/index.html b/src/main/resources/templates/IndexResource/index.html index 0b84d85..f1ec0f5 100644 --- a/src/main/resources/templates/IndexResource/index.html +++ b/src/main/resources/templates/IndexResource/index.html @@ -5,6 +5,11 @@ Running TalkPages version {config:["quarkus.application.version"]} \ No newline at end of file