This commit is contained in:
Minecon724 2024-09-12 16:13:56 +02:00
parent 5c87e3d86d
commit 20d0e142c0
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
14 changed files with 455 additions and 5 deletions

11
pom.xml
View file

@ -62,6 +62,17 @@
<artifactId>quarkus-junit5</artifactId> <artifactId>quarkus-junit5</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- custom auth -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>
<dependency>
<groupId>de.mkammerer</groupId>
<artifactId>argon2-jvm-nolibs</artifactId>
<version>2.11</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View file

@ -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<String, String> 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<String, String> 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();
}
}
}

View file

@ -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 {}
}

View file

@ -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<SecurityIdentity> 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<ChallengeData> getChallenge(RoutingContext context) {
return Uni.createFrom().nullItem();
}
@Override
public Uni<Boolean> sendChallenge(RoutingContext context) {
// Always return false to indicate that no challenge was sent
return Uni.createFrom().item(false);
}
}

View file

@ -1,10 +1,7 @@
package eu.m724.talkpages.orm.entity; package eu.m724.talkpages.orm.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity; import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.CascadeType; import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -28,4 +25,7 @@ public class Account extends PanacheEntity {
*/ */
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = false) @OneToMany(cascade = CascadeType.ALL, orphanRemoval = false)
public List<PageRevision> revisions = new ArrayList<>(); public List<PageRevision> revisions = new ArrayList<>();
@OneToOne
public RegisteredUser user;
} }

View file

@ -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<String> roles;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = false)
public Set<Session> 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;
}
}

View file

@ -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}<br>
* 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;
}
}

View file

@ -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<CurrentIdentityAssociation> handle = Arc.container().instance(CurrentIdentityAssociation.class)) {
SecurityIdentity identity = handle.get().getIdentity();
return identity.isAnonymous() ? null : identity.getPrincipal().getName();
}
}
}

View file

@ -1,4 +1,4 @@
package eu.m724.talkpages; package eu.m724.talkpages.template;
import io.quarkus.qute.TemplateExtension; import io.quarkus.qute.TemplateExtension;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;

View file

@ -2,6 +2,8 @@ talkpages.homePage=/
talkpages.systemUser.name=System talkpages.systemUser.name=System
talkpages.createExamplePage=true talkpages.createExamplePage=true
quarkus.http.auth.basic=true
quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.datasource.db-kind=h2 quarkus.datasource.db-kind=h2

View file

@ -0,0 +1,18 @@
<h1>Login</h1>
<p>To log in, submit your credentials.</p>
<p>To register, submit desired credentials.</p>
{#if message != null}
<h3>{message}</h3>
{/if}
<form method="post" action="/auth/action/login">
<label for="username">Username</label>
<input type="text" id="username" name="username">
<br>
<label for="password">Password</label>
<input type="password" id="password" name="password">
<br>
<input type="submit">
</form>

View file

@ -0,0 +1,2 @@
<p>Logged in as {username}</p>
<p><a href="/auth/logout">Log out</a></p>

View file

@ -0,0 +1,13 @@
<p>Registering as <strong>{username}</strong></p>
{#if message != null}
<h3>{message}</h3>
{/if}
<form method="post" action="/auth/action/register">
<input type="hidden" name="username" value="{username}">
<label for="password">Confirm password</label>
<input type="password" id="password" name="password">
<br>
<input type="submit" value="Register">
</form>

View file

@ -5,6 +5,11 @@
<ul> <ul>
<li><a href="/edit">Create a page</a></li> <li><a href="/edit">Create a page</a></li>
{#if user:name == null}
<li><a href="/auth">Login or register</a></li>
{#else}
<li><a href="/auth/logout">Logout ({user:name})</a></li>
{/if}
</ul> </ul>
<small>Running <a href="/page/TalkPages">TalkPages</a> version {config:["quarkus.application.version"]}</small> <small>Running <a href="/page/TalkPages">TalkPages</a> version {config:["quarkus.application.version"]}</small>