diff --git a/.gitignore b/.gitignore index 332d4ac..a90b8b9 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,5 @@ addons logs libraries -.env +.env.repo diff --git a/README.md b/README.md index feff86b..36b2533 100644 --- a/README.md +++ b/README.md @@ -10,5 +10,5 @@ | CraftsNet Version | Compatible | |-------------------|------------| -| >= 3.4.1-SNAPSHOT | ✅ | -| <= 3.4.0-SNAPSHOT | ❌ | +| >= 3.7.0 | ✅ | +| <= 3.7.0 | ❌ | diff --git a/build.gradle b/build.gradle index 3fc669b..9d7407a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,7 @@ plugins { id 'java' id 'maven-publish' -} - -def env = new Properties() -file(".env").withInputStream { - env.load(it) + id "de.craftsblock.gradle.publish" version "0.0.20" } java { @@ -29,27 +25,15 @@ dependencies { // CraftsBlock dependencies ---------------------------------------------------------------------------------------- // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/bom - implementation platform("de.craftsblock.craftscore:bom:3.8.12") - - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/event - implementation "de.craftsblock.craftscore:event" - - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/json - implementation "de.craftsblock.craftscore:json" - - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/sql - implementation "de.craftsblock.craftscore:sql" - - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/utils - implementation "de.craftsblock.craftscore:utils" + implementation platform("de.craftsblock.craftscore:bom:3.8.13-pre8") // https://repo.craftsblock.de/#/releases/de/craftsblock/craftsnet - implementation "de.craftsblock:craftsnet:3.5.6-pre6" + implementation "de.craftsblock:craftsnet:3.7.0-pre8" // Third party dependencies ---------------------------------------------------------------------------------------- // https://mvnrepository.com/artifact/org.springframework.security/spring-security-crypto - implementation 'org.springframework.security:spring-security-crypto:6.5.0' + implementation 'org.springframework.security:spring-security-crypto:7.0.2' // https://mvnrepository.com/artifact/org.jetbrains/annotations implementation 'org.jetbrains:annotations:26.0.2' @@ -73,45 +57,32 @@ jar { } } -publishing { - publications { - normal(MavenPublication) { - artifactId "security" - from components.java - pom { - name = 'Security' - description = 'Protect your CraftsNet Restful API with an token-based access control system' - - scm { - url = 'https://github.com/CraftsBlock/CraftsNet-Security' - connection = 'scm:git:git://github.com/CraftsBlock/CraftsNet-Security.git' - developerConnection = 'scm:git:git@github.com:CraftsBlock/CraftsNet-Security.git' - } - - issueManagement { - system = 'github' - url = 'https://github.com/CraftsBlock/CraftsNet-Security/issues' - } - - licenses { - license { - name = 'GNU General Public License v3.0' - url = 'https://github.com/CraftsBlock/CraftsNet-Security/blob/master/LICENSE' - } - } - } +craftsPublish { + artifactId = "security" + name = "CraftsNet" + + component = project.components.java + + pom { + name = "Security" + description = "Protect your CraftsNet API with easy drop in security features." + + scm { + url = 'https://github.com/CraftsBlock/CraftsNet-Security' + connection = 'scm:git:git://github.com/CraftsBlock/CraftsNet-Security.git' + developerConnection = 'scm:git:git@github.com:CraftsBlock/CraftsNet-Security.git' } - } - repositories { - maven { - url('https://repo.craftsblock.de/experimental') - authentication { - basic(BasicAuthentication) - } - credentials { - username = env["username"] - password = env["password"] + + issueManagement { + system = 'github' + url = 'https://github.com/CraftsBlock/CraftsNet-Security/issues' + } + + licenses { + license { + name = 'GNU General Public License v3.0' + url = 'https://github.com/CraftsBlock/CraftsNet-Security/blob/master/LICENSE' } } } -} \ No newline at end of file +} diff --git a/settings.gradle b/settings.gradle index b9e61b5..b1a16a2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,11 @@ +pluginManagement { + repositories { + maven { + url = uri("https://repo.craftsblock.de/releases") + } + gradlePluginPortal() + } +} + rootProject.name = 'Security' diff --git a/src/main/java/de/craftsblock/cnet/modules/security/AddonEntrypoint.java b/src/main/java/de/craftsblock/cnet/modules/security/AddonEntrypoint.java deleted file mode 100644 index 17b2ab1..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/AddonEntrypoint.java +++ /dev/null @@ -1,56 +0,0 @@ -package de.craftsblock.cnet.modules.security; - -import de.craftsblock.cnet.modules.security.auth.AuthChainManager; -import de.craftsblock.cnet.modules.security.auth.chains.SimpleAuthChain; -import de.craftsblock.cnet.modules.security.auth.token.TokenManager; -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitManager; -import de.craftsblock.craftsnet.addon.Addon; -import de.craftsblock.craftsnet.addon.meta.annotations.Meta; - -/** - * The AccessControllerAddon class extends the base {@link Addon} class to provide specific functionality - * for the access controller module. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.2 - * @since 1.0.0-SNAPSHOT - */ -@Meta(name = "CNetSecurity") -public class AddonEntrypoint extends Addon { - - /** - * Called when the addon is loaded. - */ - @Override - public void onLoad() { - // Set the instance - CNetSecurity.register(this); - CNetSecurity.register(this.logger()); - - // Set environment variables - CNetSecurity.register(new AuthChainManager()); - CNetSecurity.register(new TokenManager()); - CNetSecurity.register(new RateLimitManager()); - - // Create a new default auth chain - AuthChainManager chains = CNetSecurity.getAuthChainManager(); - if (chains != null) { - SimpleAuthChain chain = new SimpleAuthChain(); - chains.add(chain); - CNetSecurity.register(chain); - } - } - - /** - * Called when the addon is disabled. - */ - @Override - public void onDisable() { - CNetSecurity.getTokenManager().save(); - - // Unset the instance - CNetSecurity.unregister(this); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/CNetSecurity.java b/src/main/java/de/craftsblock/cnet/modules/security/CNetSecurity.java deleted file mode 100644 index 939f812..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/CNetSecurity.java +++ /dev/null @@ -1,148 +0,0 @@ -package de.craftsblock.cnet.modules.security; - -import de.craftsblock.cnet.modules.security.auth.AuthChainManager; -import de.craftsblock.cnet.modules.security.auth.chains.SimpleAuthChain; -import de.craftsblock.cnet.modules.security.auth.token.TokenManager; -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitManager; -import de.craftsblock.craftscore.event.Event; -import de.craftsblock.craftsnet.logging.Logger; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.InvocationTargetException; -import java.util.concurrent.ConcurrentHashMap; - -/** - * The AccessController class provides functionality for managing various variables used by the access control addon. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @since 1.0.0-SNAPSHOT - */ -public class CNetSecurity { - - private static final ConcurrentHashMap, Object> instances = new ConcurrentHashMap<>(); - - /** - * Registers a new object instance in the internal map. The instance is stored using its class type as the key. - * This method is intended to be used internally to register object instances during initialization. - * - * @param instance The object instance to be registered. - */ - @ApiStatus.Internal - protected static void register(Object instance) { - instances.put(instance.getClass(), instance); - } - - /** - * Unregisters an object instance from the internal map. - * - * @param instance The object instance to be unregistered. - */ - @ApiStatus.Internal - protected static void unregister(Object instance) { - unregister(instance.getClass()); - } - - /** - * Unregisters an object type from the internal map. - * - * @param instance The object type to be unregistered. - */ - @ApiStatus.Internal - protected static void unregister(Class instance) { - instances.remove(instance); - } - - /** - * Retrieves a registered manager instance by its class type. - * If the requested manager has not been registered, {@code null} is returned. - * - * @param The type of the instance. - * @param type class type of the instance to be retrieved. - * @return The manager instance, if found. - */ - @ApiStatus.Internal - protected static @Nullable T get(Class type) { - if (!instances.containsKey(type)) return null; - return type.cast(instances.get(type)); - } - - /** - * Retrieves the currently set {@link AddonEntrypoint} instance. - * - * @return The current {@link AddonEntrypoint}, or null if none has been set. - */ - public static AddonEntrypoint getAddonEntrypoint() { - return get(AddonEntrypoint.class); - } - - /** - * Retrieves the default {@link SimpleAuthChain} instance. - * - * @return The {@link SimpleAuthChain} instance. - * @throws IllegalStateException If no default instance of {@link SimpleAuthChain} is registered. - */ - public static SimpleAuthChain getDefaultAuthChain() { - return get(SimpleAuthChain.class); - } - - /** - * Retrieves the {@link TokenManager} instance that manages authentication tokens. - * - * @return The {@link TokenManager} instance. - * @throws IllegalStateException If no instance of {@link TokenManager} is registered. - */ - public static TokenManager getTokenManager() { - return get(TokenManager.class); - } - - /** - * Retrieves the {@link AuthChainManager} instance that manages authentication chains. - * - * @return The {@link AuthChainManager} instance. - * @throws IllegalStateException If no instance of {@link AuthChainManager} is registered. - */ - public static AuthChainManager getAuthChainManager() { - return get(AuthChainManager.class); - } - - /** - * Retrieves the {@link RateLimitManager} instance that manages rate limits. - * - * @return The {@link RateLimitManager} instance. - * @throws IllegalStateException If no instance of {@link RateLimitManager} is registered. - */ - public static RateLimitManager getRateLimitManager() { - return get(RateLimitManager.class); - } - - /** - * Retrieves the {@link Logger} instance. - * - * @return The {@link Logger} instance. - * @throws IllegalStateException If no instance of {@link Logger} is registered. - */ - @ApiStatus.Internal - public static Logger getLogger() { - return get(Logger.class); - } - - /** - * Dispatches the given event to the registered listeners via the listener registry. - * This method ensures that the AccessController addon is active before proceeding. - * - * @param event The event to be dispatched to the listeners. - * @throws IllegalStateException If the AccessController addon is not active or not set. - * @throws InvocationTargetException If an error occurs while invoking a listener method. - * @throws IllegalAccessException If a listener method cannot be accessed. - */ - @ApiStatus.Internal - public static void callEvent(Event event) throws InvocationTargetException, IllegalAccessException { - if (getAddonEntrypoint() == null) - throw new IllegalStateException("The addon instance has not been set! Is the CNetSecurity addon active?"); - getAddonEntrypoint().craftsNet().listenerRegistry().call(event); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java b/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java new file mode 100644 index 0000000..d5325ac --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java @@ -0,0 +1,71 @@ +package de.craftsblock.cnet.modules.security; + +import de.craftsblock.cnet.modules.security.auth.AuthChain; +import de.craftsblock.cnet.modules.security.token.TokenManager; +import de.craftsblock.cnet.modules.security.token.adapter.HttpTokenAuthAdapter; +import de.craftsblock.cnet.modules.security.token.adapter.WebSocketTokenAuthAdapter; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.craftsnet.CraftsNet; +import de.craftsblock.craftsnet.addon.Addon; +import de.craftsblock.craftsnet.addon.meta.annotations.Meta; +import de.craftsblock.craftsnet.builder.ActivateType; + +import java.io.IOException; + +@Meta(name = "CNetSecurity") +public class CraftsNetSecurity extends Addon { + + private AuthChain authChain; + + private TokenManager tokenManager; + private TokenStoreDriver tokenStoreDriver; + + public static void main(String[] args) throws IOException { + CraftsNet.create(CraftsNetSecurity.class) + .withWebServer(ActivateType.ENABLED) + .withWebSocketServer(ActivateType.ENABLED) + .withFileLogger(ActivateType.DISABLED) + .withDebug(true) + .build(); + } + + @Override + public void onLoad() { + this.authChain = new AuthChain(); + this.authChain.append(new HttpTokenAuthAdapter(null)); + this.authChain.append(new WebSocketTokenAuthAdapter()); + this.tokenManager = new TokenManager(); + } + + @Override + public void onEnable() { + super.onEnable(); + } + + @Override + public void onDisable() { + super.onDisable(); + this.tokenStoreDriver.close(); + } + + public static AuthChain getAuthChain() { + return getInstance().authChain; + } + + public static TokenManager getTokenManager() { + return getInstance().tokenManager; + } + + public static void setTokenStoreDriver(TokenStoreDriver tokenStoreDriver) { + getInstance().tokenStoreDriver = tokenStoreDriver; + } + + public static TokenStoreDriver getTokenStoreDriver() { + return getInstance().tokenStoreDriver; + } + + public static CraftsNetSecurity getInstance() { + return getAddon(CraftsNetSecurity.class); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthAdapter.java deleted file mode 100644 index 342be4d..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth; - -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.Request; - -/** - * The {@link AuthAdapter} interface defines the contract for implementing custom authentication mechanisms. - * Classes implementing this interface provide the logic for authenticating requests and handling - * authentication success or failure. - * - *

It includes a method for performing authentication on a given {@link Request} and a default method - * for handling authentication failure by setting the appropriate state in an {@link AuthResult} object.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public interface AuthAdapter { - - /** - * Authenticates the incoming request. Implementations of this method should define the logic for - * checking whether the request is authorized or not. - * - * @param result The {@link AuthResult} object where the outcome of the authentication process is stored. - * @param exchange The {@link Exchange} object representing the HTTP request. - */ - void authenticate(AuthResult result, Exchange exchange); - - /** - * Marks the authentication process as failed. This method is used to set the failure state - * in the {@link AuthResult} object, including the reason for the failure. - * - * @param result The {@link AuthResult} object that stores the result of the authentication process. - * @param reason A string explaining why the authentication failed. - */ - default void failAuth(AuthResult result, String reason) { - result.cancel(reason); - } - - /** - * Marks the authentication process as failed. This method is used to set the failure state - * in the {@link AuthResult} object, including the reason for the failure. - * - * @param code The response http code. - * @param result The {@link AuthResult} object that stores the result of the authentication process. - * @param reason A string explaining why the authentication failed. - */ - default void failAuth(AuthResult result, int code, String reason) { - result.cancel(code, reason); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java new file mode 100644 index 0000000..356a723 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java @@ -0,0 +1,130 @@ +package de.craftsblock.cnet.modules.security.auth; + +import de.craftsblock.cnet.modules.security.auth.adapter.AuthAdapter; +import de.craftsblock.cnet.modules.security.auth.exclusion.Exclusions; +import de.craftsblock.craftsnet.api.BaseExchange; +import de.craftsblock.craftsnet.api.http.Exchange; +import de.craftsblock.craftsnet.api.http.Request; +import de.craftsblock.craftsnet.api.utils.Scheme; +import de.craftsblock.craftsnet.api.websocket.SocketExchange; +import de.craftsblock.craftsnet.api.websocket.WebSocketClient; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Queue; +import java.util.concurrent.LinkedBlockingQueue; + +public class AuthChain { + + private final EnumMap> adapters = new EnumMap<>(Scheme.class); + + private final Exclusions exclusions = new Exclusions(); + + public void append(AuthAdapter adapter) { + computeApplicableAuthAdapterQueues(adapter).forEach(authAdapters -> { + synchronized (authAdapters) { + if (authAdapters.contains(adapter)) { + return; + } + + authAdapters.offer(adapter); + } + }); + } + + public void remove(AuthAdapter adapter) { + computeApplicableAuthAdapterQueues(adapter).forEach(authAdapters -> { + synchronized (authAdapters) { + if (!authAdapters.contains(adapter)) { + return; + } + + authAdapters.remove(adapter); + } + }); + } + + public AuthResult authenticate(BaseExchange exchange) { + if (exchange instanceof Exchange http) { + return authenticateHttp(http); + } else if (exchange instanceof SocketExchange webSocket) { + return authenticateWebSocket(webSocket); + } + + throw new IllegalStateException("Unexpected exchange: " + exchange.getClass().getName()); + } + + private AuthResult authenticateHttp(Exchange exchange) { + final Request request = exchange.request(); + if (this.exclusions.isHttpExcluded(request.getUrl(), request.getHttpMethod())) { + return AuthResult.skip(); + } + + Queue httpAdapters = this.computeAuthAdapterQueue(Scheme.HTTP); + synchronized (httpAdapters) { + for (AuthAdapter adapter : httpAdapters) { + if (!(adapter instanceof AuthAdapter.Http httpAuthAdapter)) { + throw new IllegalStateException("Found a non http auth adapter " + + adapter.getClass().getName() + " in the http adapter list!"); + } + + AuthResult result = httpAuthAdapter.authenticate(exchange); + if (result.isFailure()) { + return result; + } + } + } + + return AuthResult.ok(); + } + + private AuthResult authenticateWebSocket(SocketExchange exchange) { + final WebSocketClient client = exchange.client(); + if (this.exclusions.isWebSocketExcluded(client.getPath())) { + return AuthResult.skip(); + } + + Queue webSocketAdapters = this.computeAuthAdapterQueue(Scheme.WS); + synchronized (webSocketAdapters) { + for (AuthAdapter adapter : webSocketAdapters) { + if (!(adapter instanceof AuthAdapter.WebSocket webSocketAuthAdapter)) { + throw new IllegalStateException("Found a non web socket auth adapter " + + adapter.getClass().getName() + " in the web socket adapter list!"); + } + + AuthResult result = webSocketAuthAdapter.authenticate(exchange); + if (result.isFailure()) { + return result; + } + } + } + + return AuthResult.ok(); + } + + private Collection> computeApplicableAuthAdapterQueues(AuthAdapter adapter) { + Collection> authAdapters = new ArrayList<>(); + + if (adapter instanceof AuthAdapter.Http) { + authAdapters.add((computeAuthAdapterQueue(Scheme.HTTP))); + } + + if (adapter instanceof AuthAdapter.WebSocket) { + authAdapters.add(computeAuthAdapterQueue(Scheme.WS)); + } + + return authAdapters; + } + + private Queue computeAuthAdapterQueue(Scheme scheme) { + synchronized (adapters) { + return adapters.computeIfAbsent(scheme, s -> new LinkedBlockingQueue<>()); + } + } + + public Exclusions getExclusions() { + return exclusions; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChainManager.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChainManager.java deleted file mode 100644 index d7d5d13..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChainManager.java +++ /dev/null @@ -1,20 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth; - -import de.craftsblock.cnet.modules.security.auth.chains.AuthChain; -import de.craftsblock.cnet.modules.security.utils.Manager; - -import java.util.concurrent.ConcurrentLinkedQueue; - -/** - * The {@code AuthChainManager} class is a manager for handling multiple {@link AuthChain} instances. - * It extends {@link ConcurrentLinkedQueue} to provide a thread-safe way to manage and manipulate - * authentication chains. Each {@link AuthChain} represents a chain of authentication adapters. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public final class AuthChainManager extends ConcurrentLinkedQueue implements Manager { - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java index bc95740..8a3cdf1 100644 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java @@ -1,89 +1,75 @@ package de.craftsblock.cnet.modules.security.auth; -/** - * The {@link AuthResult} class represents the outcome of an authentication process. - * It provides information about whether the authentication was successful or cancelled, - * and if cancelled, it holds a reason for the cancellation. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @since 1.0.0-SNAPSHOT - */ public class AuthResult { - private boolean success = true; - private int code = 401; - private String cancelReason = ""; + private final Type type; + private final int code; + private final String reason; - /** - * Creates a new {@link AuthResult} instance with a default success state of {@code true}. - */ - public AuthResult() { + private AuthResult(Type type) { + this(type, null); } - /** - * Cancels the authentication, setting the success state to {@code false}. - */ - public void cancel() { - this.success = false; + private AuthResult(Type type, String reason) { + this(type, reason, 400); } - /** - * Cancels the authentication with a specific reason. - * - * @param reason The reason for cancellation, providing context for the failure. - */ - public void cancel(String reason) { - this.cancel(403, reason); - } - - /** - * Cancels the authentication with a specific reason. - * - * @param reason The reason for cancellation, providing context for the failure. - */ - public void cancel(int code, String reason) { - this.cancel(); - this.cancelReason = reason; + private AuthResult(Type type, String reason, int code) { + this.type = type; this.code = code; + this.reason = reason; } - /** - * Returns whether the authentication was successful or not. - * - * @return {@code true} if the authentication was successful, {@code false} otherwise. - */ - public boolean isSuccess() { - return success; + public boolean isOk() { + return this.type.equals(Type.OK); } - /** - * Returns whether the authentication process was cancelled. - * - * @return {@code true} if the process was cancelled, {@code false} otherwise. - */ - public boolean isCancelled() { - return !success; + public boolean isSkip() { + return this.type.equals(Type.SKIP); } - /** - * Returns the reason for cancelling the authentication process. - * If the authentication was successful, this will return an empty string. - * - * @return The cancellation reason or an empty string if authentication was successful. - */ - public String getCancelReason() { - return cancelReason; + public boolean isFailure() { + return this.type.equals(Type.FAILURE); } - /** - * Returns the http status code for cancelling the authentication process. - * - * @return The http status code. - */ public int getCode() { return code; } + public String getReason() { + return reason; + } + + public Type getType() { + return type; + } + + public static AuthResult ok() { + return new AuthResult(Type.OK); + } + + public static AuthResult skip() { + return new AuthResult(Type.SKIP); + } + + public static AuthResult failure() { + return failure(null); + } + + public static AuthResult failure(String reason) { + return new AuthResult(Type.FAILURE, reason); + } + + public static AuthResult failure(String reason, int code) { + return new AuthResult(Type.FAILURE, reason, code); + } + + public enum Type { + + OK, + SKIP, + FAILURE + + } + } diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/adapter/AuthAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/adapter/AuthAdapter.java new file mode 100644 index 0000000..a71111f --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/adapter/AuthAdapter.java @@ -0,0 +1,21 @@ +package de.craftsblock.cnet.modules.security.auth.adapter; + +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.craftsnet.api.http.Exchange; +import de.craftsblock.craftsnet.api.websocket.SocketExchange; + +public sealed interface AuthAdapter permits AuthAdapter.Http, AuthAdapter.WebSocket { + + non-sealed interface Http extends AuthAdapter { + + AuthResult authenticate(Exchange exchange); + + } + + non-sealed interface WebSocket extends AuthAdapter { + + AuthResult authenticate(SocketExchange exchange); + + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/AuthChain.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/AuthChain.java deleted file mode 100644 index 5871e41..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/AuthChain.java +++ /dev/null @@ -1,58 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.chains; - -import de.craftsblock.cnet.modules.security.auth.AuthAdapter; -import de.craftsblock.cnet.modules.security.auth.AuthResult; -import de.craftsblock.craftsnet.api.http.Exchange; - -/** - * The {@link AuthChain} class represents an authentication chain that manages multiple - * {@link AuthAdapter} instances. It provides methods to authenticate requests by passing - * them through the chain of adapters and managing the adapters dynamically. - * - *

This class is designed to be extended for custom implementations of authentication chains, - * where multiple authentication strategies (adapters) can be used in sequence.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public abstract class AuthChain { - - /** - * Authenticates the provided {@link Exchange} by passing it through the chain of registered - * {@link AuthAdapter} instances. Each adapter in the chain is responsible for determining - * whether the request is authorized or not. - * - * @param exchange The {@link Exchange} object representing the incoming HTTP request. - * @return The {@link AuthResult} object that contains the result of the authentication process. - */ - public abstract AuthResult authenticate(Exchange exchange); - - /** - * Appends a new {@link AuthAdapter} to the authentication chain. The adapter will be used - * during future authentication attempts. - * - * @param adapter The {@link AuthAdapter} to be added to the authentication chain. - * @return The instance of {@link AuthChain} used for chain method calls. - */ - public abstract AuthChain append(AuthAdapter adapter); - - /** - * Removes a specific {@link AuthAdapter} from the authentication chain. - * - * @param adapter The {@link AuthAdapter} to be removed from the authentication chain. - * @return The instance of {@link AuthChain} used for chain method calls. - */ - public abstract AuthChain remove(AuthAdapter adapter); - - /** - * Removes all {@link AuthAdapter} instances of the specified type from the authentication chain. - * This can be used to clear all adapters of a certain type (e.g., all token-based authenticators). - * - * @param adapter The class type of {@link AuthAdapter} to be removed. - * @return The instance of {@link AuthChain} used for chain method calls. - */ - public abstract AuthChain removeAll(Class adapter); - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/SimpleAuthChain.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/SimpleAuthChain.java deleted file mode 100644 index 622df5c..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/chains/SimpleAuthChain.java +++ /dev/null @@ -1,208 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.chains; - -import de.craftsblock.cnet.modules.security.auth.AuthAdapter; -import de.craftsblock.cnet.modules.security.auth.AuthResult; -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.HttpMethod; -import de.craftsblock.craftsnet.api.http.Request; - -import java.util.*; -import java.util.concurrent.ConcurrentLinkedQueue; - -/** - * The {@link SimpleAuthChain} class is a concrete implementation of the {@link AuthChain} class, - * using a simple queue-based approach to handle multiple {@link AuthAdapter} instances in sequence. - * It processes each authentication adapter in the order they were added. - * - *

Adapters are executed in the order they were appended to the chain, and the chain stops - * processing if an authentication result is cancelled (i.e., if an adapter denies access).

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.1.0 - * @since 1.0.0-SNAPSHOT - */ -public class SimpleAuthChain extends AuthChain { - - private final ConcurrentLinkedQueue adapters = new ConcurrentLinkedQueue<>(); - private final List exclusions = new ArrayList<>(); - - /** - * Authenticates the provided {@link Exchange} by passing it through the chain of - * registered {@link AuthAdapter} instances. If any adapter in the chain cancels the - * authentication, the process stops. - * - * @param exchange The {@link Exchange} object representing the incoming HTTP request. - * @return The {@link AuthResult} object that contains the result of the authentication process. - */ - @Override - public AuthResult authenticate(final Exchange exchange) { - final Request request = exchange.request(); - final AuthResult result = new AuthResult(); - - if (exclusions.stream().anyMatch(exclusion -> exclusion.isExcluded(request))) - return result; - - // Iterate over each adapter in the chain and authenticate the request. - for (AuthAdapter adapter : adapters) { - adapter.authenticate(result, exchange); - - // Stop processing further adapters if the authentication is cancelled. - if (result.isCancelled()) break; - } - - return result; - } - - /** - * Appends a new {@link AuthAdapter} to the chain. If the adapter is already present, - * it will not be added again. - * - * @param adapter The {@link AuthAdapter} to be appended to the chain. - * @return The instance of {@link SimpleAuthChain} used for chain method calls. - */ - @Override - public SimpleAuthChain append(AuthAdapter adapter) { - if (!adapters.isEmpty() && adapters.contains(adapter)) return this; - adapters.add(adapter); - return this; - } - - /** - * Removes a specific {@link AuthAdapter} from the chain. - * - * @param adapter The {@link AuthAdapter} to be removed from the chain. - * @return The instance of {@link SimpleAuthChain} used for chain method calls. - */ - @Override - public SimpleAuthChain remove(AuthAdapter adapter) { - adapters.remove(adapter); - return this; - } - - /** - * Removes all instances of the specified {@link AuthAdapter} class from the chain. - * - * @param adapter The class type of the {@link AuthAdapter} to be removed. - * @return The instance of {@link SimpleAuthChain} used for chain method calls. - */ - @Override - public SimpleAuthChain removeAll(Class adapter) { - adapters.stream() - .filter(adapter::isInstance) - .forEach(this::remove); - return this; - } - - /** - * Adds an url pattern to be excluded from authentication. - * - * @param pattern A regular expression matching request urls to exclude. - * @return The instance of {@link SimpleAuthChain} used for chain method calls. - */ - public SimpleAuthChain addExclusion(String pattern) { - return addExclusion(pattern, HttpMethod.ALL); - } - - /** - * Adds an url pattern to be excluded from authentication for the specified http methods. - * - * @param pattern A regular expression matching request URLs to exclude. - * @param methods One or more {@link HttpMethod methods} for which the pattern should be excluded. - * @return The instance of {@link SimpleAuthChain} used for chain method calls. - */ - public SimpleAuthChain addExclusion(String pattern, HttpMethod... methods) { - exclusions.add(new Exclusion(pattern, normalizedMethods(methods))); - return this; - } - - /** - * Removes all exclusion entries matching the given url pattern. - *

Any {@link Exclusion} whose pattern equals the provided {@code pattern} will be removed

- * - * @param pattern The regular expression pattern of request URLs to remove from exclusions - * @return The current {@link SimpleAuthChain} instance, to allow method chaining - */ - public SimpleAuthChain removeExclusion(String pattern) { - return removeExclusion(pattern, HttpMethod.ALL); - } - - /** - * Removes exclusion entries matching the given url pattern for the specified http methods. - *

If an {@link Exclusion} with the same pattern exists and any of its methods matches one of - * the provided {@code methods}, that exclusion entry will be removed from the list.

- * - * @param pattern The regular expression pattern of request URLs to remove from exclusions - * @param methods One or more {@link HttpMethod methods} for which the pattern should no longer be excluded - * @return The current {@link SimpleAuthChain} instance, to allow method chaining - */ - public SimpleAuthChain removeExclusion(String pattern, HttpMethod... methods) { - exclusions.removeIf(exclusion -> { - if (!exclusion.pattern().equals(pattern)) return false; - - Collection excludedMethods = Arrays.asList(exclusion.methods()); - return Arrays.stream(methods).anyMatch(excludedMethods::contains); - }); - return this; - } - - /** - * Expands any composite {@link HttpMethod methods} into their - * constituent methods and returns a flat array of real methods. - * - * @param methods One or more {@link HttpMethod}s, possibly composite, to normalize. - * @return An array of individual {@link HttpMethod}s after expansion. - */ - private HttpMethod[] normalizedMethods(HttpMethod... methods) { - Set realMethods = new HashSet<>(); - - for (HttpMethod method : methods) - switch (method) { - case ALL, ALL_RAW -> { - List subMethods = Arrays.stream(method.getMethods()).toList(); - realMethods.addAll(subMethods); - } - default -> realMethods.add(method); - } - - return realMethods.toArray(HttpMethod[]::new); - } - - /** - * Internal record representing a URL pattern exclusion for one or more HTTP methods. - * - * @param pattern A regular expression for matching request URLs. - * @param methods The HTTP methods for which the pattern is excluded. - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see HttpMethod - * @since 1.0.0-SNAPSHOT - */ - private record Exclusion(String pattern, HttpMethod... methods) { - - /** - * Checks whether the given {@link Request} matches this exclusion. - * - * @param request The incoming HTTP request to check. - * @return {@code true} if the request’s URL and method match this exclusion. - */ - boolean isExcluded(Request request) { - return isExcluded(request.getUrl(), request.getHttpMethod()); - } - - /** - * Checks whether the given URL and {@link HttpMethod} match this exclusion. - * - * @param url The request URL to match against the exclusion pattern. - * @param method The HTTP method to check for exclusion. - * @return {@code true} if the URL matches the pattern and the method is in the exclusion list. - */ - boolean isExcluded(String url, HttpMethod method) { - if (!url.matches(pattern)) return false; - return Arrays.asList(methods).contains(method); - } - - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthFailureEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthFailureEvent.java new file mode 100644 index 0000000..f0f1c10 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthFailureEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.auth.event; + +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.craftsnet.api.BaseExchange; + +public final class AuthFailureEvent extends AuthResultEvent { + + public AuthFailureEvent(BaseExchange exchange, AuthResult result) { + super(exchange, result); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthResultEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthResultEvent.java new file mode 100644 index 0000000..427e9bb --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthResultEvent.java @@ -0,0 +1,26 @@ +package de.craftsblock.cnet.modules.security.auth.event; + +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.craftscore.event.Event; +import de.craftsblock.craftsnet.api.BaseExchange; + +public abstract sealed class AuthResultEvent extends Event + permits AuthFailureEvent, AuthSkipEvent, AuthSuccessEvent { + + private final BaseExchange exchange; + private final AuthResult result; + + public AuthResultEvent(BaseExchange exchange, AuthResult result) { + this.exchange = exchange; + this.result = result; + } + + public BaseExchange getExchange() { + return exchange; + } + + public AuthResult getResult() { + return result; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSkipEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSkipEvent.java new file mode 100644 index 0000000..cc01028 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSkipEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.auth.event; + +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.craftsnet.api.BaseExchange; + +public final class AuthSkipEvent extends AuthResultEvent { + + public AuthSkipEvent(BaseExchange exchange, AuthResult result) { + super(exchange, result); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSuccessEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSuccessEvent.java new file mode 100644 index 0000000..0be9d91 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSuccessEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.auth.event; + +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.craftsnet.api.BaseExchange; + +public final class AuthSuccessEvent extends AuthResultEvent { + + public AuthSuccessEvent(BaseExchange exchange, AuthResult result) { + super(exchange, result); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusion.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusion.java new file mode 100644 index 0000000..8aacf19 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusion.java @@ -0,0 +1,13 @@ +package de.craftsblock.cnet.modules.security.auth.exclusion; + +import de.craftsblock.craftsnet.api.utils.Scheme; + +import java.util.regex.Pattern; + +public sealed interface Exclusion permits HttpExclusion, WebSocketExclusion { + + Scheme scheme(); + + Pattern path(); + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java new file mode 100644 index 0000000..0c63358 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java @@ -0,0 +1,110 @@ +package de.craftsblock.cnet.modules.security.auth.exclusion; + +import de.craftsblock.craftsnet.api.http.HttpMethod; +import de.craftsblock.craftsnet.api.utils.Scheme; +import org.intellij.lang.annotations.RegExp; + +import java.util.*; +import java.util.regex.Matcher; + +public final class Exclusions { + + private final Map> exclusions = new EnumMap<>(Scheme.class); + + public Exclusions http(@RegExp String path, HttpMethod... methods) { + Collection httpExclusions = exclusions.computeIfAbsent(Scheme.HTTP, s -> new ArrayList<>()); + + synchronized (httpExclusions) { + for (Exclusion exclusion : httpExclusions) { + if (!(exclusion instanceof HttpExclusion httpExclusion)) { + continue; + } + + if (!exclusion.path().pattern().equals(path)) { + throw new IllegalStateException("Found a non http exclusion " + + exclusion.getClass().getName() + " in the http list!"); + } + + if (httpExclusion.methods().containsAll(Arrays.asList(HttpMethod.normalize(methods)))) { + return this; + } + } + + httpExclusions.add(new HttpExclusion(path, methods)); + } + + return this; + } + + public boolean isHttpExcluded(String path, HttpMethod method) { + Collection httpExclusions = exclusions.get(Scheme.HTTP); + if (httpExclusions == null) { + return false; + } + + synchronized (httpExclusions) { + for (Exclusion exclusion : httpExclusions) { + if (!(exclusion instanceof HttpExclusion httpExclusion)) { + throw new IllegalStateException("Found a non http exclusion " + + exclusion.getClass().getName() + " in the http list!"); + } + + Matcher matcher = exclusion.path().matcher(path); + if (!matcher.matches()) { + continue; + } + + if (httpExclusion.methods().contains(method)) { + return true; + } + } + } + + return false; + } + + public Exclusions webSocket(@RegExp String path) { + Collection webSocketExclusions = exclusions.computeIfAbsent(Scheme.WS, s -> new ArrayList<>()); + + synchronized (webSocketExclusions) { + for (Exclusion exclusion : webSocketExclusions) { + if (!(exclusion instanceof WebSocketExclusion)) { + throw new IllegalStateException("Found a non web socket exclusion " + + exclusion.getClass().getName() + " in the web socket list!"); + } + + if (exclusion.path().pattern().equals(path)) { + return this; + } + } + + webSocketExclusions.add(new WebSocketExclusion(path)); + } + + return this; + } + + public boolean isWebSocketExcluded(String path) { + Collection httpExclusions = exclusions.get(Scheme.WS); + if (httpExclusions == null) { + return false; + } + + synchronized (httpExclusions) { + for (Exclusion exclusion : httpExclusions) { + if (!(exclusion instanceof WebSocketExclusion)) { + throw new IllegalStateException("Found a non web socket exclusion " + + exclusion.getClass().getName() + " in the web socket list!"); + } + + Matcher matcher = exclusion.path().matcher(path); + if (matcher.matches()) { + return true; + } + } + } + + return false; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/HttpExclusion.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/HttpExclusion.java new file mode 100644 index 0000000..7e8e87e --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/HttpExclusion.java @@ -0,0 +1,17 @@ +package de.craftsblock.cnet.modules.security.auth.exclusion; + +import de.craftsblock.craftsnet.api.http.HttpMethod; +import de.craftsblock.craftsnet.api.utils.Scheme; +import org.intellij.lang.annotations.RegExp; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.regex.Pattern; + +public record HttpExclusion(Scheme scheme, Pattern path, HashSet methods) implements Exclusion { + + public HttpExclusion(@RegExp String path, HttpMethod... methods) { + this(Scheme.HTTP, Pattern.compile(path), new HashSet<>(Arrays.asList(HttpMethod.normalize(methods)))); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/WebSocketExclusion.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/WebSocketExclusion.java new file mode 100644 index 0000000..7e8154d --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/WebSocketExclusion.java @@ -0,0 +1,14 @@ +package de.craftsblock.cnet.modules.security.auth.exclusion; + +import de.craftsblock.craftsnet.api.utils.Scheme; +import org.intellij.lang.annotations.RegExp; + +import java.util.regex.Pattern; + +public record WebSocketExclusion(Scheme scheme, Pattern path) implements Exclusion { + + public WebSocketExclusion(@RegExp String path) { + this(Scheme.WS, Pattern.compile(path)); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/AuthListener.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/AuthListener.java new file mode 100644 index 0000000..0fc1555 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/AuthListener.java @@ -0,0 +1,40 @@ +package de.craftsblock.cnet.modules.security.auth.listener; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.cnet.modules.security.auth.event.AuthFailureEvent; +import de.craftsblock.cnet.modules.security.auth.event.AuthSkipEvent; +import de.craftsblock.cnet.modules.security.auth.event.AuthSuccessEvent; +import de.craftsblock.craftscore.event.CancellableEvent; +import de.craftsblock.craftsnet.api.BaseExchange; +import de.craftsblock.craftsnet.events.EventWithCancelReason; + +import java.util.function.BiConsumer; + +sealed interface AuthListener permits PreRequestListener, WebSocketConnectListener { + + CraftsNetSecurity addon(); + + default void authenticate(BaseExchange exchange, CancellableEvent event, T subject, BiConsumer onFailure) { + CraftsNetSecurity addon = this.addon(); + AuthResult result = CraftsNetSecurity.getAuthChain().authenticate(exchange); + + if (!result.isFailure()) { + addon.getListenerRegistry().call( + result.isOk() + ? new AuthSuccessEvent(exchange, result) + : new AuthSkipEvent(exchange, result) + ); + return; + } + + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("AUTH FAILED"); + } + + addon.getListenerRegistry().call(new AuthFailureEvent(exchange, result)); + onFailure.accept(subject, result); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java new file mode 100644 index 0000000..416953e --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java @@ -0,0 +1,58 @@ +package de.craftsblock.cnet.modules.security.auth.listener; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftsnet.CraftsNet; +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.api.http.Exchange; +import de.craftsblock.craftsnet.api.http.Request; +import de.craftsblock.craftsnet.api.http.Response; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.autoregister.meta.constructors.FallbackConstructor; +import de.craftsblock.craftsnet.autoregister.meta.constructors.PreferConstructor; +import de.craftsblock.craftsnet.events.requests.PreRequestEvent; + +@AutoRegister(startup = Startup.LOAD) +public record PreRequestListener(CraftsNet craftsNet, CraftsNetSecurity addon) implements AuthListener, ListenerAdapter { + + @PreferConstructor + public PreRequestListener { + } + + @FallbackConstructor + public PreRequestListener(CraftsNet craftsNet) { + this(craftsNet, CraftsNetSecurity.getInstance()); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreWhenCancelled = true) + public void handlePreRequestEvent(PreRequestEvent event) { + final Exchange exchange = event.getExchange(); + final Request request = exchange.request(); + + this.authenticate(exchange, event, exchange.response(), ((response, result) -> { + addon.getCraftsNet().getLogger().warning("%s %s from %s \u001b[38;5;9m[%s]".formatted( + request.getHttpMethod(), + request.getRawUrl(), + request.getIp(), + "AUTH FAILED" + )); + + if (!response.headersSent()) { + response.setCode(400); + } + + if (response.sendingFile()) { + return; + } + + response.print(Json.empty() + .set("success", false) + .set("error.code", result.getCode()) + .set("error.message", result.getReason())); + })); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/WebSocketConnectListener.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/WebSocketConnectListener.java new file mode 100644 index 0000000..c31e856 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/WebSocketConnectListener.java @@ -0,0 +1,43 @@ +package de.craftsblock.cnet.modules.security.auth.listener; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftsnet.CraftsNet; +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.api.websocket.SocketExchange; +import de.craftsblock.craftsnet.api.websocket.WebSocketClient; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.autoregister.meta.constructors.FallbackConstructor; +import de.craftsblock.craftsnet.autoregister.meta.constructors.PreferConstructor; +import de.craftsblock.craftsnet.events.sockets.ClientConnectEvent; + +@AutoRegister(startup = Startup.LOAD) +public record WebSocketConnectListener(CraftsNet craftsNet, CraftsNetSecurity addon) implements ListenerAdapter, AuthListener { + + @PreferConstructor + public WebSocketConnectListener { + } + + @FallbackConstructor + public WebSocketConnectListener(CraftsNet craftsNet) { + this(craftsNet, CraftsNetSecurity.getInstance()); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreWhenCancelled = true) + public void handleConnect(ClientConnectEvent event) { + final SocketExchange exchange = event.getExchange(); + this.authenticate( + exchange, event, exchange.client(), + (client, result) -> client.sendMessage( + Json.empty() + .set("success", false) + .set("error.code", result.getCode()) + .set("error.message", result.getReason()) + ) + ); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/Token.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/Token.java deleted file mode 100644 index e61584b..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/Token.java +++ /dev/null @@ -1,89 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token; - -import de.craftsblock.cnet.modules.security.utils.Entity; -import de.craftsblock.craftscore.json.Json; -import de.craftsblock.craftscore.utils.id.Snowflake; -import org.jetbrains.annotations.ApiStatus; -import org.springframework.security.crypto.bcrypt.BCrypt; - -import java.util.ArrayList; -import java.util.List; - -/** - * This class represents a token entity that holds information such as - * the token ID, hash, and associated permissions. - * It also provides functionality for validation and serialization. - * - * @param id the unique identifier of the token. - * @param hash the hashed value of the token secret. - * @param permissions a list of {@link TokenPermission}, defining access control rules for the token. - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.3 - * @since 1.0.0-SNAPSHOT - */ -public record Token(long id, String hash, List permissions) implements Entity { - - /** - * Validates if the given secret matches the hashed secret stored in the token. - * - * @param secret the secret to be validated. - * @return {@code true} if the secret matches the hash, {@code false} otherwise. - * @deprecated Use {@link #validate(String)} instead! - */ - @ApiStatus.ScheduledForRemoval(inVersion = "2.0.0") - @Deprecated(since = "1.0.0-pre10", forRemoval = true) - public boolean valid(String secret) { - return this.validate(secret); - } - - /** - * Validates if the given secret matches the hashed secret stored in the token. - * - * @param secret the secret to be validated. - * @return {@code true} if the secret matches the hash, {@code false} otherwise. - */ - public boolean validate(String secret) { - return BCrypt.checkpw(secret, hash()); - } - - /** - * Serializes the {@link Token} object into a {@link Json} object, - * which includes the ID, hash, expiration time, and permission details. - * - * @return a {@link Json} object representing the serialized token. - */ - @Override - public Json serialize() { - Json json = Json.empty(); - json.set("id", id); - json.set("hash", hash); - json.set("permissions", permissions.stream().map(TokenPermission::serialize).map(Json::getObject).toList()); - return json; - } - - /** - * Creates a new {@link Token} object using a hash. - * The token ID is generated using the {@link Snowflake} utility. - * The token will be created with empty permissions by default. - * - * @param hash the hashed token secret. - * @return a new {@link Token} object. - */ - public static Token of(String hash) { - return of(Snowflake.generate(), hash, new ArrayList<>()); - } - - /** - * A private factory method for creating a {@link Token} object with specified - * ID, hash, and permissions. - * - * @param id the unique identifier of the token. - * @param hash the hashed token secret. - * @param permissions a list of {@link TokenPermission} associated with this token. - * @return a new {@link Token} object. - */ - public static Token of(long id, String hash, List permissions) { - return new Token(id, hash, permissions); - } -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenManager.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenManager.java deleted file mode 100644 index 29e6623..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenManager.java +++ /dev/null @@ -1,309 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token; - -import de.craftsblock.cnet.modules.security.CNetSecurity; -import de.craftsblock.cnet.modules.security.auth.token.driver.storage.TokenStorageDriver; -import de.craftsblock.cnet.modules.security.events.auth.token.TokenCreateEvent; -import de.craftsblock.cnet.modules.security.events.auth.token.TokenRevokeEvent; -import de.craftsblock.cnet.modules.security.utils.Manager; -import de.craftsblock.craftsnet.api.http.HttpMethod; -import de.craftsblock.craftsnet.utils.PassphraseUtils; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.security.crypto.bcrypt.BCrypt; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Pattern; - -/** - * Manages a collection of authentication tokens, providing functionality to register, unregister, save, - * and generate tokens with associated permissions. It extends {@link ConcurrentHashMap} to store tokens - * by their unique IDs and implements the {@link Manager} interface for managing token-related operations. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.3.3 - * @since 1.0.0-SNAPSHOT - */ -public final class TokenManager extends ConcurrentHashMap implements Manager { - - private static TokenStorageDriver DRIVER; - - private static String TOKEN_PREFIX = "cnet_"; - private static String TOKEN_PREFIX_DELIMITER = "_"; - - /** - * Sets the storage driver to be used for persisting tokens and loads all tokens from it. - *

- * Existing tokens in the manager will be cleared and replaced with the loaded ones. - *

- * - * @param driver The {@link TokenStorageDriver} to be set and used for token persistence. - */ - @ApiStatus.Experimental - public static void setDriver(@NotNull TokenStorageDriver driver) { - TokenManager.DRIVER = driver; - - TokenManager manager = CNetSecurity.getTokenManager(); - if (manager == null) return; - - manager.clear(); - driver.loadAll().forEach(token -> manager.put(token.id(), token)); - } - - /** - * Retrieves the currently set {@link TokenStorageDriver} used for token persistence. - * - * @return The current {@link TokenStorageDriver}, or {@code null} if none is set. - */ - @ApiStatus.Experimental - public static @Nullable TokenStorageDriver getDriver() { - return DRIVER; - } - - /** - * Sets the prefix used when generating token strings. - * - * @param tokenPrefix The prefix string to be used for tokens. - */ - @ApiStatus.Experimental - public static void setTokenPrefix(String tokenPrefix) { - TOKEN_PREFIX = tokenPrefix.replaceAll(TOKEN_PREFIX_DELIMITER + "+", TOKEN_PREFIX_DELIMITER).trim(); - - if (TOKEN_PREFIX.endsWith(TOKEN_PREFIX_DELIMITER)) return; - TOKEN_PREFIX += TOKEN_PREFIX_DELIMITER; - } - - /** - * Retrieves the currently configured token prefix. - * - * @return The token prefix as a string. - */ - @ApiStatus.Experimental - public static String getTokenPrefix() { - return TOKEN_PREFIX; - } - - /** - * Sets the delimiter used to split token components. - *

- * The delimiter will be quoted to ensure it is used correctly in regular expressions. - *

- * - * @param tokenPrefixDelimiter The delimiter to be used in token formatting. - */ - @ApiStatus.Experimental - public static void setTokenPrefixDelimiter(String tokenPrefixDelimiter) { - TOKEN_PREFIX_DELIMITER = Pattern.quote(tokenPrefixDelimiter); - } - - /** - * Retrieves the currently configured token prefix delimiter. - * - * @return The token prefix delimiter as a string. - */ - @ApiStatus.Experimental - public static String getTokenPrefixDelimiter() { - return TOKEN_PREFIX_DELIMITER; - } - - /** - * Registers a new token by adding it to the token manager. - * - * @param token The {@link Token} to be registered. - */ - public void registerToken(Token token) { - try { - TokenCreateEvent event = new TokenCreateEvent(token); - if (event.isCancelled()) { - CNetSecurity.getLogger().debug("Token creation of token " + token.id() + " cancelled!"); - return; - } - - CNetSecurity.callEvent(event); - } catch (InvocationTargetException | IllegalAccessException e) { - throw new RuntimeException(e); - } - - this.put(token.id(), token); - } - - /** - * Unregisters a token by removing it from the token manager. - * - * @param token The {@link Token} to be unregistered. - */ - public void unregisterToken(Token token) { - try { - TokenRevokeEvent event = new TokenRevokeEvent(token); - if (event.isCancelled()) { - CNetSecurity.getLogger().debug("Token revokation for token " + token.id() + " cancelled!"); - return; - } - - CNetSecurity.callEvent(event); - } catch (InvocationTargetException | IllegalAccessException e) { - throw new RuntimeException(e); - } - - this.remove(token.id()); - DRIVER.delete(token); - } - - /** - * Saves the current tokens in the token manager to the driver. - */ - public void save() { - DRIVER.save(this.values()); - } - - /** - * Generates a new token with the provided permissions, creates a random secret, - * hashes the secret using BCrypt, and associates the permissions with the token. - * - * @param permissions An array of {@link TokenPermission} to be associated with the token. - * @return A {@link Map.Entry} containing the plain text secret (as the key) and the generated {@link Token} (as the value). - */ - public Map.Entry generateToken(TokenPermission... permissions) { - return generateToken(Arrays.asList(permissions)); - } - - /** - * Generates a new token with the provided list of permissions, creates a random secret, - * hashes the secret using BCrypt, and associates the permissions with the token. - * - * @param permissions A list of {@link TokenPermission} to be associated with the token. - * @return A {@link Map.Entry} containing the plain text secret (as the key) and the generated {@link Token} (as the value). - */ - public Map.Entry generateToken(List permissions) { - byte[] secret = this.generateTokenSecret(); - String hash = BCrypt.hashpw(secret, BCrypt.gensalt()); - - Token token = Token.of(hash); - token.permissions().addAll(permissions); - registerToken(token); - - Map.Entry tokenEntry = Map.entry(generatePlainToken(token.id(), secret), token); - PassphraseUtils.erase(secret); - return tokenEntry; - } - - /** - * Generates a plain token in the format {@code cnet_[secret]}. - *

- * The token is composed of a UTF-8 prefix, the hexadecimal representation of the ID, and the raw secret bytes. - *

- * - * @param id The identifier to embed in the token, encoded as hexadecimal. - * @param secret The secret byte array to include in the token; must not be null. - * @return A byte array representing the constructed token. - * @throws RuntimeException If an I/O error occurs during token generation. - */ - public byte[] generatePlainToken(long id, byte[] secret) { - try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) { - stream.write(TOKEN_PREFIX.getBytes(StandardCharsets.UTF_8)); - stream.write(Long.toHexString(id).getBytes(StandardCharsets.UTF_8)); - stream.write(secret); - - return stream.toByteArray(); - } catch (IOException e) { - throw new RuntimeException("Could not write plain token!", e); - } - } - - /** - * Generates a secure random byte array to be used as a token secret. - *

- * The generated secret is between 45 and 70 bytes long and excludes special characters. - *

- * - * @return A securely generated byte array to be used as a token secret. - */ - public byte[] generateTokenSecret() { - return PassphraseUtils.generateSecure(45, 70, false); - } - - /** - * Retrieves a {@link Token} based on the given token string. - * The token string is expected to contain an identifier in hexadecimal format. - * If the token is invalid or cannot be parsed, this method returns {@code null}. - * - * @param token The token string to be parsed. - * @return The corresponding {@link Token} if found, otherwise {@code null}. - */ - public @Nullable Token getToken(@NotNull String token) { - // Split the token into parts - String[] parts = token.split(TOKEN_PREFIX_DELIMITER); - if (parts.length == 0) return null; - - String part = parts[parts.length - 1]; - if (part.length() < 16) return null; - - try { - long id = Long.parseLong(part.substring(0, 16), 16); - return CNetSecurity.getTokenManager().get(id); - } catch (NumberFormatException | IllegalStateException ignored) { - return null; - } - } - - /** - * Retrieves and validates a {@link Token} for a given request. - * This method first attempts to retrieve the token using {@link #getToken(String)}. - * If the token exists, it verifies the token's validity based on the provided url, domain, http method, and secret. - * - * @param url The requested URL. - * @param domain The domain from which the request originates. - * @param method The HTTP method of the request. - * @param token The token string to be validated. - * @return The validated {@link Token} if authentication is successful, otherwise {@code null}. - */ - public @Nullable Token getValidatedToken(@NotNull String url, @NotNull String domain, @NotNull HttpMethod method, @NotNull String token) { - Token realToken = getToken(token); - if (realToken == null) return null; - - String[] parts = token.split(TOKEN_PREFIX_DELIMITER); - if (parts.length < 2 || parts[1].length() < 16) return null; - - if (!TOKEN_PREFIX.equalsIgnoreCase(parts[0] + TOKEN_PREFIX_DELIMITER)) return null; - - String secret = parts[1].substring(16); - return isTokenValid(url, domain, method, secret, realToken) ? realToken : null; - } - - /** - * Validates whether a given {@link Token} is authorized for the requested action. - * The token is verified using its hashed secret and checked for permission against the specified http method, domain, and url. - * - * @param url The requested URL. - * @param domain The domain from which the request originates. - * @param method The HTTP method of the request. - * @param secret The secret extracted from the token for authentication. - * @param token The {@link Token} object to be validated. - * @return {@code true} if the token is valid and authorized, otherwise {@code false}. - */ - public boolean isTokenValid(@NotNull String url, @NotNull String domain, @NotNull HttpMethod method, @NotNull String secret, Token token) { - if (token == null || secret.isBlank()) return false; - - try { - // Extract the secret from the token and verify it - if (!BCrypt.checkpw(secret, token.hash())) return false; - - // Check the token permissions - return token.permissions().stream() - .anyMatch(permission -> permission.isHttpMethodAllowed(method) - && permission.isDomainAllowed(domain) - && permission.isPathAllowed(url)); - } catch (Exception e) { - throw new RuntimeException("Could not verify the token", e); - } - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenPermission.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenPermission.java deleted file mode 100644 index 58d5296..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/TokenPermission.java +++ /dev/null @@ -1,152 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token; - -import de.craftsblock.cnet.modules.security.utils.Entity; -import de.craftsblock.craftscore.json.Json; -import de.craftsblock.craftscore.utils.id.Snowflake; -import de.craftsblock.craftsnet.api.http.HttpMethod; - -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -/** - * This class represents a permission model for a token, defining access - * control based on a combination of path patterns, domain patterns, and http methods. - * - * @param path a regular expression pattern representing the allowed path. - * @param domain a regular expression pattern representing the allowed domain. - * @param methods a variable number of {@link HttpMethod} values representing - * the allowed http methods (e.g., GET, POST). - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.1.1 - * @since 1.0.0-SNAPSHOT - */ -public record TokenPermission(long id, String path, String domain, HttpMethod... methods) implements Entity { - - /** - * Checks if a given pattern is a wildcard pattern. - * A pattern is considered a wildcard if it is "*" or ".*". - * - * @param pattern the pattern to check. - * @return {@code true} if the pattern is a wildcard, {@code false} otherwise. - */ - private boolean isWildcard(String pattern) { - return pattern.equals("*") || pattern.equals(".*"); - } - - /** - * Checks if a given value is allowed by matching it against the provided pattern. - * - * @param value the value to be checked (e.g., a path or domain). - * @param pattern the pattern to match against. - * @return {@code true} if the value matches the pattern, {@code false} otherwise. - */ - private boolean isAllowed(String value, String pattern) { - return value.matches(pattern); - } - - /** - * Checks if the path pattern is a wildcard. - * - * @return {@code true} if the path pattern is a wildcard, {@code false} otherwise. - */ - boolean isPathWildcard() { - return isWildcard(path()); - } - - /** - * Determines if a given path is allowed based on the defined path pattern. - * A path is allowed if it either matches the pattern or if the pattern is a wildcard. - * - * @param path the path to check. - * @return {@code true} if the path is allowed, {@code false} otherwise. - */ - boolean isPathAllowed(String path) { - return isPathWildcard() || isAllowed(path, path()); - } - - /** - * Checks if the domain pattern is a wildcard. - * - * @return {@code true} if the domain pattern is a wildcard, {@code false} otherwise. - */ - boolean isDomainWildcard() { - return isWildcard(domain()); - } - - /** - * Determines if a given domain is allowed based on the defined domain pattern. - * A domain is allowed if it either matches the pattern or if the pattern is a wildcard. - * - * @param domain the domain to check. - * @return {@code true} if the domain is allowed, {@code false} otherwise. - */ - boolean isDomainAllowed(String domain) { - return isDomainWildcard() || isAllowed(domain, domain()); - } - - /** - * Determines if a given http method is allowed based on the defined allowed methods. - * - * @param method the http method to check. - * @return {@code true} if the http method is allowed, {@code false} otherwise. - */ - public boolean isHttpMethodAllowed(HttpMethod method) { - List methods = Arrays.asList(methods()); - return methods.contains(HttpMethod.ALL) || methods.contains(HttpMethod.ALL_RAW) || methods.contains(method); - } - - /** - * Serializes the {@link TokenPermission} object into a {@link Json} object. - * The serialization includes the path, domain, and allowed http methods. - * - * @return a {@link Json} object representing the serialized permission details. - */ - @Override - public Json serialize() { - return Json.empty() - .set("id", id()) - .set("path", path()) - .set("domain", domain()) - .set("methods", Arrays.stream(methods()).map(HttpMethod::name).toList()); - } - - /** - * Creates a new {@link TokenPermission} with a given path and http methods. - * The domain pattern defaults to a wildcard (".*"). - * - * @param path the regular expression pattern for the path. - * @param methods the allowed http methods for this permission. - * @return a new {@link TokenPermission} instance. - */ - public static TokenPermission of(String path, HttpMethod... methods) { - return TokenPermission.of(path, ".*", methods); - } - - /** - * Creates a new {@link TokenPermission} with a given path, domain, and http methods. - * - * @param path the regular expression pattern for the path. - * @param domain the regular expression pattern for the domain. - * @param methods the allowed http methods for this permission. - * @return a new {@link TokenPermission} instance. - */ - public static TokenPermission of(String path, String domain, HttpMethod... methods) { - return TokenPermission.of(Snowflake.generate(), path, domain, methods); - } - - /** - * Creates a new {@link TokenPermission} with the specified id, path, domain, and http methods. - * - * @param id the unique id of the permission (usually generated via Snowflake or read from storage). - * @param path the regular expression pattern for the path. - * @param domain the regular expression pattern for the domain. - * @param methods the allowed http methods for this permission. - * @return a new {@link TokenPermission} instance. - */ - public static TokenPermission of(long id, String path, String domain, HttpMethod... methods) { - return new TokenPermission(id, path, domain, methods); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthAdapter.java deleted file mode 100644 index b0205cb..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthAdapter.java +++ /dev/null @@ -1,213 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token.adapter; - -import de.craftsblock.cnet.modules.security.CNetSecurity; -import de.craftsblock.cnet.modules.security.auth.AuthAdapter; -import de.craftsblock.cnet.modules.security.auth.AuthResult; -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.auth.token.TokenManager; -import de.craftsblock.cnet.modules.security.events.auth.token.TokenUsedEvent; -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.HttpMethod; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.http.cookies.Cookie; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.Nullable; - -import java.util.EnumMap; -import java.util.Map; - -/** - * The {@link TokenAuthAdapter} class implements the {@link AuthAdapter} interface to provide authentication - * functionality using bearer tokens. - *

- * This adapter extracts the token from the Authorization header of a http request, - * validates it, and performs authentication by checking the token's validity - * against the stored tokens managed by the {@link TokenManager}. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.5 - * @see TokenAuthType - * @see TokenUsedEvent - * @since 1.0.0-SNAPSHOT - */ -public class TokenAuthAdapter implements AuthAdapter { - - /** - * The expected authorization type for bearer tokens. - */ - public static final String HEADER_AUTH_TYPE = "bearer"; - - private final EnumMap authTypes = new EnumMap<>(TokenAuthType.class); - - private String tokenSessionKey = null; - - /** - * Enables token authentication for the given authentication type using a default name. - *

- * For {@link TokenAuthType#HEADER}, the default name "Authorization" is used. - * For {@link TokenAuthType#COOKIE} and {@link TokenAuthType#SESSION}, no default name is provided and an - * {@link IllegalStateException} is thrown. - *

- * - * @param type The token authentication type to enable. - * @return The current instance of {@code TokenAuthAdapter} for method chaining. - * @throws IllegalStateException if no default name is defined for the given authentication type. - */ - public TokenAuthAdapter enable(TokenAuthType type) { - return switch (type) { - case HEADER -> enable(type, "Authorization"); - case COOKIE, SESSION -> throw new IllegalStateException("No default name for auth type " + type + " found!"); - }; - } - - /** - * Enables token authentication for the given authentication type using the specified name. - * - * @param type The token authentication type to enable. - * @param name The name of the header, cookie, or session attribute to use. - * @return The current instance of {@code TokenAuthAdapter} for method chaining. - */ - public TokenAuthAdapter enable(TokenAuthType type, String name) { - this.authTypes.put(type, name); - return this; - } - - /** - * Disables token authentication for the specified authentication type. - * - * @param type The token authentication type to disable. - * @return The current instance of {@code TokenAuthAdapter} for method chaining. - */ - public TokenAuthAdapter disable(TokenAuthType type) { - this.authTypes.remove(type); - return this; - } - - /** - * Checks if token authentication is enabled for the specified authentication type. - * - * @param type The token authentication type to check. - * @return {@code true} if the authentication type is enabled, {@code false} otherwise. - */ - public boolean isEnabled(TokenAuthType type) { - return this.authTypes.containsKey(type); - } - - /** - * Sets the key where the used token should be stored in the session - * of the exchange. If the session key is {@code null} the token will - * not be stored in the session. - * - * @param sessionKey The key where the token should be stored. - */ - public void setTokenSessionKey(@Nullable String sessionKey) { - this.tokenSessionKey = sessionKey; - } - - /** - * Retrieves the key where the used token is stored inside the session. - * If the token is not stored anywhere in the session this method returns - * {@code null}. - * - * @return The key where the token is stored, or {@code null} when the token - * is not stored in the session. - */ - public @Nullable String getTokenSessionKey() { - return tokenSessionKey; - } - - /** - * Authenticates the user based on the provided token in the request. - *

- * This method checks for the presence of the Authorization header and validates - * the token format. If the token is valid, it retrieves the corresponding - * {@link Token} from the {@link CNetSecurity} and verifies the token's - * secret using BCrypt. If any validation fails, the authentication result is - * marked as failed. - * - * @param result The {@link AuthResult} object where the authentication result will be stored. - * @param exchange The {@link Exchange} object representing the HTTP request. - */ - @Override - public void authenticate(AuthResult result, Exchange exchange) { - if (result.isCancelled()) return; - - if (authTypes.isEmpty()) { - failAuth(result, 501, "No auth type has been set up!"); - return; - } - - for (Map.Entry entry : authTypes.entrySet()) { - TokenAuthType type = entry.getKey(); - String name = entry.getValue(); - if (handle(result, exchange, type, name)) return; - } - - if (result.isCancelled()) return; - failAuth(result, 401, "Requires authentication"); - } - - /** - * Handles the authentication process for a specific token authentication type. - * - * @param result The {@link AuthResult} object to update with authentication status. - * @param exchange The {@link Exchange} object representing the HTTP request and session. - * @param type The token authentication type (e.g., HEADER, COOKIE, SESSION). - * @param name The name of the header, cookie, or session attribute to extract the token from. - * @return {@code true} if the authentication process for this token type has been completed (successfully or not), - * or {@code false} if the token was not found and further processing is required. - */ - private boolean handle(AuthResult result, Exchange exchange, TokenAuthType type, String name) { - if (result.isCancelled()) return true; - - final Request request = exchange.request(); - final Session session = exchange.session(); - - String secret = switch (type) { - case HEADER -> { - // Retrieve the authorization header from the request - String auth_header = request.getHeader(name); - - // Check if the header is present - if (auth_header == null || auth_header.isBlank()) yield null; - - // Split the auth header and check if it has two values and is of the correct type - String[] header = auth_header.split(" "); - if (header.length != 2 || !HEADER_AUTH_TYPE.equalsIgnoreCase(header[0])) { - failAuth(result, 400, "Invalid authorization header!"); - yield null; - } - - // Extract the token from the authorization header - yield header[1]; - } - case COOKIE -> request.getCookies().getOrDefault(name, new Cookie(name, null)).getValue(); - case SESSION -> session.getAsType(name, String.class); - }; - - if (result.isCancelled()) return true; - if (secret == null || secret.isBlank()) return false; - - String url = request.getUrl(); - String domain = request.getDomain(); - HttpMethod method = request.getHttpMethod(); - Token token = CNetSecurity.getTokenManager().getValidatedToken(url, domain, method, secret); - if (token == null) { - failAuth(result, "You do not have access to this ressource!"); - return true; - } - - try { - if (tokenSessionKey != null && !tokenSessionKey.isBlank()) - session.put(tokenSessionKey, token); - - CNetSecurity.callEvent(new TokenUsedEvent(token, type)); - } catch (Exception e) { - failAuth(result, 500, "Failed to verify your token!"); - CNetSecurity.getAddonEntrypoint().logger().error(e, "Failed to verify the api token!"); - } - return true; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthType.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthType.java deleted file mode 100644 index 2b44a55..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/adapter/TokenAuthType.java +++ /dev/null @@ -1,28 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token.adapter; - -/** - * Enum representing the supported types of token authentication. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public enum TokenAuthType { - - /** - * Token authentication via HTTP header. - */ - HEADER, - - /** - * Token authentication via HTTP cookie. - */ - COOKIE, - - /** - * Token authentication via session attribute. - */ - SESSION, - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/FileTokenStorageDriver.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/FileTokenStorageDriver.java deleted file mode 100644 index 4bf361d..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/FileTokenStorageDriver.java +++ /dev/null @@ -1,164 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token.driver.storage; - -import de.craftsblock.cnet.modules.security.CNetSecurity; -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.auth.token.TokenPermission; -import de.craftsblock.craftscore.json.Json; -import de.craftsblock.craftscore.json.JsonParser; -import de.craftsblock.craftscore.utils.id.Snowflake; -import de.craftsblock.craftsnet.api.http.HttpMethod; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * A file-based implementation of {@link TokenStorageDriver} that serializes tokens - * and stores them in a json file. - * - *

This implementation is useful for lightweight deployments where a database - * is not available or necessary.

- * - *

The file is synchronized during read/write operations to ensure thread safety.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @see Json - * @see TokenStorageDriver - * @since 1.0.0-SNAPSHOT - */ -public class FileTokenStorageDriver extends TokenStorageDriver { - - private final Path saveFile; - - /** - * Constructs a {@link FileTokenStorageDriver} that stores tokens in the default {@code tokens.json} - * file within the plugin's data folder. - */ - public FileTokenStorageDriver() { - this(CNetSecurity.getAddonEntrypoint().getDataFolder().toPath().resolve("tokens.json")); - } - - /** - * Constructs a {@link FileTokenStorageDriver} that stores tokens in the specified file. - * - * @param saveFile The path to the file where tokens will be stored. - */ - public FileTokenStorageDriver(Path saveFile) { - if (!Files.exists(saveFile)) { - try { - Files.createFile(saveFile); - } catch (IOException e) { - throw new RuntimeException("Could not create save file at %s!".formatted(saveFile.toAbsolutePath().toString()), e); - } - } - - if (!Files.isRegularFile(saveFile) && !Files.isSymbolicLink(saveFile)) - throw new IllegalArgumentException(""); - - this.saveFile = saveFile; - } - - /** - * Saves the given collection of tokens to the configured json file. - *

- * Each token is serialized to json and stored under its id as the key. - * The write operation is synchronized to avoid concurrent access issues. - * - * @param tokens The tokens to save. - */ - @Override - public void save(Collection tokens) { - Json json = Json.empty(); - tokens.forEach(token -> json.set(String.valueOf(token.id()), token.serialize())); - - synchronizedSave(json); - } - - /** - * Loads all tokens from the json file. - *

- * Parses each json object and reconstructs the {@link Token} and associated {@link TokenPermission}s. - * The read operation is synchronized to ensure thread safety. - * - * @return A collection of all loaded tokens, or an empty list if the file is empty or invalid. - */ - @Override - public Collection loadAll() { - Json json = synchronizedRead(); - if (!json.getObject().isJsonObject()) return List.of(); - - return json.values().stream() - .map(JsonParser::parse) - .map(this::createTokenFromJson) - .toList(); - } - - /** - * Synchronously reads the contents of the json file and parses it into a {@link Json} object. - * - * @return The parsed {@link Json} object from the file. - */ - private Json synchronizedRead() { - synchronized (saveFile) { - return JsonParser.parse(saveFile); - } - } - - /** - * Synchronously writes the given {@link Json} object to the file. - * - * @param json The {@link Json} data to write to the file. - */ - private void synchronizedSave(Json json) { - synchronized (saveFile) { - json.save(saveFile); - } - } - - /** - * Returns the file path used for saving and loading token data. - * - * @return The path to the save file. - */ - public Path getSaveFile() { - return saveFile; - } - - /** - * Constructs a {@link Token} from the given json object. - *

- * Parses the token id, hash, and all associated permissions from the nested json structure. - * - * @param json The json object representing a token. - * @return The constructed {@link Token}. - */ - private Token createTokenFromJson(Json json) { - return Token.of(json.getLong("id"), json.getString("hash"), - new ArrayList<>(json.getJsonList("permissions").stream().map(this::createTokenPermissionFromJson).toList())); - } - - /** - * Constructs a {@link TokenPermission} from the given json object. - *

- * If the permission does not contain an "id" field, a new id is generated using {@link Snowflake} - * to maintain compatibility with older formats. - * - * @param json The json object representing a permission. - * @return The constructed {@link TokenPermission}. - */ - private TokenPermission createTokenPermissionFromJson(Json json) { - // Required for backwards compatibility, as the old token permissions do not have an id - long id = json.contains("id") ? json.getLong("id") : Snowflake.generate(); - - return TokenPermission.of( - id, json.getString("path"), json.getString("domain"), - json.getStringList("methods").stream().map(HttpMethod::parse).toArray(HttpMethod[]::new) - ); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/SQLTokenStorageDriver.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/SQLTokenStorageDriver.java deleted file mode 100644 index 6642419..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/SQLTokenStorageDriver.java +++ /dev/null @@ -1,298 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token.driver.storage; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.auth.token.TokenPermission; -import de.craftsblock.craftscore.sql.SQL; -import de.craftsblock.craftsnet.api.http.HttpMethod; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -/** - * A concrete implementation of {@link TokenStorageDriver} that persists and retrieves tokens - * and their associated permissions using an SQL-based relational database. - * - *

This class creates the required database tables, views, and triggers if they do not already exist.

- *

It supports saving, deleting, and loading tokens, including managing the many-to-many relationship - * between tokens and permissions.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @see SQL - * @see TokenStorageDriver - * @since 1.0.0-SNAPSHOT - */ -public class SQLTokenStorageDriver extends TokenStorageDriver { - - private final SQL sql; - - /** - * Constructs a new {@link SQLTokenStorageDriver} with the given {@link SQL} connection. - * - * @param sql An active {@link SQL} connection to a relational database. - * @throws IllegalStateException If the SQL connection is not active. - */ - public SQLTokenStorageDriver(SQL sql) { - this.sql = sql; - - try { - if (!this.sql.isConnected()) - throw new IllegalStateException("The sql instance must be connected to the database!"); - } catch (SQLException e) { - throw new RuntimeException("Could not verify sql connection status!", e); - } - - // Create tables if they not exists - this.createTables(); - } - - /** - * Saves a collection of tokens to the database by calling {@link #save(Token)} for each token. - * - * @param tokens The collection of {@link Token} instances to persist. - */ - @Override - public void save(Collection tokens) { - tokens.forEach(this::save); - } - - /** - * Saves a single token and its associated permissions to the database. - *

- * This involves: - *

    - *
  • Inserting or updating the token in the {@code cnet_security_tokens} table
  • - *
  • Inserting or updating each permission in the {@code cnet_security_permissions} table
  • - *
  • Linking the token to its permissions in the {@code cnet_security_token_permissions} table
  • - *
- * - * @param token The {@link Token} to persist. - */ - public void save(Token token) { - try (PreparedStatement statement = this.sql.prepareStatement( - "INSERT INTO `cnet_security_tokens` (`id`, `hash`) VALUES (?,?) ON DUPLICATE KEY UPDATE `hash`=?;" - )) { - statement.setLong(1, token.id()); - statement.setString(2, token.hash()); - statement.setString(3, token.hash()); - - this.sql.update(statement); - } catch (SQLException e) { - throw new RuntimeException("Could not save token %s to the database!".formatted(token.id()), e); - } - - List permissionIDs = new ArrayList<>(); - token.permissions().forEach(permission -> { - try (PreparedStatement statement = this.sql.prepareStatement( - "INSERT INTO `cnet_security_permissions` (`id`, `path`, `domain`, `http_methods`) VALUES (?, ?, ?, ?) " + - "ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id);", true - )) { - statement.setLong(1, permission.id()); - statement.setString(2, permission.path()); - statement.setString(3, permission.domain()); - statement.setString(4, HttpMethod.join(permission.methods())); - - statement.executeUpdate(); - - try (ResultSet keys = statement.getGeneratedKeys()) { - if (keys.next()) - permissionIDs.add(keys.getLong(1)); - else permissionIDs.add(permission.id()); - } - } catch (SQLException e) { - throw new RuntimeException("Could not create token permission for token %s!".formatted(token.id()), e); - } - }); - - permissionIDs.forEach(id -> { - try (PreparedStatement statement = this.sql.prepareStatement( - "INSERT IGNORE INTO `cnet_security_token_permissions` (`token`, `permission`) VALUES (?,?);" - )) { - statement.setLong(1, token.id()); - statement.setLong(2, id); - - this.sql.update(statement); - } catch (SQLException e) { - throw new RuntimeException("Could not link token permission %s with token %s!".formatted(id, token.id()), e); - } - }); - } - - /** - * Deletes a token with the specified id from the database. - *

- * Related entries in the {@code cnet_security_token_permissions} table will also be removed, - * and a cleanup trigger may delete unused permissions. - * - * @param id The ID of the token to delete. - */ - @Override - public void delete(long id) { - try (PreparedStatement statement = this.sql.prepareStatement( - "DELETE FROM `cnet_security_token_permissions` WHERE `cnet_security_token_permissions`.`token`=?;" - )) { - statement.setLong(1, id); - this.sql.update(statement); - } catch (SQLException e) { - throw new RuntimeException("Could not delete token permissions for token %s from the database!".formatted(id), e); - } - - try (PreparedStatement statement = this.sql.prepareStatement( - "DELETE FROM `cnet_security_tokens` WHERE `cnet_security_tokens`.`id`=?;" - )) { - statement.setLong(1, id); - this.sql.update(statement); - } catch (SQLException e) { - throw new RuntimeException("Could not delete token %s from the database!".formatted(id), e); - } - } - - /** - * Loads all tokens and their associated permissions from the database. - * - * @return A collection of {@link Token} instances with their full permission sets. - */ - @Override - public Collection loadAll() { - try (ResultSet result = this.sql.query("SELECT * FROM `cnet_security_tokens_merged`;")) { - return createTokensFromResultSet(result).values(); - } catch (SQLException e) { - throw new RuntimeException("Could not load all tokens in the database!", e); - } - } - - /** - * Constructs tokens and their permissions from the result set of the merged view. - * - * @param result The {@link ResultSet} containing joined token and permission data. - * @return A map of token ID to {@link Token} instance. - */ - private Map createTokensFromResultSet(ResultSet result) { - Map tokens = new HashMap<>(); - - try { - while (result.next()) { - long id = result.getLong("token_id"); - String hash = result.getString("hash"); - - Token token = tokens.computeIfAbsent(id, tokenID -> Token.of(tokenID, hash, new ArrayList<>())); - token.permissions().add(createTokenPermissionFromResultSet(result)); - } - } catch (SQLException e) { - throw new RuntimeException("Could not read token from database!", e); - } - - return tokens; - } - - /** - * Creates a {@link TokenPermission} object from the current row of the result set. - * - * @param result The {@link ResultSet} to extract permission data from. - * @return The constructed {@link TokenPermission}. - */ - private TokenPermission createTokenPermissionFromResultSet(ResultSet result) { - try { - HttpMethod[] methods = Arrays.stream(result.getString("http_methods").split("\\|")) - .map(HttpMethod::parse) - .toArray(HttpMethod[]::new); - - return TokenPermission.of(result.getLong("permission_id"), - result.getString("path"), result.getString("domain"), - methods - ); - } catch (SQLException e) { - throw new RuntimeException("Could not read token permission from database!", e); - } - } - - /** - * Initializes the database schema including required tables, views, and triggers - * for managing tokens and their permissions. - */ - private void createTables() { - this.sqlCreate("table cnet_security_tokens", """ - CREATE TABLE IF NOT EXISTS `cnet_security_tokens` ( - `id` BIGINT NOT NULL , - `hash` VARCHAR(128) NOT NULL , - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - `updated_at` TIMESTAMP on update CURRENT_TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - PRIMARY KEY (`id`) - ); - """); - - this.sqlCreate("table cnet_security_permissions", """ - CREATE TABLE IF NOT EXISTS `cnet_security_permissions` ( - `id` BIGINT NOT NULL , - `path` VARCHAR(256) NOT NULL , - `domain` VARCHAR(256) NOT NULL , - `http_methods` VARCHAR(128) NOT NULL , - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - `updated_at` TIMESTAMP on update CURRENT_TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - PRIMARY KEY (`id`) , - UNIQUE KEY `unique_permission` (`path`, `domain`, `http_methods`) - ); - """); - - this.sqlCreate("table cnet_security_token_permissions", """ - CREATE TABLE IF NOT EXISTS `cnet_security_token_permissions` ( - `token` BIGINT NOT NULL , - `permission` BIGINT NOT NULL , - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - `updated_at` TIMESTAMP on update CURRENT_TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , - PRIMARY KEY(`token`, `permission`) , - FOREIGN KEY (`token`) REFERENCES `cnet_security_tokens`(`id`) ON DELETE CASCADE ON UPDATE CASCADE , - FOREIGN KEY (`permission`) REFERENCES `cnet_security_permissions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE - ); - """); - - this.sqlCreate("view cnet_security_tokens_merged", """ - CREATE OR REPLACE VIEW `cnet_security_tokens_merged` AS SELECT - `cnet_security_tokens`.`id` AS `token_id` , - `cnet_security_tokens`.`hash` , - `cnet_security_permissions`.`id` AS `permission_id` , - `cnet_security_permissions`.`path` , - `cnet_security_permissions`.`domain` , - `cnet_security_permissions`.`http_methods` - FROM `cnet_security_tokens` - JOIN `cnet_security_token_permissions` ON (`cnet_security_tokens`.`id` = `cnet_security_token_permissions`.`token`) - JOIN `cnet_security_permissions` ON (`cnet_security_token_permissions`.`permission` = `cnet_security_permissions`.`id`) - """); - - this.sqlCreate("trigger cnet_security_cleanup_unused_permissions", """ - CREATE OR REPLACE TRIGGER `cnet_security_cleanup_unused_permissions` - AFTER DELETE ON `cnet_security_token_permissions` - FOR EACH ROW - BEGIN - DECLARE remaining INT; - \s - SELECT COUNT(*) INTO remaining - FROM `cnet_security_token_permissions` - WHERE `cnet_security_token_permissions`.`permission` = OLD.`permission`; - \s - IF remaining = 0 THEN - DELETE FROM `cnet_security_permissions` - WHERE `cnet_security_permissions`.`id` = OLD.`permission`; - END IF; - END; - """); - } - - /** - * Executes an SQL update for the given schema object creation command. - * - * @param target A descriptive name for the target being created. - * @param sqlCommand The SQL DDL command to execute. - */ - private void sqlCreate(String target, String sqlCommand) { - try { - this.sql.update(sqlCommand); - } catch (SQLException e) { - throw new RuntimeException("Could not create %s!".formatted(target), e); - } - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/TokenStorageDriver.java b/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/TokenStorageDriver.java deleted file mode 100644 index b06ca39..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/token/driver/storage/TokenStorageDriver.java +++ /dev/null @@ -1,52 +0,0 @@ -package de.craftsblock.cnet.modules.security.auth.token.driver.storage; - -import de.craftsblock.cnet.modules.security.auth.token.Token; - -import java.util.Collection; - -/** - * Abstract base class representing a storage driver for authentication tokens. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public abstract class TokenStorageDriver { - - /** - * Persists the given collection of tokens to the underlying storage mechanism. - * - * @param tokens A collection of {@link Token} instances to be saved. - */ - public abstract void save(Collection tokens); - - /** - * Loads all tokens currently stored in the underlying storage. - * - * @return A collection of all {@link Token} instances retrieved from storage. - */ - public abstract Collection loadAll(); - - /** - * Deletes the specified token from the storage. - *

- * This is a convenience method that delegates to {@link #delete(long)} using the tokens id. - *

- * - * @param token The {@link Token} instance to be deleted. - */ - public void delete(Token token) { - this.delete(token.id()); - } - - /** - * Deletes a token identified by its unique id. - * - * @param id the unique identifier of the token to delete. - */ - public void delete(long id) { - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthFailedEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthFailedEvent.java deleted file mode 100644 index eb78712..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthFailedEvent.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth; - -import de.craftsblock.craftsnet.api.http.Exchange; -import org.jetbrains.annotations.NotNull; - -/** - * Represents an event triggered when authentication fails. - *

- * This event extends {@link GenericAuthResultEvent} to provide - * information about the failed authentication attempt, such as the - * associated {@link Exchange}. - *

- * - *

Listeners can use this event to handle authentication failures, - * such as logging the attempt or displaying an additional error message.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public class AuthFailedEvent extends GenericAuthResultEvent { - - /** - * Constructs a new {@link AuthFailedEvent}. - * - * @param exchange The HTTP exchange associated with the failed authentication. - * Must not be null. - * @throws NullPointerException If {@code exchange} is null. - */ - public AuthFailedEvent(@NotNull Exchange exchange) { - super(exchange); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthSuccessEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthSuccessEvent.java deleted file mode 100644 index 558866c..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/AuthSuccessEvent.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth; - -import de.craftsblock.craftsnet.api.http.Exchange; -import org.jetbrains.annotations.NotNull; - -/** - * Represents an event triggered when authentication is successful. - *

- * This event extends {@link GenericAuthResultEvent} to provide - * information about the successful authentication, such as the - * associated {@link Exchange}. - *

- * - *

Listeners can use this event to perform post-authentication actions, - * such as logging or granting access to specific resources.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public class AuthSuccessEvent extends GenericAuthResultEvent { - - /** - * Constructs a new {@link AuthSuccessEvent}. - * - * @param exchange The HTTP exchange associated with the successful authentication. - * Must not be null. - * @throws NullPointerException If {@code exchange} is null. - */ - public AuthSuccessEvent(@NotNull Exchange exchange) { - super(exchange); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthEvent.java deleted file mode 100644 index c8fa72c..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth; - -import de.craftsblock.craftscore.event.Event; - -/** - * Represents a generic base class for all authentication related events. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see Event - * @since 1.0.0-SNAPSHOT - */ -public abstract class GenericAuthEvent extends Event { -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthResultEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthResultEvent.java deleted file mode 100644 index 00d88f2..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/GenericAuthResultEvent.java +++ /dev/null @@ -1,73 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth; - -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.http.Response; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.NotNull; - -/** - * Represents a base class for authentication related events that involve - * an HTTP {@link Exchange}. This class provides access to the request, - * response, and session storage associated with the exchange. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see Exchange - * @see Request - * @see Response - * @see Session - * @since 1.0.0-SNAPSHOT - */ -public abstract class GenericAuthResultEvent extends GenericAuthEvent { - - private final @NotNull Exchange exchange; - - /** - * Constructs a new {@link GenericAuthResultEvent}. - * - * @param exchange The HTTP exchange associated with this event. Must not be null. - * @throws NullPointerException If {@code exchange} is null. - */ - public GenericAuthResultEvent(@NotNull Exchange exchange) { - this.exchange = exchange; - } - - /** - * Gets the HTTP exchange associated with this event. - * - * @return The associated {@link Exchange}. - */ - public @NotNull Exchange getExchange() { - return exchange; - } - - /** - * Gets the HTTP request associated with this event. - * - * @return The associated {@link Request}. - */ - public Request getRequest() { - return exchange.request(); - } - - /** - * Gets the HTTP response associated with this event. - * - * @return The associated {@link Response}. - */ - public Response getResponse() { - return exchange.response(); - } - - /** - * Gets the session storage associated with this event. - * - * @return The associated {@link Session}. - */ - public Session getStorage() { - return exchange.session(); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/CancellableTokenEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/CancellableTokenEvent.java deleted file mode 100644 index eb919ae..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/CancellableTokenEvent.java +++ /dev/null @@ -1,56 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth.token; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.craftscore.event.Cancellable; -import org.jetbrains.annotations.NotNull; - - -/** - * Represents a cancellable token-related event. - *

- * This class extends {@link GenericTokenEvent} and implements {@link Cancellable}, - * allowing the event to be cancelled during processing. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see GenericTokenEvent - * @see Cancellable - * @since 1.0.0-SNAPSHOT - */ -public abstract class CancellableTokenEvent extends GenericTokenEvent implements Cancellable { - - private boolean cancelled = false; - - /** - * Constructs a new {@code CancellableTokenEvent}. - * - * @param token The token associated with this event. Must not be null. - * @throws NullPointerException If {@code token} is null. - */ - public CancellableTokenEvent(@NotNull Token token) { - super(token); - } - - /** - * Sets the cancellation state of this event. - * - * @param cancelled {@code true} to cancel the event, {@code false} to allow it to proceed. - */ - @Override - public void setCancelled(boolean cancelled) { - this.cancelled = cancelled; - } - - /** - * Checks whether this event has been cancelled. - * - * @return {@code true} if the event is cancelled, {@code false} otherwise. - */ - @Override - public boolean isCancelled() { - return cancelled; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/GenericTokenEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/GenericTokenEvent.java deleted file mode 100644 index 41a4e38..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/GenericTokenEvent.java +++ /dev/null @@ -1,44 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth.token; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.events.auth.GenericAuthEvent; -import org.jetbrains.annotations.NotNull; - -/** - * Represents a generic event related to authentication tokens. - *

- * This class serves as a base for more specific token related events - * and provides access to the associated {@link Token}. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see GenericAuthEvent - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public abstract class GenericTokenEvent extends GenericAuthEvent { - - private final @NotNull Token token; - - /** - * Constructs a new {@link GenericTokenEvent}. - * - * @param token The token associated with this event. Must not be null. - * @throws NullPointerException If {@code token} is null. - */ - public GenericTokenEvent(@NotNull Token token) { - this.token = token; - } - - /** - * Returns the token associated with this event. - * - * @return The associated {@link Token}, never null. - */ - public @NotNull Token getToken() { - return token; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenCreateEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenCreateEvent.java deleted file mode 100644 index 4c599c1..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenCreateEvent.java +++ /dev/null @@ -1,33 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth.token; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import org.jetbrains.annotations.NotNull; - -/** - * Event triggered before a new token is created. - *

- * This event extends {@link CancellableTokenEvent}, allowing listeners to cancel the token creation process - * if necessary. Cancellation might be useful in cases where certain conditions for token creation - * are not met. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see CancellableTokenEvent - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public class TokenCreateEvent extends CancellableTokenEvent { - - /** - * Constructs a new {@link TokenCreateEvent}. - * - * @param token The token being created. Must not be null. - * @throws NullPointerException If {@code token} is null. - */ - public TokenCreateEvent(@NotNull Token token) { - super(token); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenRevokeEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenRevokeEvent.java deleted file mode 100644 index e484a87..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenRevokeEvent.java +++ /dev/null @@ -1,33 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth.token; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import org.jetbrains.annotations.NotNull; - -/** - * Event triggered before a token is revoked. - *

- * This event extends {@link CancellableTokenEvent}, allowing listeners to cancel the revocation process - * if necessary. For example, cancellation might occur if the revocation request does not meet certain conditions - * or is unauthorized. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see CancellableTokenEvent - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public class TokenRevokeEvent extends CancellableTokenEvent { - - /** - * Constructs a new {@link TokenRevokeEvent}. - * - * @param token The token being revoked. Must not be null. - * @throws NullPointerException If {@code token} is null. - */ - public TokenRevokeEvent(@NotNull Token token) { - super(token); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenUsedEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenUsedEvent.java deleted file mode 100644 index 66d7ab4..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/auth/token/TokenUsedEvent.java +++ /dev/null @@ -1,43 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.auth.token; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.auth.token.adapter.TokenAuthType; -import org.jetbrains.annotations.NotNull; - -/** - * Event triggered when a token is successfully used. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @see GenericTokenEvent - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public class TokenUsedEvent extends GenericTokenEvent { - - private final TokenAuthType type; - - /** - * Constructs a new {@link TokenUsedEvent}. - * - * @param token The {@link Token} that has been used. Must not be null. - * @param type The {@link TokenAuthType} where the {@link Token} was found. Must not be null. - * @throws NullPointerException If {@code token} is null. - */ - public TokenUsedEvent(@NotNull Token token, @NotNull TokenAuthType type) { - super(token); - this.type = type; - } - - /** - * Retrieves the {@link TokenAuthType} where the {@link Token} was - * found. - * - * @return The {@link TokenAuthType} where the {@link Token} was found. - */ - public @NotNull TokenAuthType getAuthType() { - return type; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/GenericRateLimitEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/GenericRateLimitEvent.java deleted file mode 100644 index 7235820..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/GenericRateLimitEvent.java +++ /dev/null @@ -1,27 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.ratelimit; - -import de.craftsblock.craftscore.event.Event; - -/** - * The {@link GenericRateLimitEvent} serves as a base class for all rate-limiting-related events. - *

- * Subclasses of this event can be used to handle various rate-limiting scenarios, - * such as when a rate limit is exceeded or reset. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see Event - * @since 1.0.0-SNAPSHOT - */ -public abstract class GenericRateLimitEvent extends Event { - - /** - * Constructs a new {@link GenericRateLimitEvent}. - */ - public GenericRateLimitEvent() { - - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/RateLimitExceededEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/RateLimitExceededEvent.java deleted file mode 100644 index 9c17ff0..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/events/ratelimit/RateLimitExceededEvent.java +++ /dev/null @@ -1,63 +0,0 @@ -package de.craftsblock.cnet.modules.security.events.ratelimit; - -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitAdapter; -import de.craftsblock.craftsnet.api.http.Exchange; - -import java.util.List; - -/** - * The {@link RateLimitExceededEvent} is triggered when one or more rate limits are exceeded for a specific HTTP request. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see GenericRateLimitEvent - * @see RateLimitAdapter - * @see Exchange - * @since 1.0.0-SNAPSHOT - */ -public class RateLimitExceededEvent extends GenericRateLimitEvent { - - private final Exchange exchange; - private final List exceeded; - - /** - * Constructs a new {@link RateLimitExceededEvent} with a given {@link Exchange} and a variable number of {@link RateLimitAdapter}s. - * - * @param exchange The {@link Exchange} representing the HTTP request that caused the rate limit to be exceeded. - * @param exceeded A variable number of {@link RateLimitAdapter}s responsible for exceeding the rate limits. - */ - public RateLimitExceededEvent(Exchange exchange, RateLimitAdapter... exceeded) { - this(exchange, List.of(exceeded)); - } - - /** - * Constructs a new {@link RateLimitExceededEvent} with a given {@link Exchange} and a list of {@link RateLimitAdapter}s. - * - * @param exchange The {@link Exchange} representing the HTTP request that caused the rate limit to be exceeded. - * @param exceeded A list of {@link RateLimitAdapter}s responsible for exceeding the rate limits. - */ - public RateLimitExceededEvent(Exchange exchange, List exceeded) { - this.exchange = exchange; - this.exceeded = exceeded; - } - - /** - * Gets the {@link Exchange} associated with this event. - * - * @return The {@link Exchange} associated with the rate limiting event. - */ - public Exchange getExchange() { - return exchange; - } - - /** - * Gets the list of {@link RateLimitAdapter}s responsible for the rate limit being exceeded. - * - * @return A list of {@link RateLimitAdapter}s that exceeded their limits. - */ - public List getExceeded() { - return exceeded; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/listeners/PreRequestListener.java b/src/main/java/de/craftsblock/cnet/modules/security/listeners/PreRequestListener.java deleted file mode 100644 index 5e0a617..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/listeners/PreRequestListener.java +++ /dev/null @@ -1,141 +0,0 @@ -package de.craftsblock.cnet.modules.security.listeners; - -import de.craftsblock.cnet.modules.security.CNetSecurity; -import de.craftsblock.cnet.modules.security.auth.AuthResult; -import de.craftsblock.cnet.modules.security.auth.chains.AuthChain; -import de.craftsblock.cnet.modules.security.events.auth.AuthFailedEvent; -import de.craftsblock.cnet.modules.security.events.auth.AuthSuccessEvent; -import de.craftsblock.cnet.modules.security.events.auth.GenericAuthResultEvent; -import de.craftsblock.craftscore.event.EventHandler; -import de.craftsblock.craftscore.event.EventPriority; -import de.craftsblock.craftscore.event.ListenerAdapter; -import de.craftsblock.craftscore.json.Json; -import de.craftsblock.craftsnet.CraftsNet; -import de.craftsblock.craftsnet.addon.meta.Startup; -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.http.Response; -import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; -import de.craftsblock.craftsnet.events.EventWithCancelReason; -import de.craftsblock.craftsnet.events.requests.PreRequestEvent; -import de.craftsblock.craftsnet.events.requests.routes.RouteRequestEvent; -import de.craftsblock.craftsnet.events.requests.shares.ShareRequestEvent; - -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; - -/** - * The PreRequestListener class listens for pre-request events and processes - * authentication chains to determine if an incoming request should be allowed. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.1.2 - * @since 1.0.0-SNAPSHOT - */ -@AutoRegister(startup = Startup.LOAD) -public class PreRequestListener implements ListenerAdapter { - - private final CraftsNet craftsNet; - - /** - * Constructs a new {@link PreRequestEvent}. - * - * @param craftsNet The {@link CraftsNet} instance bound to this {@link ListenerAdapter}. - */ - public PreRequestListener(CraftsNet craftsNet) { - this.craftsNet = craftsNet; - } - - /** - * Handles the {@link PreRequestEvent}. This method is triggered when a pre-request - * event occurs and processes the authentication chains. - * - * @param event The {@link PreRequestEvent} containing information about the request. - * @throws InvocationTargetException If an error occurs while calling / processing the event system - * @throws IllegalAccessException If an error occurs while calling / processing the event system - */ - @EventHandler - public void handleAuthChains(PreRequestEvent event) throws InvocationTargetException, IllegalAccessException { - if (event.isCancelled()) return; - - Exchange exchange = event.getExchange(); - final Request request = exchange.request(); - - // Iterate through each authentication chain - for (AuthChain chain : CNetSecurity.getAuthChainManager()) { - // Authenticate the incoming request using the current chain - AuthResult result = chain.authenticate(exchange); - - // Continue if the authentication was cancelled - if (!result.isCancelled()) continue; - - event.setCancelled(true); // Cancel the event - AuthFailedEvent authFailedEvent = new AuthFailedEvent(exchange); - - // Send an error response back to the client - Response response = exchange.response(); - if (!response.headersSent()) response.setCode(result.getCode()); - response.print(Json.empty().set("status", String.valueOf(result.getCode())) - .set("message", result.getCancelReason())); - - craftsNet.logger().debug("%s %s from %s \u001b[38;5;9m[%s]".formatted( - request.getHttpMethod(), - request.getRawUrl(), - request.getIp(), - "AUTH FAILED" - )); - - CNetSecurity.callEvent(authFailedEvent); - - return; - } - - AuthSuccessEvent authSuccessEvent = new AuthSuccessEvent(exchange); - CNetSecurity.callEvent(authSuccessEvent); - } - - /** - * Handles the {@link RouteRequestEvent}. This method is triggered when a route request - * event occurs and processes the rate limit chain. - * - * @param event The {@link RouteRequestEvent} containing information about the request. - */ - @EventHandler(priority = EventPriority.HIGH) - public void handleRateLimiter(RouteRequestEvent event) { - handleRateLimiter(event, event.getExchange()); - } - - /** - * Handles the {@link ShareRequestEvent}. This method is triggered when a share request - * event occurs and processes the rate limit chain. - * - * @param event The {@link ShareRequestEvent} containing information about the share request. - */ - @EventHandler(priority = EventPriority.HIGH) - public void handleRateLimiter(ShareRequestEvent event) { - handleRateLimiter(event, event.getExchange()); - } - - /** - * Processes the rate limit chain. - * - * @param event The {@link EventWithCancelReason} that was fired. - * @param exchange The {@link Exchange} containing information about the request. - */ - public void handleRateLimiter(EventWithCancelReason event, Exchange exchange) { - if (CNetSecurity.getRateLimitManager().isRateLimited(exchange)) { - // Cancel the event - event.setCancelled(true); - event.setCancelReason("RATE LIMITED"); - - // Send an error response back to the client - Response response = exchange.response(); - if (!response.headersSent()) exchange.response().setCode(429); - response.print(Json.empty() - .set("status", "429") - .set("message", "You have been rate limited!")); - } - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/listeners/StartupListener.java b/src/main/java/de/craftsblock/cnet/modules/security/listeners/StartupListener.java deleted file mode 100644 index cee52c6..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/listeners/StartupListener.java +++ /dev/null @@ -1,32 +0,0 @@ -package de.craftsblock.cnet.modules.security.listeners; - -import de.craftsblock.cnet.modules.security.auth.token.TokenManager; -import de.craftsblock.cnet.modules.security.auth.token.driver.storage.FileTokenStorageDriver; -import de.craftsblock.craftscore.event.EventHandler; -import de.craftsblock.craftscore.event.ListenerAdapter; -import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; -import de.craftsblock.craftsnet.events.addons.AllAddonsLoadedEvent; - -/** - * Initializes security related components after all addons have been loaded. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -@AutoRegister -public class StartupListener implements ListenerAdapter { - - /** - * Handles the {@link AllAddonsLoadedEvent} to initialize the token storage driver if none is set. - * - * @param event The {@link AllAddonsLoadedEvent} triggered when all addons have been loaded. - */ - @EventHandler - public void handleAllAddonLoaded(AllAddonsLoadedEvent event) { - if (TokenManager.getDriver() != null) return; - TokenManager.setDriver(new FileTokenStorageDriver()); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitAdapter.java deleted file mode 100644 index 968029c..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitAdapter.java +++ /dev/null @@ -1,164 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit; - -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.http.Response; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.Nullable; - -/** - * The {@link RateLimitAdapter} is an abstract class that defines the structure for rate limiting logic. - * It enforces rate limiting policies for incoming {@link Request}s by mapping them to {@link RateLimitIndex} objects. - * The adapter also manages configuration settings like maximum request count, expiration times, and response headers. - *

- * Subclasses must implement the {@link #adapt(Request, Session)} method to define custom rate limiting behavior. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.2 - * @see RateLimitIndex - * @see RateLimitInfo - * @see Request - * @since 1.0.0-SNAPSHOT - */ -public abstract class RateLimitAdapter { - - /** - * The maximum allowed expiration time in milliseconds (31 days). - */ - public static final long MAX_EXPIRE_MILLIS = (long) 31 * 24 * 60 * 60 * 1000; - - private final String id; - private final long max; - private final long expire; - private final boolean headers; - - /** - * Constructs a new {@link RateLimitAdapter} with the specified ID and maximum requests. - * The expiration time defaults to 60 seconds, and headers are included in the response. - * - * @param id The ID of the adapter (must contain only alphabetic characters). - * @param max The maximum number of requests allowed within the expiration period. - * @throws IllegalStateException If the ID is invalid. - * @see #RateLimitAdapter(String, long, long) - */ - public RateLimitAdapter(String id, long max) { - this(id, max, 1000 * 60); - } - - /** - * Constructs a new {@link RateLimitAdapter} with the specified ID, maximum requests, and expiration time. - * Headers are included in the response by default. - * - * @param id The ID of the adapter (must contain only alphabetic characters). - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds. - * @throws IllegalStateException If the ID is invalid. - * @see #RateLimitAdapter(String, long, long, boolean) - */ - public RateLimitAdapter(String id, long max, long expire) { - this(id, max, expire, true); - } - - /** - * Constructs a new {@link RateLimitAdapter} with the specified parameters. - * - * @param id The ID of the adapter (must contain only alphabetic characters). - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds (must be greater than 0 and less than or equal to {@link #MAX_EXPIRE_MILLIS}). - * @param headers Whether the rate limiting headers should be included in the response. - * @throws IllegalStateException If the ID is invalid. - * @throws AssertionError If the expiration time is not within the allowed range. - */ - public RateLimitAdapter(String id, long max, long expire, boolean headers) { - if (!id.matches("^[a-zA-Z]+$")) - throw new IllegalStateException("Rate limiting adapter IDs may only contain letters! (Invalid ID: '" + id + - "', set for: " + getClass().getName() + ")"); - - if (expire <= 0 || expire > MAX_EXPIRE_MILLIS) - throw new IllegalArgumentException("The expire time must be greater than 0 and less or equal than " + - MAX_EXPIRE_MILLIS + "! (Got: " + expire + ")"); - - this.id = id.toUpperCase(); - this.max = max; - this.expire = expire; - this.headers = headers; - } - - /** - * Maps a {@link Request} to a {@link RateLimitIndex}, defining how rate limits are applied. - * Subclasses must override this method to provide custom mapping logic. - * - * @param request The incoming HTTP request. - * @param session The session storage associated with the request. - * @return A {@link RateLimitIndex} representing the rate limit for the request, or {@code null} if no rate limit applies. - */ - public abstract @Nullable RateLimitIndex adapt(Request request, Session session); - - /** - * Creates a new {@link RateLimitInfo} instance for this adapter. - * - * @return A new {@link RateLimitInfo} instance. - */ - public RateLimitInfo createInfo() { - return RateLimitInfo.of(this); - } - - /** - * Appends rate limit information as HTTP headers to the response of the given {@link Exchange}. - *

This method adds the following headers to the response:

- *
    - *
  • X-RateLimit-Limit: Indicates the maximum number of requests allowed within the rate limit.
  • - *
  • X-RateLimit-Remaining: Indicates the remaining number of requests that can be made before the rate limit is exceeded.
  • - *
  • X-RateLimit-Reset: Indicates the time in milliseconds until the rate limit resets.
  • - *
- * - * @param exchange The {@link Exchange} representing the current HTTP request and response. - * @param info The {@link RateLimitInfo} containing the rate limit details for the current request. - */ - public void appendToResponse(final Exchange exchange, final RateLimitInfo info) { - final Response response = exchange.response(); - - response.addHeader("X-RateLimit-Limit", getId() + "=" + getMax()); - response.addHeader("X-RateLimit-Remaining", getId() + "=" + Math.max(0, getMax() - info.times().get())); - response.addHeader("X-RateLimit-Reset", getId() + "=" + Math.max(0, info.expiresAt().get() - System.currentTimeMillis())); - } - - /** - * Indicates whether rate limiting information should be included in the response headers. - * - * @return {@code true} if headers should be included, {@code false} otherwise. - */ - public boolean shouldBeInResponse() { - return headers; - } - - /** - * Gets the ID of this adapter. - * - * @return The ID of the adapter. - */ - public String getId() { - return id; - } - - /** - * Gets the maximum number of requests allowed within the expiration period. - * - * @return The maximum number of requests. - */ - public long getMax() { - return max; - } - - /** - * Gets the expiration time in milliseconds for this rate limit. - * - * @return The expiration time in milliseconds. - */ - public long getExpireInMilliseconds() { - return expire; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitIndex.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitIndex.java deleted file mode 100644 index f7344b3..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitIndex.java +++ /dev/null @@ -1,98 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Objects; - -/** - * The {@link RateLimitIndex} class represents a unique index for rate limiting purposes. - * It wraps an arbitrary {@link RateLimitIndex#source} object, which serves as the identifier for a rate limit. - *

- * This class is implemented as a record for immutability and concise representation. - *

- * - * @param source The object representing the source of the rate limit. - * This could be an IP address, user ID, or any other identifier. - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see Objects - * @since 1.0.0-SNAPSHOT - */ -public record RateLimitIndex(@Nullable RateLimitAdapter adapter, @NotNull Object source) { - - /** - * Compares this {@link RateLimitIndex} with another object for equality. - * Two {@link RateLimitIndex} instances are considered equal if their {@link #source} fields are equal. - * - * @param o The object to compare with this {@link RateLimitIndex}. - * @return {@code true} if the objects are equal, {@code false} otherwise. - */ - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - RateLimitIndex that = (RateLimitIndex) o; - - if (this.isGlobal()) { - if (!that.isGlobal() || !Objects.equals(this.adapter(), that.adapter())) - return false; - - } else if (this.adapter == null || !this.adapter.equals(that.adapter)) - return false; - - return Objects.equals(this.source(), that.source()); - } - - /** - * Computes the hash code for this {@link RateLimitIndex}. - * The hash code is derived from the {@link #source} object. - * - * @return The hash code of this {@link RateLimitIndex}. - */ - @Override - public int hashCode() { - return Objects.hashCode(source); - } - - /** - * Checks whether this {@link RateLimitIndex} should be treated globally or - * per {@link RateLimitAdapter}. - * - * @return {@code true} if this {@link RateLimitIndex} should be treated globally, - * {@code false} otherwise. - */ - public boolean isGlobal() { - return this.adapter() == null; - } - - /** - * Factory method to create a new global {@link RateLimitIndex} instance, - * with an instance of {@link Object} as source. - * - * @param source The source object to use as the identifier for the rate limit. - * @return A new {@link RateLimitIndex} instance wrapping the specified {@link RateLimitIndex#source}. - */ - public static RateLimitIndex of(@NotNull Object source) { - return new RateLimitIndex(null, source); - } - - /** - * Factory method to create a new {@link RateLimitIndex} instance, - * with an instance of {@link RateLimitAdapter} and an {@link Object} as source. - * - *

- * When the {@link RateLimitAdapter} is set to {@code null}, the index should be - * treated globally. - *

- * - * @param adapter The {@link RateLimitAdapter} that created the index. - * @param source The source object to use as the identifier for the rate limit. - * @return A new {@link RateLimitIndex} instance wrapping the specified {@link RateLimitIndex#source}. - */ - public static RateLimitIndex of(@Nullable RateLimitAdapter adapter, @NotNull Object source) { - return new RateLimitIndex(adapter, source); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitInfo.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitInfo.java deleted file mode 100644 index e47d9e4..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitInfo.java +++ /dev/null @@ -1,96 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit; - -import java.util.concurrent.atomic.AtomicLong; - -/** - * The {@link RateLimitInfo} class encapsulates information about rate limiting for a specific {@link RateLimitAdapter}. - * It tracks the number of accesses, the expiration time, and provides mechanisms to enforce rate limiting rules. - *

- * This record is immutable in structure, with thread-safe handling of internal state using {@link AtomicLong}. - *

- * - * @param adapter The {@link RateLimitAdapter} that defines the rate limiting configuration. - * @param times An {@link AtomicLong} tracking the number of accesses. - * @param expiresAt An {@link AtomicLong} representing the expiration timestamp in milliseconds. - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see RateLimitAdapter - * @see AtomicLong - * @since 1.0.0-SNAPSHOT - */ -public record RateLimitInfo(RateLimitAdapter adapter, AtomicLong times, AtomicLong expiresAt) { - - /** - * Gets the expiration time as a new {@link AtomicLong}. - * This prevents external modification of the internal expiration timestamp. - * - * @return A new {@link AtomicLong} representing the expiration timestamp. - */ - public AtomicLong expiresAt() { - return new AtomicLong(expiresAt.get()); - } - - /** - * Attempts to access the resource controlled by this rate limit. - *

- * If the rate limit is expired, it resets the state. If the access count exceeds the maximum allowed, - * the method returns {@code true}, indicating the rate limit has been exceeded. Otherwise, it increments - * the access count and returns {@code false}. - *

- * - * @return {@code true} if the rate limit is exceeded, {@code false} otherwise. - */ - public boolean access() { - if (resetIfExpired()) return false; - if (times().get() >= adapter.getMax()) return true; - - times().incrementAndGet(); - return false; - } - - /** - * Checks if the rate limit has expired and resets it if necessary. - * - * @return {@code true} if the rate limit was expired and has been reset, {@code false} otherwise. - */ - public boolean resetIfExpired() { - if (isExpired()) { - reset(); - return true; - } - - return false; - } - - /** - * Resets the rate limit by setting the access count to zero and updating the expiration timestamp. - */ - public void reset() { - times().set(0); - expiresAt.set(System.currentTimeMillis() + adapter().getExpireInMilliseconds()); - } - - /** - * Checks whether the rate limit has expired based on the current system time. - * - * @return {@code true} if the rate limit has expired, {@code false} otherwise. - */ - public boolean isExpired() { - return expiresAt.get() <= System.currentTimeMillis(); - } - - /** - * Creates a new {@link RateLimitInfo} instance with the specified {@link RateLimitAdapter}. - * The access count and expiration timestamp are initialized and the state is reset. - * - * @param adapter The {@link RateLimitAdapter} to associate with this rate limit information. - * @return A new {@link RateLimitInfo} instance. - */ - public static RateLimitInfo of(RateLimitAdapter adapter) { - RateLimitInfo info = new RateLimitInfo(adapter, new AtomicLong(-1), new AtomicLong(-1)); - info.reset(); - return info; - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitManager.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitManager.java deleted file mode 100644 index 28f03ee..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/RateLimitManager.java +++ /dev/null @@ -1,121 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit; - -import de.craftsblock.cnet.modules.security.CNetSecurity; -import de.craftsblock.cnet.modules.security.events.ratelimit.RateLimitExceededEvent; -import de.craftsblock.cnet.modules.security.utils.Manager; -import de.craftsblock.craftsnet.api.http.Exchange; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.NotNull; - -import java.lang.reflect.InvocationTargetException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Stream; - -/** - * The {@link RateLimitManager} manages rate limiting adapters and their associated indices. - * It handles the registration of adapters, checks for rate limiting conditions, and removes expired rate limit entries. - *

- * This class is thread-safe, using {@link ConcurrentHashMap} to store adapters and indices. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @see RateLimitAdapter - * @see RateLimitIndex - * @see RateLimitInfo - * @since 1.0.0-SNAPSHOT - */ -public class RateLimitManager implements Manager { - - private final ConcurrentHashMap adapters = new ConcurrentHashMap<>(); - private final ConcurrentHashMap indices = new ConcurrentHashMap<>(); - - /** - * Registers a {@link RateLimitAdapter} to this manager. - * - * @param adapter The {@link RateLimitAdapter} to register. - * @throws IllegalStateException If an adapter with the same ID is already registered. - */ - public void register(@NotNull RateLimitAdapter adapter) { - String id = adapter.getId(); - if (adapters.containsKey(id)) - throw new IllegalStateException("Tried to register rate limit adapter with id " + id + " for " + adapter.getClass().getName() + - ", but this id is already taken by " + adapters.get(id).getClass().getName() + "!"); - - this.adapters.put(id, adapter); - } - - /** - * Unregisters a {@link RateLimitAdapter} from this manager. - * - * @param adapter The {@link RateLimitAdapter} to unregister. - */ - public void unregister(@NotNull RateLimitAdapter adapter) { - this.adapters.remove(adapter.getId()); - } - - /** - * Checks whether a {@link RateLimitAdapter} is registered with this manager. - * - * @param adapter The {@link RateLimitAdapter} to check. - * @return {@code true} if the adapter is registered, {@code false} otherwise. - */ - public boolean isRegistered(@NotNull RateLimitAdapter adapter) { - return this.adapters.containsKey(adapter.getId()); - } - - /** - * Determines whether the given {@link Exchange} is rate limited by any registered adapter. - * If rate limited, the appropriate headers are added to the response. - * - * @param exchange The {@link Exchange} to check for rate limiting. - * @return {@code true} if the request is rate limited, {@code false} otherwise. - */ - public boolean isRateLimited(@NotNull Exchange exchange) { - if (this.adapters.isEmpty()) return false; - - final Request request = exchange.request(); - final Session session = exchange.session(); - - List exceeded = new ArrayList<>(); - for (RateLimitAdapter adapter : adapters.values()) { - RateLimitIndex index = adapter.adapt(request, session); - if (index == null) continue; - - RateLimitInfo info = indices.computeIfAbsent(index, r -> adapter.createInfo()); - if (info.access()) exceeded.add(adapter); - - if (adapter.shouldBeInResponse()) - adapter.appendToResponse(exchange, info); - } - - if (exceeded.isEmpty()) return false; - - try { - CNetSecurity.callEvent(new RateLimitExceededEvent(exchange, exceeded)); - } catch (InvocationTargetException | IllegalAccessException e) { - throw new RuntimeException(e); - } - - return true; - } - - /** - * Cleans up expired rate limit entries from the indices map. - * This method uses parallel streams if the number of entries exceeds 100 for better performance. - */ - public void tick() { - Stream> stream; - if (indices.size() >= 100) stream = indices.entrySet().parallelStream(); - else stream = indices.entrySet().stream(); - - stream.filter(entry -> entry.getValue().isExpired()) - .forEach(entry -> indices.remove(entry.getKey())); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/IPRateLimitAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/IPRateLimitAdapter.java deleted file mode 100644 index c93051a..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/IPRateLimitAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit.builtin; - -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitAdapter; -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitIndex; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.Nullable; - -/** - * The {@link IPRateLimitAdapter} is a builtin implementation of {@link RateLimitAdapter}. - * It enforces rate limiting based on the client's IP address. - *

- * Each unique IP address is tracked as a {@link RateLimitIndex}, and rate limits are applied individually. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @see RateLimitAdapter - * @see RateLimitIndex - * @since 1.0.0-SNAPSHOT - */ -public class IPRateLimitAdapter extends RateLimitAdapter { - - /** - * The id of the {@link IPRateLimitAdapter}. - */ - public static final String ID = "IP"; - - /** - * Constructs a new {@link IPRateLimitAdapter} with the default rate limit of one request per period. - * - * @param max The maximum number of requests allowed within the expiration period. - */ - public IPRateLimitAdapter(long max) { - super(ID, max); - } - - /** - * Constructs a new {@link IPRateLimitAdapter} with the default rate limit of one request per period. - * - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds (must be greater than 0 and less than or equal to {@link #MAX_EXPIRE_MILLIS}). - */ - public IPRateLimitAdapter(long max, long expire) { - super(ID, max, expire); - } - - /** - * Constructs a new {@link IPRateLimitAdapter} with the default rate limit of one request per period. - * - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds (must be greater than 0 and less than or equal to {@link #MAX_EXPIRE_MILLIS}). - * @param headers Whether the rate limiting headers should be included in the response. - */ - public IPRateLimitAdapter(long max, long expire, boolean headers) { - super(ID, max, expire, headers); - } - - /** - * Adapts the given {@link Request} into a {@link RateLimitIndex} based on the client's IP address. - * - * @param request The {@link Request} to adapt. - * @param session The {@link Session} associated with the request. - * @return A {@link RateLimitIndex} representing the client's IP address, or {@code null} if adaptation fails. - */ - @Override - public @Nullable RateLimitIndex adapt(Request request, Session session) { - return RateLimitIndex.of(this, request.getIp()); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/TokenRateLimitAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/TokenRateLimitAdapter.java deleted file mode 100644 index 47e0380..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/ratelimit/builtin/TokenRateLimitAdapter.java +++ /dev/null @@ -1,78 +0,0 @@ -package de.craftsblock.cnet.modules.security.ratelimit.builtin; - -import de.craftsblock.cnet.modules.security.auth.token.Token; -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitAdapter; -import de.craftsblock.cnet.modules.security.ratelimit.RateLimitIndex; -import de.craftsblock.craftsnet.api.http.Request; -import de.craftsblock.craftsnet.api.session.Session; -import org.jetbrains.annotations.Nullable; - -/** - * The {@link TokenRateLimitAdapter} is a builtin implementation of {@link RateLimitAdapter}. - * It enforces rate limiting based on the authentication token stored in the {@link Session}. - *

- * Each unique token is tracked as a {@link RateLimitIndex}, and rate limits are applied individually. - *

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.1 - * @see RateLimitAdapter - * @see RateLimitIndex - * @see Token - * @since 1.0.0-SNAPSHOT - */ -public class TokenRateLimitAdapter extends RateLimitAdapter { - - /** - * The id of the {@link TokenRateLimitAdapter}. - */ - public static final String ID = "TOKEN"; - - /** - * Constructs a new {@link TokenRateLimitAdapter} with the default rate limit of 60 requests per period. - * - * @param max The maximum number of requests allowed within the expiration period. - */ - public TokenRateLimitAdapter(long max) { - super(ID, max); - } - - /** - * Constructs a new {@link TokenRateLimitAdapter} with the default rate limit of 60 requests per period. - * - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds (must be greater than 0 and less than or equal to {@link #MAX_EXPIRE_MILLIS}). - */ - public TokenRateLimitAdapter(long max, long expire) { - super(ID, max, expire); - } - - /** - * Constructs a new {@link TokenRateLimitAdapter} with the default rate limit of 60 requests per period. - * - * @param max The maximum number of requests allowed within the expiration period. - * @param expire The expiration time in milliseconds (must be greater than 0 and less than or equal to {@link #MAX_EXPIRE_MILLIS}). - * @param headers Whether the rate limiting headers should be included in the response. - */ - public TokenRateLimitAdapter(long max, long expire, boolean headers) { - super(ID, max, expire, headers); - } - - /** - * Adapts the given {@link Request} into a {@link RateLimitIndex} based on the authentication token stored in the {@link Session}. - *

- * If the session storage does not contain a valid authentication token, the method returns {@code null}. - *

- * - * @param request The {@link Request} to adapt. - * @param session The {@link Session} associated with the request, expected to contain the authentication token. - * @return A {@link RateLimitIndex} representing the token, or {@code null} if no token is found. - */ - @Override - public @Nullable RateLimitIndex adapt(Request request, Session session) { - if (!session.containsKey("auth.token")) return null; - return RateLimitIndex.of(this, session.getAsType("auth.token", Token.class)); - } - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java b/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java new file mode 100644 index 0000000..3250722 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java @@ -0,0 +1,75 @@ +package de.craftsblock.cnet.modules.security.token; + +import com.google.gson.JsonObject; +import de.craftsblock.craftscore.json.Json; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; +import org.springframework.security.crypto.bcrypt.BCrypt; + +import java.util.*; +import java.util.stream.IntStream; + +public record Token(long id, @NotNull String hash, @NotNull Collection scopes, @NotNull TokenDataContainer tokenDataContainer) { + + public Token(long id, @NotNull String hash, @NotNull Collection scopes, @NotNull TokenDataContainer tokenDataContainer) { + this.id = id; + this.hash = hash; + this.scopes = Collections.unmodifiableCollection(scopes); + this.tokenDataContainer = tokenDataContainer; + } + + @Override + public @NotNull @UnmodifiableView Collection scopes() { + return scopes; + } + + public boolean validate(byte @NotNull [] secret) { + return BCrypt.checkpw(secret, hash); + } + + public Json toJson() { + Json json = Json.empty() + .set("id", this.id) + .set("hash", this.hash) + .set("scopes", this.scopes); + + Map serializedTokenDataContainer = this.tokenDataContainer.serializeToMap(); + serializedTokenDataContainer.forEach((key, data) -> json.set( + "token_data_container." + key, + IntStream.range(0, data.length) + .mapToObj(i -> data[i]) + .toList() + )); + + if (!json.contains("token_data_container")) { + json.set("token_data_container", new JsonObject()); + } + + return json; + } + + public static Token fromJson(Json json) { + Json jsonTokenDataContainer = json.getJson("token_data_container", Json.empty()); + Map serializedTokenDataContainer = new HashMap<>(); + + jsonTokenDataContainer.keySet().forEach(key -> { + List dataList = (List) jsonTokenDataContainer.getByteList(key); + byte[] data = new byte[dataList.size()]; + + for (int i = 0; i < dataList.size(); i++) { + data[i] = dataList.get(i); + } + + serializedTokenDataContainer.put(key, data); + }); + + TokenDataContainer tokenDataContainer = new TokenDataContainer(serializedTokenDataContainer); + return new Token( + json.getLong("id"), + json.getString("hash"), + json.getStringList("scopes"), + tokenDataContainer + ); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/TokenDataContainer.java b/src/main/java/de/craftsblock/cnet/modules/security/token/TokenDataContainer.java new file mode 100644 index 0000000..a02faef --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/TokenDataContainer.java @@ -0,0 +1,68 @@ +package de.craftsblock.cnet.modules.security.token; + +import de.craftsblock.craftscore.buffer.BufferUtil; +import de.craftsblock.craftscore.buffer.ObjectSerializer; +import de.craftsblock.craftsnet.utils.reflection.TypeUtils; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class TokenDataContainer extends ConcurrentHashMap { + + public TokenDataContainer() { + } + + public TokenDataContainer(byte[] data) { + BufferUtil buffer = BufferUtil.wrap(data); + + while (buffer.hasRemainingBytes()) { + String key = buffer.getUtf(); + byte[] value = buffer.getNBytes(buffer.getVarInt()); + this.put(key, ObjectSerializer.deserialize(value)); + } + } + + public TokenDataContainer(Map data) { + data.forEach((key, value) -> this.put(key, ObjectSerializer.deserialize(value))); + } + + public T getTyped(@NotNull String key, @NotNull Class type) { + return this.getOrDefaultTyped(key, type, null); + } + + @SuppressWarnings("unchecked") + public T getOrDefaultTyped(@NotNull String key, @NotNull Class type, T orElse) { + if (!containsKey(key)) return orElse; + return (T) get(key); + } + + public boolean isType(@NotNull String key, @NotNull Class type) { + if (!containsKey(key)) return false; + return TypeUtils.isAssignable(type, get(key).getClass()); + } + + public byte[] serializeToBytes() { + BufferUtil buffer = BufferUtil.allocate(0); + + this.forEach((key, value) -> { + byte[] serialized = ObjectSerializer.serialize(value); + + buffer.ensure(8 + key.getBytes(StandardCharsets.UTF_8).length + serialized.length) + .putUtf(key) + .putVarInt(serialized.length) + .with(raw -> raw.put(serialized)); + }); + + return buffer.trim().toByteArray(); + } + + public Map serializeToMap() { + Map serialized = new HashMap<>(); + this.forEach((key, value) -> serialized.put(key, ObjectSerializer.serialize(value))); + return serialized; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java b/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java new file mode 100644 index 0000000..7f2dbef --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java @@ -0,0 +1,95 @@ +package de.craftsblock.cnet.modules.security.token; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.event.TokenCreateEvent; +import de.craftsblock.cnet.modules.security.token.util.NewToken; +import de.craftsblock.cnet.modules.security.token.util.TokenParts; +import de.craftsblock.cnet.modules.security.token.util.TokenUtil; +import de.craftsblock.craftscore.cache.Cache; +import de.craftsblock.craftscore.utils.id.Snowflake; +import de.craftsblock.craftsnet.utils.PassphraseUtils; +import org.springframework.security.crypto.bcrypt.BCrypt; + +import java.util.Collection; +import java.util.List; + +public class TokenManager { + + private final Cache tokenCache = new Cache<>(25); + + public void persist(Token token) { + CraftsNetSecurity.getTokenStoreDriver().save(token); + } + + public void delete(Token token) { + CraftsNetSecurity.getTokenStoreDriver().delete(token); + } + + public Token getToken(long id) { + if (tokenCache.containsKey(id)) { + return tokenCache.get(id); + } + + if (!CraftsNetSecurity.getTokenStoreDriver().exists(id)) { + return null; + } + + Token token = CraftsNetSecurity.getTokenStoreDriver().load(id); + tokenCache.put(token.id(), token); + return token; + } + + public Token getValidatedToken(String token) { + TokenParts parts = TokenUtil.splitToTokenParts(token); + if (parts == null) { + return null; + } + + try { + Token realToken = getToken(parts.id()); + if (realToken == null || !realToken.validate(parts.secret())) { + return null; + } + + return realToken; + } finally { + PassphraseUtils.erase(parts.secret()); + } + } + + public NewToken newPersistedToken(String... scopes) { + return this.newPersistedToken(List.of(scopes)); + } + + public NewToken newPersistedToken(Collection scopes) { + NewToken newToken = newToken(scopes); + persist(newToken.token()); + return newToken; + } + + public NewToken newToken(String... scopes) { + return this.newToken(List.of(scopes)); + } + + public NewToken newToken(Collection scopes) { + long id = Snowflake.generate(); + byte[] secret = TokenUtil.newSecureSecret(); + String secretHash = BCrypt.hashpw(secret, BCrypt.gensalt()); + + try { + Token token = new Token(id, secretHash, scopes, new TokenDataContainer()); + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenCreateEvent(token)); + return new NewToken( + token, + TokenUtil.mergeTokenParts(id, secret) + ); + } finally { + PassphraseUtils.erase(secret); + } + } + + public void clearCache() { + this.tokenCache.clear(); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java new file mode 100644 index 0000000..0f038be --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java @@ -0,0 +1,93 @@ +package de.craftsblock.cnet.modules.security.token.adapter; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.cnet.modules.security.auth.adapter.AuthAdapter; +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenUsedEvent; +import de.craftsblock.craftsnet.api.http.Exchange; +import de.craftsblock.craftsnet.api.http.Request; +import de.craftsblock.craftsnet.api.session.Session; + +import java.util.EnumMap; +import java.util.concurrent.atomic.AtomicReference; + +public class HttpTokenAuthAdapter implements AuthAdapter.Http { + + /** + * The expected authorization type for bearer tokens. + */ + public static final String HEADER_AUTH_TYPE = "bearer"; + + private final EnumMap authTypes; + + public HttpTokenAuthAdapter(EnumMap authTypes) { + this.authTypes = authTypes; + } + + @Override + public AuthResult authenticate(Exchange exchange) { + if (authTypes == null || authTypes.isEmpty()) { + CraftsNetSecurity.getInstance().getLogger().warning("No http token auth type is set up!"); + return AuthResult.failure("Not allowed!"); + } + + AtomicReference authResultReference = new AtomicReference<>(); + authTypes.forEach((authType, location) -> { + AuthResult previous = authResultReference.get(); + if (previous != null && !previous.isSkip()) { + return; + } + + AuthResult result = authenticate(exchange, authType, location); + authResultReference.set(result); + }); + + AuthResult result = authResultReference.get(); + if (result == null || !result.isOk()) { + return result != null && result.isFailure() ? result : AuthResult.failure("Not allowed!"); + } + + return AuthResult.ok(); + } + + public AuthResult authenticate(Exchange exchange, HttpTokenAuthType authType, String location) { + final Request request = exchange.request(); + final Session session = exchange.session(); + + String plainToken = switch (authType) { + case HEADER -> { + String auth_header = request.getHeader(location); + if (auth_header == null) { + yield null; + } + + String[] header = auth_header.split(" ", 2); + if (header.length != 2 || !HEADER_AUTH_TYPE.equalsIgnoreCase(header[0])) { + yield HEADER_AUTH_TYPE; + } + + yield header[1]; + } + case COOKIE -> { + var cookies = request.getCookies(); + yield cookies.containsKey(location) ? cookies.get(location).getValue() : null; + } + case SESSION -> session.getTyped(location, String.class); + }; + + if (plainToken == null || plainToken.isBlank()) { + return AuthResult.skip(); + } + + Token token = CraftsNetSecurity.getTokenManager().getValidatedToken(plainToken); + if (token == null) { + return AuthResult.failure("Not allowed! 2"); + } + + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenUsedEvent(token)); + exchange.context().put(token); + return AuthResult.ok(); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java new file mode 100644 index 0000000..9c7e7ae --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java @@ -0,0 +1,28 @@ +package de.craftsblock.cnet.modules.security.token.adapter; + +public enum HttpTokenAuthType { + + HEADER("Authorization"), + COOKIE(), + SESSION(), + ; + + private final String defaultLocation; + + HttpTokenAuthType() { + this(null); + } + + HttpTokenAuthType(String defaultLocation) { + this.defaultLocation = defaultLocation; + } + + public String getDefaultLocation() { + return defaultLocation; + } + + public boolean hasDefaultLocation() { + return defaultLocation != null; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java new file mode 100644 index 0000000..f28cd04 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java @@ -0,0 +1,98 @@ +package de.craftsblock.cnet.modules.security.token.adapter; + +import com.google.gson.JsonSyntaxException; +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.auth.AuthResult; +import de.craftsblock.cnet.modules.security.auth.adapter.AuthAdapter; +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenUsedEvent; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftscore.json.JsonParser; +import de.craftsblock.craftsnet.api.utils.Context; +import de.craftsblock.craftsnet.api.websocket.ClosureCode; +import de.craftsblock.craftsnet.api.websocket.Opcode; +import de.craftsblock.craftsnet.api.websocket.SocketExchange; +import de.craftsblock.craftsnet.api.websocket.WebSocketClient; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.events.sockets.message.IncomingSocketMessageEvent; +import de.craftsblock.craftsnet.events.sockets.message.OutgoingSocketMessageEvent; + +@AutoRegister +public class WebSocketTokenAuthAdapter implements ListenerAdapter, AuthAdapter.WebSocket { + + private static final String MESSAGE_LITERAL_WRONG_AUTH = "Not allowed!"; + private static final Json MESSAGE_WRONG_AUTH = Json.empty() + .set("success", false) + .set("error.code", 400) + .set("error.message", MESSAGE_LITERAL_WRONG_AUTH); + + @Override + public AuthResult authenticate(SocketExchange exchange) { + exchange.context().put(new RequireAuth()); + return AuthResult.skip(); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreWhenCancelled = true) + public void handleIncomingMessage(IncomingSocketMessageEvent event) { + final SocketExchange exchange = event.getExchange(); + final Context context = exchange.context(); + if (!context.containsKey(RequireAuth.class)) { + return; + } + + final WebSocketClient client = event.getClient(); + event.setCancelled(true); + if (!event.getOpcode().equals(Opcode.TEXT)) { + failAuth(client, "NOT TEXT"); + return; + } + + try { + String message = event.getUtf8(); + Json json = JsonParser.parse(message); + if (!json.contains("token")) { + failAuth(client, "NO TOKEN"); + return; + } + + Token token = CraftsNetSecurity.getTokenManager().getValidatedToken(json.getString("token")); + if (token == null) { + failAuth(client, "WRONG TOKEN"); + return; + } + + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenUsedEvent(token)); + context.put(token); + context.remove(RequireAuth.class); + } catch (JsonSyntaxException ignored) { + failAuth(client, "NOT A JSON"); + } + } + + private void failAuth(WebSocketClient client, String reason) { + client.sendMessage(MESSAGE_WRONG_AUTH); + CraftsNetSecurity.getInstance().getLogger().debug("%s failed to authenticate \u001b[38;5;9m[%s]", client.getIp(), reason); + client.close(ClosureCode.NORMAL, MESSAGE_LITERAL_WRONG_AUTH); + } + + @EventHandler(ignoreWhenCancelled = true) + public void handleOutgoingMessage(OutgoingSocketMessageEvent event) { + final SocketExchange exchange = event.getExchange(); + if (!exchange.context().containsKey(RequireAuth.class)) { + return; + } + + if (event.getOpcode().equals(Opcode.TEXT) && event.getUtf8().equals(MESSAGE_WRONG_AUTH.toString())) { + return; + } + + event.setCancelled(true); + } + + private static class RequireAuth { + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/driver/TokenStoreDriver.java b/src/main/java/de/craftsblock/cnet/modules/security/token/driver/TokenStoreDriver.java new file mode 100644 index 0000000..85fea96 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/driver/TokenStoreDriver.java @@ -0,0 +1,37 @@ +package de.craftsblock.cnet.modules.security.token.driver; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenDeleteEvent; +import de.craftsblock.cnet.modules.security.token.event.TokenPersistEvent; + +import java.util.Collection; + +public interface TokenStoreDriver extends AutoCloseable { + + default void reload() { + CraftsNetSecurity.getTokenManager().clearCache(); + } + + boolean exists(long id); + + Token load(long id); + + default void save(Token token) { + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenPersistEvent(token)); + } + + default void delete(long id) { + this.delete(load(id)); + } + + default void delete(Token token) { + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenDeleteEvent(token)); + } + + Collection getAllTokenIds(); + + @Override + void close(); + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java b/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java new file mode 100644 index 0000000..ac5c2d3 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java @@ -0,0 +1,178 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftscore.json.JsonParser; +import de.craftsblock.craftsnet.logging.Logger; +import de.craftsblock.craftsnet.utils.reflection.TypeUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.util.Collection; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +public class FileTokenStoreDriver implements TokenStoreDriver { + + public static final int WARN_AT_FILE_SIZE = 1024 * 1024 * 15; + + private final @NotNull Path tokensDirectory; + private final @NotNull Path tokensFile; + private final @NotNull AtomicReference tokens = new AtomicReference<>(); + + private final @NotNull Thread watchThread; + private final @NotNull WatchService watchService; + + private boolean closed = false; + + public FileTokenStoreDriver(@NotNull Path tokensFile) { + this.tokensFile = tokensFile; + this.tokensDirectory = tokensFile.toAbsolutePath().getParent(); + + try { + if (Files.notExists(tokensDirectory)) { + Files.createDirectories(tokensDirectory); + } + + if (Files.notExists(tokensFile)) { + Files.createFile(tokensFile); + } + + long size = Files.size(tokensFile); + if (size >= WARN_AT_FILE_SIZE) { + Logger logger = CraftsNetSecurity.getInstance().getLogger(); + logger.warning( + "The token store is larger than %s MB (%s MB), which may cause slowdowns!", + WARN_AT_FILE_SIZE / 1024 / 1024, size / 1024 / 1024 + ); + logger.warning("Please consider using a database."); + } + + this.reload(); + this.watchService = FileSystems.getDefault().newWatchService(); + this.tokensDirectory.register(this.watchService, StandardWatchEventKinds.ENTRY_MODIFY); + + this.watchThread = new Thread(() -> { + try { + WatchKey key; + while ((key = watchService.take()) != null) { + for (WatchEvent event : key.pollEvents()) { + if (!TypeUtils.isAssignable(Path.class, event.kind().type())) { + continue; + } + + Path path = (Path) event.context(); + Path realPath = tokensDirectory.resolve(path); + if (realPath.equals(tokensFile.toAbsolutePath())) { + CraftsNetSecurity.getInstance().getLogger().debug("Detected file system change, " + + "reloading token file."); + this.reload(); + } + } + key.reset(); + } + } catch (InterruptedException ignored) { + } + }, "Token file watcher"); + this.watchThread.start(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read file: " + e.getMessage(), e); + } + } + + public void reload() { + ensureOpen(); + synchronized (this.tokens) { + this.tokens.set(JsonParser.parse(tokensFile)); + TokenStoreDriver.super.reload(); + } + } + + @Override + public boolean exists(long id) { + ensureOpen(); + synchronized (this.tokens) { + return this.tokens.get().contains(String.valueOf(id)); + } + } + + @Override + public Token load(long id) { + ensureOpen(); + Json token; + + synchronized (this.tokens) { + token = this.tokens.get().getJson(String.valueOf(id)); + } + + if (token == null) { + throw new IllegalStateException("Token for id %s not found".formatted(id)); + } + + return Token.fromJson(token); + } + + @Override + public void save(@NotNull Token token) { + ensureOpen(); + Json json = token.toJson(); + + synchronized (this.tokens) { + this.tokens.get().set(String.valueOf(token.id()), json); + this.tokens.get().save(tokensFile); + } + } + + @Override + public void delete(Token token) { + ensureOpen(); + synchronized (this.tokens) { + this.tokens.get().remove(String.valueOf(token.id())); + TokenStoreDriver.super.delete(token); + } + } + + @Override + public Collection getAllTokenIds() { + ensureOpen(); + Set stringIds; + + synchronized (this.tokens) { + stringIds = this.tokens.get().keySet(); + } + + return stringIds.stream() + .map(Long::parseLong) + .toList(); + } + + public void ensureOpen() { + if (closed) { + throw new IllegalStateException("No operations allowed after closure!"); + } + } + + @Override + public void close() { + ensureOpen(); + try { + this.watchThread.interrupt(); + this.watchThread.join(); + } catch (InterruptedException ignored) { + } + + try { + this.watchService.close(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to close: " + e.getMessage(), e); + } + + this.tokens.set(null); + this.closed = true; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java new file mode 100644 index 0000000..17991f5 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java @@ -0,0 +1,11 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; + +public final class TokenCreateEvent extends TokenEvent { + + public TokenCreateEvent(Token token) { + super(token); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java new file mode 100644 index 0000000..5125b75 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java @@ -0,0 +1,11 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; + +public final class TokenDeleteEvent extends TokenEvent { + + public TokenDeleteEvent(Token token) { + super(token); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenEvent.java new file mode 100644 index 0000000..ee28fed --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenEvent.java @@ -0,0 +1,19 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.craftscore.event.Event; + +public abstract sealed class TokenEvent extends Event + permits TokenCreateEvent, TokenDeleteEvent, TokenPersistEvent, TokenUsedEvent { + + private final Token token; + + public TokenEvent(Token token) { + this.token = token; + } + + public Token getToken() { + return token; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java new file mode 100644 index 0000000..4ccf915 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java @@ -0,0 +1,11 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; + +public final class TokenPersistEvent extends TokenEvent { + + public TokenPersistEvent(Token token) { + super(token); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java new file mode 100644 index 0000000..02606de --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java @@ -0,0 +1,11 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; + +public final class TokenUsedEvent extends TokenEvent { + + public TokenUsedEvent(Token token) { + super(token); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java b/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java new file mode 100644 index 0000000..d313f97 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java @@ -0,0 +1,28 @@ +package de.craftsblock.cnet.modules.security.token.listener; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.file.FileTokenStoreDriver; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.events.addons.AllAddonsLoadedEvent; + +import java.nio.file.Path; + +@AutoRegister +public class TokenPostSetupListener implements ListenerAdapter { + + @EventHandler(priority = EventPriority.HIGHEST) + public void handleAllAddonsLoaded(AllAddonsLoadedEvent event) { + TokenStoreDriver currentDriver = CraftsNetSecurity.getTokenStoreDriver(); + if (currentDriver != null) { + return; + } + + Path dataPath = CraftsNetSecurity.getInstance().getDataPath(); + CraftsNetSecurity.setTokenStoreDriver(new FileTokenStoreDriver(dataPath.resolve("tokens.json"))); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/scope/RequireScope.java b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/RequireScope.java new file mode 100644 index 0000000..7074c19 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/RequireScope.java @@ -0,0 +1,18 @@ +package de.craftsblock.cnet.modules.security.token.scope; + +import de.craftsblock.craftsnet.api.requirements.meta.RequirementMeta; +import de.craftsblock.craftsnet.api.requirements.meta.RequirementStore; +import de.craftsblock.craftsnet.api.requirements.meta.RequirementType; + +import java.lang.annotation.*; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +@RequirementMeta(type = RequirementType.STORING) +public @interface RequireScope { + + @RequirementStore + String[] value(); + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequest.java b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequest.java new file mode 100644 index 0000000..abbb2a1 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequest.java @@ -0,0 +1,9 @@ +package de.craftsblock.cnet.modules.security.token.scope; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.List; + +@ApiStatus.Internal +record ScopeRequest(List scopes) { +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java new file mode 100644 index 0000000..36bf237 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java @@ -0,0 +1,64 @@ +package de.craftsblock.cnet.modules.security.token.scope; + +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.api.RouteRegistry; +import de.craftsblock.craftsnet.api.http.Request; +import de.craftsblock.craftsnet.api.requirements.web.WebRequirement; +import de.craftsblock.craftsnet.api.requirements.websocket.WebSocketRequirement; +import de.craftsblock.craftsnet.api.utils.Context; +import de.craftsblock.craftsnet.api.websocket.WebSocketClient; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.List; + +@ApiStatus.Internal +public sealed interface ScopeRequirement + permits ScopeRequirement.Http, ScopeRequirement.WebSocket { + + default boolean injectRequest(Context context, RouteRegistry.EndpointMapping mapping) { + if (mapping.isPresent(getAnnotation(), "value")) { + List scopes = mapping.getRequirements(getAnnotation(), "value"); + context.put(new ScopeRequest(scopes)); + } else { + context.put(new ScopeRequest(Collections.emptyList())); + } + + return true; + } + + Class getAnnotation(); + + @ApiStatus.Internal + @AutoRegister(startup = Startup.LOAD) + final class Http extends WebRequirement implements ScopeRequirement { + + public Http() { + super(RequireScope.class); + } + + @Override + public boolean applies(Request request, RouteRegistry.EndpointMapping mapping) { + return injectRequest(request.getExchange().context(), mapping); + } + + } + + @ApiStatus.Internal + @AutoRegister(startup = Startup.LOAD) + final class WebSocket extends WebSocketRequirement implements ScopeRequirement { + + public WebSocket() { + super(RequireScope.class); + } + + @Override + public boolean applies(WebSocketClient webSocketClient, RouteRegistry.EndpointMapping mapping) { + return injectRequest(webSocketClient.getContext(), mapping); + } + + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java new file mode 100644 index 0000000..33546b0 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java @@ -0,0 +1,84 @@ +package de.craftsblock.cnet.modules.security.token.scope; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.craftscore.event.CancellableEvent; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.api.BaseExchange; +import de.craftsblock.craftsnet.api.http.Exchange; +import de.craftsblock.craftsnet.api.utils.Context; +import de.craftsblock.craftsnet.api.websocket.ClosureCode; +import de.craftsblock.craftsnet.api.websocket.SocketExchange; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.events.EventWithCancelReason; +import de.craftsblock.craftsnet.events.requests.routes.RouteRequestEvent; +import de.craftsblock.craftsnet.events.sockets.message.IncomingSocketMessageEvent; +import org.jetbrains.annotations.ApiStatus; + +import java.util.function.Consumer; + +@ApiStatus.Internal +@AutoRegister(startup = Startup.LOAD) +public class ScopeResolveMiddleware implements ListenerAdapter { + + private final Json MISSING_SCOPES_MESSAGE = Json.empty() + .set("success", false) + .set("error.code", 400) + .set("error.message", "Not allowed!"); + + private void handle(BaseExchange exchange, CancellableEvent event, T subject, Consumer onFailure) { + Context context = exchange.context(); + if (context == null || !context.containsKey(ScopeRequest.class)) { + return; + } + + if (!context.containsKey(Token.class)) { + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("NO TOKEN"); + } + + return; + } + + final Token token = context.getTyped(Token.class); + final ScopeRequest result = context.getTyped(ScopeRequest.class); + if (token.scopes().containsAll(result.scopes())) { + return; + } + + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("SCOPE MISMATCH"); + } + + onFailure.accept(subject); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreWhenCancelled = true) + public void handleRequest(RouteRequestEvent event) { + final Exchange exchange = event.getExchange(); + handle(exchange, event, exchange.response(), response -> { + if (!response.headersSent()) { + response.setCode(400); + } + + if (!response.sendingFile()) { + response.print(MISSING_SCOPES_MESSAGE); + } + }); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreWhenCancelled = true) + public void handleWebSocketMessage(IncomingSocketMessageEvent event) { + final SocketExchange exchange = event.getExchange(); + handle(exchange, event, exchange.client(), client -> { + client.sendMessage(MISSING_SCOPES_MESSAGE); + client.close(ClosureCode.NORMAL, "Not allowed!"); + }); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/util/NewToken.java b/src/main/java/de/craftsblock/cnet/modules/security/token/util/NewToken.java new file mode 100644 index 0000000..3f5f4db --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/util/NewToken.java @@ -0,0 +1,17 @@ +package de.craftsblock.cnet.modules.security.token.util; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.craftsnet.utils.PassphraseUtils; + +public record NewToken(Token token, byte[] plain) implements AutoCloseable { + + public String plainStringify() { + return PassphraseUtils.stringify(plain); + } + + @Override + public void close() { + PassphraseUtils.erase(plain); + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenParts.java b/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenParts.java new file mode 100644 index 0000000..0299224 --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenParts.java @@ -0,0 +1,4 @@ +package de.craftsblock.cnet.modules.security.token.util; + +public record TokenParts(String prefix, long id, byte[] secret) { +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java b/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java new file mode 100644 index 0000000..67ac0cd --- /dev/null +++ b/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java @@ -0,0 +1,74 @@ +package de.craftsblock.cnet.modules.security.token.util; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.craftscore.buffer.BufferUtil; +import de.craftsblock.craftsnet.utils.PassphraseUtils; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +public class TokenUtil { + + private static String TOKEN_PREFIX = "cnet_"; + private static final byte[] TOKEN_PART_SEPARATOR_BYTES = ".".getBytes(StandardCharsets.UTF_8); + + private TokenUtil() { + } + + public static byte[] newSecureSecret() { + return PassphraseUtils.generateSecure(45, 70, false); + } + + public static byte[] mergeTokenParts(long id, byte[] secret) { + byte[] tokenPrefixBytes = TOKEN_PREFIX.getBytes(StandardCharsets.UTF_8); + byte[] idBytes = Long.toHexString(id).getBytes(StandardCharsets.UTF_8); + + BufferUtil buffer = BufferUtil.allocate(tokenPrefixBytes.length + idBytes.length + + TOKEN_PART_SEPARATOR_BYTES.length + secret.length); + try { + buffer.with(raw -> { + raw.put(tokenPrefixBytes); + raw.put(idBytes); + raw.put(TOKEN_PART_SEPARATOR_BYTES); + raw.put(secret); + }); + + return buffer.toByteArray(); + } finally { + PassphraseUtils.erase(tokenPrefixBytes); + PassphraseUtils.erase(idBytes); + } + } + + public static TokenParts splitToTokenParts(String token) { + if (!token.startsWith(TOKEN_PREFIX)) { + return null; + } + + String[] parts = token.replaceFirst("^" + Pattern.quote(TOKEN_PREFIX), "") + .split("\\.", 2); + if (parts.length != 2) { + return null; + } + + try { + String id = parts[0]; + long idLong = Long.parseLong(id, 16); + return new TokenParts(TOKEN_PREFIX.replace("_", ""), idLong, parts[1].getBytes()); + } catch (NumberFormatException ignored) { + return null; + } + } + + public static void setTokenPrefix(String tokenPrefix) { + TOKEN_PREFIX = tokenPrefix.replaceAll("_+", "_").trim(); + + if (TOKEN_PREFIX.endsWith("_")) return; + TOKEN_PREFIX += "_"; + } + + public static String getTokenPrefix() { + return TOKEN_PREFIX; + } + +} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/utils/Entity.java b/src/main/java/de/craftsblock/cnet/modules/security/utils/Entity.java deleted file mode 100644 index 1e64c55..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/utils/Entity.java +++ /dev/null @@ -1,26 +0,0 @@ -package de.craftsblock.cnet.modules.security.utils; - -import de.craftsblock.craftscore.json.Json; - -/** - * This interface defines the contract for any class that can be serialized - * into a {@link Json} object. It serves as a common type for entities - * that need to be converted to JSON format. - * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0 - */ -public interface Entity { - - /** - * Serializes the current object into a {@link Json} representation. - * Implementing classes should define how their internal state is converted - * into a JSON format. - * - * @return a {@link Json} object representing the serialized state of the entity. - */ - Json serialize(); - -} diff --git a/src/main/java/de/craftsblock/cnet/modules/security/utils/Manager.java b/src/main/java/de/craftsblock/cnet/modules/security/utils/Manager.java deleted file mode 100644 index b889c73..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/utils/Manager.java +++ /dev/null @@ -1,17 +0,0 @@ -package de.craftsblock.cnet.modules.security.utils; - -/** - * The {@link Manager} interface serves as a marker for classes that manage specific functionalities - * or resources within the AccessController addon. This interface itself does not define any methods but - * represents the general contract for all managers in the system. - * - *

Classes implementing this interface may provide methods for adding, removing, or querying - * managed entities.

- * - * @author Philipp Maywald - * @author CraftsBlock - * @version 1.0.0 - * @since 1.0.0-SNAPSHOT - */ -public interface Manager { -} diff --git a/src/main/resources/addon.json b/src/main/resources/addon.json index de55a7b..2561064 100644 --- a/src/main/resources/addon.json +++ b/src/main/resources/addon.json @@ -1,6 +1,6 @@ { "name": "CNetSecurity", - "main": "de.craftsblock.cnet.modules.security.AddonEntrypoint", + "main": "de.craftsblock.cnet.modules.security.CraftsNetSecurity", "authors": [ "Philipp Maywald", "CraftsBlock" @@ -11,7 +11,6 @@ "https://repo.craftsblock.de/releases" ], "dependencies": [ - "de.craftsblock.craftscore:sql:3.8.7", - "org.springframework.security:spring-security-crypto:6.5.0" + "org.springframework.security:spring-security-crypto:7.0.2" ] } \ No newline at end of file