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/bom/build.gradle b/bom/build.gradle new file mode 100644 index 0000000..09963d0 --- /dev/null +++ b/bom/build.gradle @@ -0,0 +1,51 @@ +plugins { + id "java-platform" +} + +gradle.projectsEvaluated { + def validSubprojects = rootProject.subprojects.findAll { sub -> + sub.name != project.name && + (!sub.ext.has("shouldPublish") || sub.ext["shouldPublish"]) && + sub.tasks.findByName("classes") != null + } + + dependencies { + constraints { + validSubprojects.sort { "$it.name" }.each { + api it + } + } + } +} + +craftsPublish { + artifactId = project.name + name = "CraftsNet Security ${project.name}" + + component = project.components.javaPlatform + + pom { + name = project.name + description = project.description + + 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' + } + } + } +} + +description = "Bill-of-Materials for craftsnet security" diff --git a/build.gradle b/build.gradle index 3fc669b..c671cc6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,86 +1,96 @@ plugins { - id 'java' - id 'maven-publish' + id "de.craftsblock.gradle.publish" version "0.0.20" apply false } -def env = new Properties() -file(".env").withInputStream { - env.load(it) -} +version = "1.0.0-pre1" -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 +subprojects { + apply plugin: "maven-publish" + apply plugin: "de.craftsblock.gradle.publish" - withJavadocJar() - withSourcesJar() -} + group = 'de.craftsblock.craftsnet.modules.security' + version = rootProject.version -group = 'de.craftsblock.craftsnet.modules' -version = '1.0.0-pre10' + repositories { + mavenCentral() + maven { url "https://repo.craftsblock.de/releases" } + maven { url "https://repo.craftsblock.de/experimental" } + } -repositories { - mavenCentral() - maven { url "https://repo.craftsblock.de/releases" } - maven { url "https://repo.craftsblock.de/experimental" } -} + afterEvaluate { + if (project.components.findByName("javaPlatform") != null) { + return + } -dependencies { - // CraftsBlock dependencies ---------------------------------------------------------------------------------------- + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/bom - implementation platform("de.craftsblock.craftscore:bom:3.8.12") + withJavadocJar() + withSourcesJar() + } - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/event - implementation "de.craftsblock.craftscore:event" + dependencies { + // CraftsBlock dependencies ---------------------------------------------------------------------------------------- - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/json - implementation "de.craftsblock.craftscore:json" + // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/bom + implementation platform("de.craftsblock.craftscore:bom:3.8.13-pre9") - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/sql - implementation "de.craftsblock.craftscore:sql" + // https://repo.craftsblock.de/#/releases/de/craftsblock/craftsnet + implementation "de.craftsblock:craftsnet:3.7.1" - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/utils - implementation "de.craftsblock.craftscore:utils" + // Third party dependencies ---------------------------------------------------------------------------------------- - // https://repo.craftsblock.de/#/releases/de/craftsblock/craftsnet - implementation "de.craftsblock:craftsnet:3.5.6-pre6" + // https://mvnrepository.com/artifact/org.springframework.security/spring-security-crypto + implementation 'org.springframework.security:spring-security-crypto:7.1.0-M3' - // Third party dependencies ---------------------------------------------------------------------------------------- + // https://mvnrepository.com/artifact/org.jetbrains/annotations + implementation 'org.jetbrains:annotations:26.1.0' - // https://mvnrepository.com/artifact/org.springframework.security/spring-security-crypto - implementation 'org.springframework.security:spring-security-crypto:6.5.0' + } - // https://mvnrepository.com/artifact/org.jetbrains/annotations - implementation 'org.jetbrains:annotations:26.0.2' + tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + } -} + jar { + from sourceSets.main.output -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' -} + duplicatesStrategy = DuplicatesStrategy.EXCLUDE -jar { - from sourceSets.main.output + manifest { + attributes( + 'Implementation-Title': project.name, + 'Implementation-Version': project.version + ) + } + } - duplicatesStrategy = DuplicatesStrategy.EXCLUDE + tasks.register("runnableJar", Jar) { + archiveClassifier.set("runnable") - manifest { - attributes( - 'Implementation-Title': project.name, - 'Implementation-Version': project.version - ) - } -} + manifest { + attributes "Main-Class": application.mainClass.get() + } + + from sourceSets.main.output + + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + + craftsPublish { + artifactId = project.name + name = "CraftsNet Security ${project.name}" + + component = project.components.java -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' + name = project.name + description = project.description scm { url = 'https://github.com/CraftsBlock/CraftsNet-Security' @@ -100,18 +110,15 @@ publishing { } } } - } - } - repositories { - maven { - url('https://repo.craftsblock.de/experimental') - authentication { - basic(BasicAuthentication) - } - credentials { - username = env["username"] - password = env["password"] + + extraArtifacts { + + runnable { + task = "runnableJar" + classifier = "runnable" + } + } } } -} \ No newline at end of file +} diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..62db450 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,10 @@ +plugins { + id "java" + id "application" +} + +application { + mainClass = "de.craftsblock.cnet.modules.security.CraftsNetSecurity" +} + +description = "Protect your CraftsNet API with easy drop in security features." \ No newline at end of file diff --git a/common/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java b/common/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java new file mode 100644 index 0000000..8a65f18 --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/CraftsNetSecurity.java @@ -0,0 +1,54 @@ +package de.craftsblock.cnet.modules.security; + +import de.craftsblock.cnet.modules.security.auth.AuthChain; +import de.craftsblock.cnet.modules.security.auth.autoregister.AuthChainAutoRegisterHandler; +import de.craftsblock.craftsnet.CraftsNet; +import de.craftsblock.craftsnet.addon.Addon; +import de.craftsblock.craftsnet.addon.meta.annotations.Meta; +import de.craftsblock.craftsnet.api.http.HttpMethod; +import de.craftsblock.craftsnet.autoregister.AutoRegisterRegistry; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +@Meta(name = "CraftsNetSecurity") +public final class CraftsNetSecurity extends Addon { + + public static final String VERSION = "1.0.0-pre1"; + + private AuthChain authChain; + + public static void main(String[] args) throws IOException { + CraftsNet.create(CraftsNetSecurity.class) + .withArgs(args) + .build(); + } + + @Override + public void onLoad() { + super.onLoad(); + this.authChain = new AuthChain(); + + AutoRegisterRegistry autoRegisterRegistry = this.getAutoRegisterRegistry(); + autoRegisterRegistry.register(new AuthChainAutoRegisterHandler()); + } + + @Override + public void onEnable() { + super.onEnable(); + } + + @Override + public void onDisable() { + super.onDisable(); + } + + public static @NotNull AuthChain getAuthChain() { + return getInstance().authChain; + } + + public static CraftsNetSecurity getInstance() { + return getAddon(CraftsNetSecurity.class); + } + +} diff --git a/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java new file mode 100644 index 0000000..930ef21 --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthChain.java @@ -0,0 +1,152 @@ +package de.craftsblock.cnet.modules.security.auth; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +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 org.jetbrains.annotations.NotNull; + +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 boolean isHttpAdapterRegistered(AuthAdapter.Http http) { + if (!adapters.containsKey(Scheme.HTTP)) { + return false; + } + + return adapters.get(Scheme.HTTP).contains(http); + } + + public boolean isWebSocketAdapterRegistered(AuthAdapter.WebSocket webSocket) { + if (!adapters.containsKey(Scheme.WS)) { + return false; + } + + return adapters.get(Scheme.WS).contains(webSocket); + } + + 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; + } + + public static @NotNull AuthChain getInstance() { + return CraftsNetSecurity.getAuthChain(); + } + +} diff --git a/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java new file mode 100644 index 0000000..8a3cdf1 --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java @@ -0,0 +1,75 @@ +package de.craftsblock.cnet.modules.security.auth; + +public class AuthResult { + + private final Type type; + private final int code; + private final String reason; + + private AuthResult(Type type) { + this(type, null); + } + + private AuthResult(Type type, String reason) { + this(type, reason, 400); + } + + private AuthResult(Type type, String reason, int code) { + this.type = type; + this.code = code; + this.reason = reason; + } + + public boolean isOk() { + return this.type.equals(Type.OK); + } + + public boolean isSkip() { + return this.type.equals(Type.SKIP); + } + + public boolean isFailure() { + return this.type.equals(Type.FAILURE); + } + + 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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/adapter/AuthAdapter.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/adapter/AuthAdapter.java new file mode 100644 index 0000000..a71111f --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/autoregister/AuthChainAutoRegisterHandler.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/autoregister/AuthChainAutoRegisterHandler.java new file mode 100644 index 0000000..ecc9054 --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/autoregister/AuthChainAutoRegisterHandler.java @@ -0,0 +1,42 @@ +package de.craftsblock.cnet.modules.security.auth.autoregister; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.auth.AuthChain; +import de.craftsblock.cnet.modules.security.auth.adapter.AuthAdapter; +import de.craftsblock.craftsnet.addon.loaders.CraftsNetClassLoader; +import de.craftsblock.craftsnet.autoregister.AutoRegisterHandler; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegisterInfo; + +public class AuthChainAutoRegisterHandler extends AutoRegisterHandler { + + private final AuthChain authChain; + + /** + * + * Constructs an {@link AuthChainAutoRegisterHandler}. + */ + public AuthChainAutoRegisterHandler() { + super(CraftsNetClassLoader.retrieveCraftsNet()); + this.authChain = CraftsNetSecurity.getAuthChain(); + } + + @Override + protected boolean handle(AuthAdapter authAdapter, AutoRegisterInfo info, Object... args) { + boolean success = false; + + if (authAdapter instanceof AuthAdapter.Http http + && !authChain.isHttpAdapterRegistered(http)) { + authChain.append(http); + success = true; + } + + if (authAdapter instanceof AuthAdapter.WebSocket webSocket + && !authChain.isWebSocketAdapterRegistered(webSocket)) { + authChain.append(webSocket); + success = true; + } + + return success; + } + +} diff --git a/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthFailureEvent.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthFailureEvent.java new file mode 100644 index 0000000..f0f1c10 --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthResultEvent.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthResultEvent.java new file mode 100644 index 0000000..427e9bb --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSkipEvent.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSkipEvent.java new file mode 100644 index 0000000..cc01028 --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSuccessEvent.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/event/AuthSuccessEvent.java new file mode 100644 index 0000000..0be9d91 --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusion.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusion.java new file mode 100644 index 0000000..8aacf19 --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java new file mode 100644 index 0000000..11e59cf --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/Exclusions.java @@ -0,0 +1,107 @@ +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)) { + throw new IllegalStateException("Found a non http exclusion " + + exclusion.getClass().getName() + " in the http list!"); + } + + if (exclusion.path().pattern().equals(path) && + 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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/HttpExclusion.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/HttpExclusion.java new file mode 100644 index 0000000..7e8e87e --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/WebSocketExclusion.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/exclusion/WebSocketExclusion.java new file mode 100644 index 0000000..7e8154d --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/AuthListener.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/AuthListener.java new file mode 100644 index 0000000..0fc1555 --- /dev/null +++ b/common/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/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java new file mode 100644 index 0000000..433908f --- /dev/null +++ b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/PreRequestListener.java @@ -0,0 +1,59 @@ +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.api.http.status.HttpStatus; +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.LOW, 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.setStatus(HttpStatus.ClientError.BAD_REQUEST); + } + + if (response.sendingFile()) { + return; + } + + response.print(Json.empty() + .set("success", false) + .set("error.code", result.getCode()) + .set("error.message", result.getReason())); + })); + } + +} diff --git a/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/WebSocketConnectListener.java b/common/src/main/java/de/craftsblock/cnet/modules/security/auth/listener/WebSocketConnectListener.java new file mode 100644 index 0000000..54024a9 --- /dev/null +++ b/common/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.LOW, 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/common/src/main/resources/addon.json b/common/src/main/resources/addon.json new file mode 100644 index 0000000..8ec04a7 --- /dev/null +++ b/common/src/main/resources/addon.json @@ -0,0 +1,17 @@ +{ + "name": "CraftsNetSecurity", + "main": "de.craftsblock.cnet.modules.security.CraftsNetSecurity", + "description": "Protect your craftsnet api with easy drop in security features.", + "website": "https://craftsblock.de", + "version": "1.0.0", + "authors": [ + "Philipp Maywald", + "CraftsBlock" + ], + "repositories": [ + "https://repo.craftsblock.de/releases" + ], + "dependencies": [ + "org.springframework.security:spring-security-crypto:7.1.0-M3" + ] +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index b9e61b5..9262f34 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,16 @@ +pluginManagement { + repositories { + maven { + url = uri("https://repo.craftsblock.de/releases") + } + gradlePluginPortal() + } +} + rootProject.name = 'Security' + +include 'bom' +include 'common' +include 'token' +include 'token-sql' 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/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/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 deleted file mode 100644 index bc95740..0000000 --- a/src/main/java/de/craftsblock/cnet/modules/security/auth/AuthResult.java +++ /dev/null @@ -1,89 +0,0 @@ -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 = ""; - - /** - * Creates a new {@link AuthResult} instance with a default success state of {@code true}. - */ - public AuthResult() { - } - - /** - * Cancels the authentication, setting the success state to {@code false}. - */ - public void cancel() { - this.success = false; - } - - /** - * 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; - this.code = code; - } - - /** - * Returns whether the authentication was successful or not. - * - * @return {@code true} if the authentication was successful, {@code false} otherwise. - */ - public boolean isSuccess() { - return success; - } - - /** - * Returns whether the authentication process was cancelled. - * - * @return {@code true} if the process was cancelled, {@code false} otherwise. - */ - public boolean isCancelled() { - return !success; - } - - /** - * 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; - } - - /** - * Returns the http status code for cancelling the authentication process. - * - * @return The http status code. - */ - public int getCode() { - return code; - } - -} 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/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/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 deleted file mode 100644 index de55a7b..0000000 --- a/src/main/resources/addon.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "CNetSecurity", - "main": "de.craftsblock.cnet.modules.security.AddonEntrypoint", - "authors": [ - "Philipp Maywald", - "CraftsBlock" - ], - "website": "https://craftsblock.de", - "version": "1.0.0-pre10", - "repositories": [ - "https://repo.craftsblock.de/releases" - ], - "dependencies": [ - "de.craftsblock.craftscore:sql:3.8.7", - "org.springframework.security:spring-security-crypto:6.5.0" - ] -} \ No newline at end of file diff --git a/token-sql/build.gradle b/token-sql/build.gradle new file mode 100644 index 0000000..0c285f3 --- /dev/null +++ b/token-sql/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "java" + id "application" +} + +application { + mainClass = "de.craftsblock.cnet.modules.security.CraftsNetSecuritySQLDriver" +} + +dependencies { + implementation project(":common") + implementation project(":token") + + // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/sql + implementation "de.craftsblock.craftscore:sql" +} \ No newline at end of file diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityTokenSQLDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityTokenSQLDriver.java new file mode 100644 index 0000000..0d5f183 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityTokenSQLDriver.java @@ -0,0 +1,34 @@ +package de.craftsblock.cnet.modules.security.token; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.craftsnet.CraftsNet; +import de.craftsblock.craftsnet.addon.Addon; +import de.craftsblock.craftsnet.addon.meta.annotations.Depends; +import de.craftsblock.craftsnet.addon.meta.annotations.Meta; + +import java.io.IOException; + +@Meta(name = "CraftsNetSecurityTokenSQLDriver") +@Depends(CraftsNetSecurity.class) +@Depends(CraftsNetSecurityToken.class) +public final class CraftsNetSecurityTokenSQLDriver extends Addon { + + public static final String VERSION = CraftsNetSecurity.VERSION; + + public static void main(String[] args) throws IOException { + CraftsNet.create(CraftsNetSecurityTokenSQLDriver.class) + .withDebug(true) + .withArgs(args) + .build(); + } + + @Override + public void onDisable() { + super.onDisable(); + } + + public static CraftsNetSecurityTokenSQLDriver getInstance() { + return getAddon(CraftsNetSecurityTokenSQLDriver.class); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/AbstractSQLStoreDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/AbstractSQLStoreDriver.java new file mode 100644 index 0000000..5755325 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/AbstractSQLStoreDriver.java @@ -0,0 +1,13 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import java.sql.Connection; +import java.util.function.Supplier; + +public sealed abstract class AbstractSQLStoreDriver extends SQLWorker + permits SQLGroupStoreDriver, SQLScopeDriver, SQLTokenStoreDriver { + + public AbstractSQLStoreDriver(Supplier connectionSupplier) { + super(connectionSupplier); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLGroupStoreDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLGroupStoreDriver.java new file mode 100644 index 0000000..115a6ed --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLGroupStoreDriver.java @@ -0,0 +1,159 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import de.craftsblock.cnet.modules.security.token.driver.GroupStoreDriver; +import de.craftsblock.cnet.modules.security.token.group.Group; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.*; +import java.util.function.Supplier; + +public final class SQLGroupStoreDriver extends AbstractSQLStoreDriver implements GroupStoreDriver { + + private SQLStoreDriver storeDriver; + + SQLGroupStoreDriver(Supplier connectionSupplier) { + super(connectionSupplier); + } + + void setStoreDriver(@NotNull SQLStoreDriver storeDriver) { + if (this.storeDriver != null) { + return; + } + + this.storeDriver = storeDriver; + } + + @Override + public boolean existsGroup(@NotNull String name) { + ensureOpen(); + + return this.query(this.preparedStatement( + "SELECT 1 FROM `cnet_security_groups` WHERE `name` = ? LIMIT 1;", name + ), ResultSet::next); + } + + @Override + public Group loadGroup(@NotNull String name) { + ensureOpen(); + + return this.query(this.preparedStatement( + """ + SELECT + `group`.`name`, + `scope`.`value` AS `scope` + FROM `cnet_security_groups` `group` + LEFT JOIN `cnet_security_entity_scopes` `entity_scope` ON `entity_scope`.`group_id` = `group`.`name` + LEFT JOIN `cnet_security_scopes` `scope` ON `scope`.`id` = `entity_scope`.`scope_id` + WHERE `group`.`name` = ?; + """, + name + ), result -> { + String groupName = null; + Set scopes = new HashSet<>(); + + while (result.next()) { + if (groupName == null) { + groupName = result.getString("name"); + } + + String scope = result.getString("scope"); + if (scope != null) { + scopes.add(scope); + } + } + + if (groupName == null) { + return null; + } + + return new Group(groupName, scopes); + }); + } + + @Override + public void saveGroup(@NotNull Group group) { + ensureOpen(); + this.update(this.preparedStatement( + "INSERT IGNORE INTO `cnet_security_groups` (`name`) VALUES (?)", + group.name() + )); + + if (group.scopes().isEmpty()) { + return; + } + + persistScopes(group); + unlinkScopes(group); + this.storeDriver.getScopeDriver().cleanUpScopes(); + } + + private void persistScopes(Group group) { + if (group.scopes().isEmpty()) { + return; + } + + Collection scopes = this.storeDriver.getScopeDriver() + .saveScopes(group.scopes().toArray(String[]::new)) + .values(); + + if (scopes.isEmpty()) { + return; + } + + final String groupName = group.name(); + this.updateBatch( + this.preparedStatement( + "INSERT IGNORE INTO `cnet_security_entity_scopes` (`scope_id`, `group_id`) VALUES (?, ?);" + ), scopes, + (statement, scopeId) -> { + statement.setLong(1, scopeId); + statement.setString(2, groupName); + } + ); + } + + private void unlinkScopes(Group group) { + if (group.scopes().isEmpty()) { + return; + } + + StringJoiner placeholders = new StringJoiner(","); + group.scopes().forEach(s -> placeholders.add("?")); + + List params = new ArrayList<>(1 + group.scopes().size()); + params.add(group.name()); + params.addAll(group.scopes()); + + this.update(this.preparedStatementList( + """ + DELETE `entity_scope` FROM `cnet_security_entity_scopes` `entity_scope` + JOIN `cnet_security_scopes` `scope` ON `scope`.`id` = `entity_scope`.`scope_id` + WHERE `entity_scope`.`group_id` = ? + AND `scope`.`value` NOT IN (%s); + """.formatted(placeholders), + params + )); + } + + @Override + public void deleteGroup(@NotNull Group group) { + ensureOpen(); + this.update(this.preparedStatement( + "DELETE FROM `cnet_security_groups` WHERE `name`=?;", + group + )); + + this.storeDriver.getScopeDriver().cleanUpScopes(); + } + + @Override + public @NotNull Collection getAllGroupNames() { + ensureOpen(); + return this.queryCollection(this.preparedStatement( + "SELECT `name` FROM `cnet_security_groups`;" + ), "name", String.class); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLScopeDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLScopeDriver.java new file mode 100644 index 0000000..12bb78c --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLScopeDriver.java @@ -0,0 +1,72 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.util.*; +import java.util.function.Supplier; + +final class SQLScopeDriver extends AbstractSQLStoreDriver { + + private SQLStoreDriver storeDriver; + + public SQLScopeDriver(Supplier connectionSupplier) { + super(connectionSupplier); + } + + public void setStoreDriver(@NotNull SQLStoreDriver storeDriver) { + if (this.storeDriver != null) { + return; + } + + this.storeDriver = storeDriver; + } + + public Map saveScopes(String... scopes) { + this.updateBatch( + this.preparedStatement("INSERT IGNORE INTO `cnet_security_scopes` (`value`) VALUES (?);"), + List.of(scopes), + (statement, scope) -> statement.setString(1, scope) + ); + + return this.query(this.preparedStatementList( + "SELECT `id`, `value` FROM `cnet_security_scopes` WHERE `value` IN (%s)".formatted( + String.join(",", Collections.nCopies(scopes.length, "?")) + ), List.of(scopes) + ), result -> { + Map resultScopes = new HashMap<>(); + while (result.next()) { + resultScopes.put( + result.getString("value"), + result.getLong("id") + ); + } + + return resultScopes; + }); + } + + public void cleanUpScopes() { + Collection unusedScopes = this.queryCollection(this.preparedStatement( + """ + SELECT `value` AS `scope` + FROM `cnet_security_scopes` + WHERE `value` NOT IN ( + SELECT `scope` FROM `cnet_security_group_scopes` + UNION + SELECT `scope` FROM `cnet_security_token_scopes` + );""" + ), "scope", String.class); + + if (unusedScopes.isEmpty()) { + return; + } + + this.update(this.preparedStatementList( + "DELETE FROM `cnet_security_scopes` WHERE `value` IN (%s)".formatted( + String.join(",", Collections.nCopies(unusedScopes.size(), "?")) + ), unusedScopes + )); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLStoreDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLStoreDriver.java new file mode 100644 index 0000000..80f758e --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLStoreDriver.java @@ -0,0 +1,120 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.WrappedStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.sql.reload.SQLPollingReloadProvider; +import de.craftsblock.cnet.modules.security.token.driver.sql.reload.SQLReloadProvider; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.SQLSchemaUpdater; +import de.craftsblock.craftsnet.logging.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.util.function.Function; +import java.util.function.Supplier; + +public class SQLStoreDriver extends WrappedStoreDriver { + + private final @NotNull Supplier connectionSupplier; + private final @NotNull SQLReloadProvider sqlReloadProvider; + + private final @NotNull SQLScopeDriver scopeDriver; + private final @NotNull SQLSchemaUpdater schemaUpdater; + + private boolean closed; + + SQLStoreDriver(@NotNull SQLGroupStoreDriver groupStoreDriver, + @NotNull SQLScopeDriver scopeDriver, + @NotNull SQLTokenStoreDriver tokenStoreDriver, + @NotNull SQLSchemaUpdater schemaUpdater, + @NotNull Supplier<@NotNull Connection> connectionSupplier, + @NotNull Function<@NotNull SQLStoreDriver, @NotNull SQLReloadProvider> providerFactory) { + super(groupStoreDriver, tokenStoreDriver); + + // When the store driver is close no more interactions with the underlying + // connection supplier should be made to prevent unnecessary reconnections + this.connectionSupplier = () -> { + if (isClosed()) { + return null; + } + + return connectionSupplier.get(); + }; + + this.scopeDriver = scopeDriver; + this.schemaUpdater = schemaUpdater; + + this.getGroupStoreDriver().setStoreDriver(this); + this.scopeDriver.setStoreDriver(this); + this.getTokenStoreDriver().setStoreDriver(this); + + Logger logger = CraftsNetSecurity.getInstance().getLogger(); + if (this.schemaUpdater.needsUpgrade()) { + logger.debug("Needed db schema updates found"); + this.schemaUpdater.performUpgrade(); + } else { + logger.debug("Your db schema is on the newest version %s", this.schemaUpdater.getCurrentInstalledVersion()); + } + + this.sqlReloadProvider = providerFactory.apply(this); + } + + @Override + public synchronized void close() { + try { + sqlReloadProvider.close(); + super.close(); + } catch (Exception e) { + throw new RuntimeException("Failed to close the sql reload provider: " + e.getMessage(), e); + } finally { + this.closed = true; + } + } + + public boolean isClosed() { + return closed; + } + + public @NotNull Supplier<@Nullable Connection> getConnectionSupplier() { + return connectionSupplier; + } + + @Override + public SQLGroupStoreDriver getGroupStoreDriver() { + return super.getGroupStoreDriver(); + } + + @NotNull SQLScopeDriver getScopeDriver() { + return scopeDriver; + } + + @Override + public SQLTokenStoreDriver getTokenStoreDriver() { + return super.getTokenStoreDriver(); + } + + public @NotNull SQLSchemaUpdater getSchemaUpdater() { + return schemaUpdater; + } + + public @NotNull SQLReloadProvider getSqlReloadProvider() { + return sqlReloadProvider; + } + + public static SQLStoreDriver create(@NotNull Supplier<@NotNull Connection> connectionSupplier) { + return create(connectionSupplier, SQLPollingReloadProvider::new); + } + + public static SQLStoreDriver create(@NotNull Supplier<@NotNull Connection> connectionSupplier, + @NotNull Function<@NotNull SQLStoreDriver, @NotNull SQLReloadProvider> providerFactory) { + return new SQLStoreDriver( + new SQLGroupStoreDriver(connectionSupplier), + new SQLScopeDriver(connectionSupplier), + new SQLTokenStoreDriver(connectionSupplier), + new SQLSchemaUpdater(connectionSupplier), + connectionSupplier, + providerFactory + ); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLTokenStoreDriver.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLTokenStoreDriver.java new file mode 100644 index 0000000..443588e --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLTokenStoreDriver.java @@ -0,0 +1,190 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.TokenDataContainer; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.cnet.modules.security.token.group.OptionalGroup; +import de.craftsblock.craftsnet.utils.PassphraseUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.*; +import java.util.function.Supplier; + +public final class SQLTokenStoreDriver extends AbstractSQLStoreDriver implements TokenStoreDriver { + + private SQLStoreDriver storeDriver; + + SQLTokenStoreDriver(Supplier connectionSupplier) { + super(connectionSupplier); + } + + void setStoreDriver(@NotNull SQLStoreDriver storeDriver) { + if (this.storeDriver != null) { + return; + } + + this.storeDriver = storeDriver; + } + + @Override + public boolean existsToken(long id) { + ensureOpen(); + + return this.query(this.preparedStatement( + "SELECT 1 FROM `cnet_security_tokens` WHERE `id` = ? LIMIT 1;", id + ), ResultSet::next); + } + + @Override + public Token loadToken(final long id) { + ensureOpen(); + + return this.query(this.preparedStatement( + """ + SELECT + `token`.`id`, + `token`.`hash`, + `token`.`data_container`, + `scope`.`value` AS `scope`, + `group`.`name` AS `group_name` + FROM `cnet_security_tokens` `token` + LEFT JOIN `cnet_security_entity_scopes` `entity_scope` ON `entity_scope`.`token_id` = `token`.`id` + LEFT JOIN `cnet_security_scopes` `scope` ON `scope`.`id` = `entity_scope`.`scope_id` + LEFT JOIN `cnet_security_token_groups` `token_group` ON `token_group`.`token_id` = `token`.`id` + LEFT JOIN `cnet_security_groups` `group` ON `group`.`name` = `token_group`.`group_id` + WHERE `token`.`id` = ?; + """, + id + ), result -> { + String hash = null; + byte[] data = null; + boolean found = false; + + final Set scopes = new HashSet<>(); + final Set groups = new HashSet<>(); + + while (result.next()) { + if (!found) { + hash = result.getString("hash"); + data = result.getBytes("data_container"); + found = true; + } + + String scope = result.getString("scope"); + if (scope != null) { + scopes.add(scope); + } + + String group = result.getString("group_name"); + if (group != null) { + groups.add(group); + } + } + + if (!found) { + return null; + } + + return new Token( + id, + hash, + scopes, + OptionalGroup.fromList(groups), + new TokenDataContainer(data) + ); + }); + } + + @Override + public void saveToken(@NotNull Token token) { + ensureOpen(); + + this.update(this.preparedStatement( + """ + INSERT INTO `cnet_security_tokens` + (`id`, `hash`, `data_container`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + `data_container` = IF(`data_container` != VALUES(`data_container`), VALUES(`data_container`), `data_container`)""", + token.id(), token.hash(), token.tokenDataContainer().serializeToBytes() + )); + + persistScopes(token); + persistGroups(token); + TokenStoreDriver.super.saveToken(token); + } + + private void persistScopes(Token token) { + if (token.directScopes().isEmpty()) { + return; + } + + Collection scopes = this.storeDriver.getScopeDriver() + .saveScopes(token.directScopes().toArray(String[]::new)) + .values(); + + if (scopes.isEmpty()) { + return; + } + + final long tokenId = token.id(); + this.updateBatch( + this.preparedStatement( + "INSERT IGNORE INTO `cnet_security_entity_scopes` (`scope_id`, `token_id`) VALUES (?, ?);" + ), scopes, + (statement, scopeId) -> { + statement.setLong(1, scopeId); + statement.setLong(2, tokenId); + } + ); + } + + private void persistGroups(Token token) { + if (token.groupNames().isEmpty()) { + return; + } + + final long tokenId = token.id(); + this.updateBatch( + this.preparedStatement( + "INSERT IGNORE INTO `cnet_security_token_groups` (`token_id`, `group_id`) VALUES (?, ?);" + ), token.groupNames(), + (statement, group) -> { + statement.setLong(1, tokenId); + statement.setString(2, group); + } + ); + } + + @Override + public void deleteToken(@NotNull Token token) { + ensureOpen(); + + this.update(this.preparedStatement( + "DELETE FROM `cnet_security_tokens` WHERE `id` = ?;", + token.id() + )); + + this.storeDriver.getScopeDriver().cleanUpScopes(); + TokenStoreDriver.super.deleteToken(token); + } + + @Override + public @NotNull Collection getAllTokenIds() { + ensureOpen(); + return this.queryCollection(this.preparedStatement( + "SELECT `id` FROM `cnet_security_tokens`;" + ), "id", Long.class); + } + + private record TokenMeta(String hash, byte[] data) { + + public void erase() { + PassphraseUtils.erase(data); + } + + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLWorker.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLWorker.java new file mode 100644 index 0000000..74e76c1 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/SQLWorker.java @@ -0,0 +1,190 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.sql.util.SQLBiConsumer; +import de.craftsblock.cnet.modules.security.token.driver.sql.util.SQLFunction; +import de.craftsblock.craftsnet.logging.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.sql.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +public class SQLWorker implements AutoCloseable { + + private final @NotNull Supplier<@Nullable Connection> connectionSupplier; + + public SQLWorker(@NotNull Supplier<@Nullable Connection> connectionSupplier) { + this.connectionSupplier = connectionSupplier; + } + + protected final void update(PreparedStatement statement) { + ensureOpen(); + + try (statement) { + statement.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("Could not perform update: " + e.getMessage(), e); + } + } + + protected final void updateBatch(@NotNull PreparedStatement statement, @NotNull Collection values, + @NotNull SQLBiConsumer valueConsumer) { + ensureOpen(); + + try (statement) { + int batchSize = 500; + int count = 0; + + for (T value : values) { + valueConsumer.acceptThrows(statement, value); + statement.addBatch(); + + if (++count % batchSize == 0) { + statement.executeBatch(); + } + } + + statement.executeBatch(); + } catch (SQLException e) { + throw new RuntimeException("Could not perform batch: " + e.getMessage(), e); + } + } + + protected final R query(PreparedStatement statement, SQLFunction resultSetRFunction) { + ensureOpen(); + try (statement) { + try (ResultSet resultSet = statement.executeQuery()) { + return resultSetRFunction.applyThrows(resultSet); + } + } catch (SQLException e) { + throw new RuntimeException("Could not perform query: " + e.getMessage(), e); + } + } + + protected final PreparedStatement preparedStatement(String sql, Object... values) { + return this.preparedStatementList(sql, List.of(values)); + } + + protected final PreparedStatement preparedStatementList(String sql, Collection values) { + ensureOpen(); + try { + Connection connection = getConnection(); + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + + AtomicInteger i = new AtomicInteger(0); + for (T value : values) { + statement.setObject(i.incrementAndGet(), value); + } + + return statement; + } catch (SQLException e) { + throw new RuntimeException("Could not prepare statement: " + e.getMessage(), e); + } + } + + protected final Collection queryCollection(PreparedStatement statement, String column, Class type) { + return this.query(statement, result -> { + Collection values = new ArrayList<>(); + while (result.next()) { + values.add(result.getObject(column, type)); + } + + return values; + }); + } + + protected final boolean performScript(String name) { + ensureOpen(); + Logger logger = CraftsNetSecurity.getInstance().getLogger(); + + try (InputStream file = getClass().getResourceAsStream(name)) { + if (file == null) { + logger.error("Could not find migration script: %s", name); + return false; + } + + Connection connection = getConnection(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file)); + Statement statement = connection.createStatement()) { + + connection.setAutoCommit(false); + + StringBuilder currentStatement = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("--")) { + continue; + } + + currentStatement.append(line).append(" "); + + if (!line.endsWith(";")) { + continue; + } + + String sql = currentStatement.toString(); + sql = sql.substring(0, sql.lastIndexOf(";")).trim(); + + if (!sql.isEmpty()) { + statement.execute(sql); + } + + currentStatement.setLength(0); + } + + connection.commit(); + return true; + } finally { + connection.setAutoCommit(true); + } + } catch (Exception e) { + logger.error("Failed to upgrade: " + e.getMessage(), e); + return false; + } + } + + @Override + public void close() { + try { + Connection connection = getConnection(); + if (connection == null || connection.isClosed()) { + return; + } + + connection.close(); + } catch (SQLException e) { + throw new RuntimeException("Failed to close sql connection!", e); + } + } + + public void ensureOpen() { + try { + Connection connection = getConnection(); + if (connection == null || connection.isClosed()) { + throw new IllegalStateException("No operations allowed after underlying closure!"); + } + } catch (SQLException e) { + throw new RuntimeException("Could not check connection state: " + e.getMessage(), e); + } + } + + public Supplier getConnectionSupplier() { + return connectionSupplier; + } + + public Connection getConnection() { + return connectionSupplier.get(); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLNoOpReloadProvider.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLNoOpReloadProvider.java new file mode 100644 index 0000000..999d85a --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLNoOpReloadProvider.java @@ -0,0 +1,16 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.reload; + +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLStoreDriver; +import org.jetbrains.annotations.NotNull; + +public class SQLNoOpReloadProvider extends SQLReloadProvider { + + public SQLNoOpReloadProvider(@NotNull SQLStoreDriver driver) { + super(driver); + } + + @Override + public void close() { + // Do nothing to not close the sql connection + } +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLPollingReloadProvider.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLPollingReloadProvider.java new file mode 100644 index 0000000..b0b0194 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLPollingReloadProvider.java @@ -0,0 +1,117 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.reload; + +import de.craftsblock.cnet.modules.security.token.CraftsNetSecurityTokenSQLDriver; +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLStoreDriver; +import de.craftsblock.craftsnet.logging.Logger; +import org.jetbrains.annotations.NotNull; + +import java.sql.Timestamp; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class SQLPollingReloadProvider extends SQLReloadProvider implements Runnable { + + private static final String SQL_UPDATE_CHECK = """ + SELECT "global" AS `entity`, MAX(`created_at`) AS `last_action` + FROM `cnet_security_entity_scopes` + + UNION ALL + + SELECT "tokens" AS `entity`, MAX(`updated_at`) AS `last_action` + FROM `cnet_security_tokens` + + UNION ALL + + SELECT "token_groups" AS `entity`, MAX(`created_at`) AS `last_action` + FROM `cnet_security_token_groups` + + UNION ALL + + SELECT "groups" AS `entity`, MAX(`created_at`) AS `last_action` + FROM `cnet_security_groups`;"""; + + private final Map lastActions = new HashMap<>(4); + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + private final CraftsNetSecurityTokenSQLDriver craftsNetSecurityTokenSQLDriver = CraftsNetSecurityTokenSQLDriver.getInstance(); + + public SQLPollingReloadProvider(@NotNull SQLStoreDriver driver) { + this(driver, 5, 15, TimeUnit.SECONDS); + } + + public SQLPollingReloadProvider(@NotNull SQLStoreDriver driver, long initialDelay, long delay, @NotNull TimeUnit unit) { + super(driver); + executor.scheduleWithFixedDelay(this, initialDelay, delay, unit); + } + + @Override + public synchronized void run() { + this.query(this.preparedStatement(SQL_UPDATE_CHECK), resultSet -> { + boolean reloadGroups = false; + boolean reloadTokens = false; + + while (resultSet.next()) { + final String entity = resultSet.getString("entity"); + final Timestamp lastAction = resultSet.getTimestamp("last_action"); + final Timestamp lastKnownAction = lastActions.get(entity); + + if (Objects.equals(lastAction, lastKnownAction)) { + continue; + } + + if (lastAction == null) { + lastActions.remove(entity); + } else { + lastActions.put(entity, lastAction); + } + + switch (entity) { + case "global" -> { + reloadGroups = true; + reloadTokens = true; + } + case "groups" -> reloadGroups = true; + case "tokens", "token_groups" -> reloadTokens = true; + } + } + + Logger logger = CraftsNetSecurityTokenSQLDriver.getInstance().getLogger(); + if (reloadGroups && reloadTokens) { + logger.debug("Detected db change, reloading full driver."); + getDriver().reload(); + return null; + } + + if (reloadGroups) { + logger.debug("Detected db group entity change, reloading group driver."); + getDriver().getGroupStoreDriver().reload(); + } + + if (reloadTokens) { + logger.debug("Detected db token entity change, reloading token driver."); + getDriver().getTokenStoreDriver().reload(); + } + + return null; + }); + } + + @Override + public void close() { + final Logger logger = craftsNetSecurityTokenSQLDriver.getLogger(); + + try { + executor.shutdown(); + if (executor.awaitTermination(5, TimeUnit.SECONDS)) { + return; + } + } catch (InterruptedException ignored) { + } + + logger.error("Canceled and dropped " + executor.shutdownNow().size() + " tasks!"); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLReloadProvider.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLReloadProvider.java new file mode 100644 index 0000000..6bf11b6 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/reload/SQLReloadProvider.java @@ -0,0 +1,20 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.reload; + +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLWorker; +import org.jetbrains.annotations.NotNull; + +public abstract class SQLReloadProvider extends SQLWorker { + + private final @NotNull SQLStoreDriver driver; + + public SQLReloadProvider(@NotNull SQLStoreDriver driver) { + super(driver.getConnectionSupplier()); + this.driver = driver; + } + + public @NotNull SQLStoreDriver getDriver() { + return driver; + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpdater.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpdater.java new file mode 100644 index 0000000..2ef1aba --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpdater.java @@ -0,0 +1,111 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.schema; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLWorker; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.upgarde.SQLSchemaUpdate2026_03_09; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.upgarde.SQLSchemaUpdate2026_03_21; +import de.craftsblock.craftsnet.logging.Logger; +import org.jetbrains.annotations.Contract; + +import java.sql.Connection; +import java.util.LinkedList; +import java.util.List; +import java.util.function.Supplier; + +public class SQLSchemaUpdater extends SQLWorker { + + public final LinkedList versions; + + public SQLSchemaUpdater(Supplier connectionSupplier) { + super(connectionSupplier); + this.versions = new LinkedList<>(List.of( + new SQLSchemaUpdate2026_03_09(this), + new SQLSchemaUpdate2026_03_21(this) + )); + } + + public boolean needsUpgrade() { + if (!isSchemaInstalled()) { + return true; + } + + String version = getCurrentInstalledVersion(); + return !versions.getLast().getVersion().equalsIgnoreCase(version); + } + + public void performUpgrade() { + Logger logger = CraftsNetSecurity.getInstance().getLogger(); + int offset; + + if (isSchemaInstalled()) { + String currentInstalledVersion = getCurrentInstalledVersion(); + SQLSchemaUpgrade currentInstalled = getUpgrade(currentInstalledVersion); + + if (currentInstalled == null) { + logger.error("Provided db schema is newer than any available schema updates."); + logger.error("Are you operating two different versions of craftsnet security?"); + logger.error("Resuming with newer db schema %s, this may cause exceptions!", + currentInstalledVersion); + logger.error("Consider updating to a newer version of craftsnet security!"); + return; + } + + offset = versions.indexOf(currentInstalled); + } else { + offset = -1; + } + + for (int i = offset + 1; i < versions.size(); i++) { + SQLSchemaUpgrade update = versions.get(i); + String version = update.getVersion(); + + long start = System.currentTimeMillis(); + boolean success = update.upgrade(); + long executionTime = System.currentTimeMillis() - start; + + this.update(this.preparedStatement(""" + INSERT INTO `cnet_security_schema_history` + (`version`, `execution_time`, `success`) + VALUES (?, ?, ?) + """, version, executionTime, success)); + + if (!success) { + throw new IllegalStateException("Failed to update db schema to " + version); + } else { + logger.debug("Installed db schema version %s after %sms", + version, executionTime); + } + } + } + + @Contract("null -> null") + public SQLSchemaUpgrade getUpgrade(String version) { + if (version == null) { + return null; + } + + return versions.stream() + .filter(upgrade -> upgrade.getVersion().equalsIgnoreCase(version)) + .findFirst() + .orElse(null); + } + + public boolean isSchemaInstalled() { + return this.query(this.preparedStatement(""" + SELECT COUNT(*) + FROM `information_schema`.`tables` + WHERE `table_schema` = DATABASE() + AND `table_name` = 'cnet_security_schema_history'; + """), result -> result.next() && result.getInt(1) == 1); + } + + public String getCurrentInstalledVersion() { + return this.query(this.preparedStatement(""" + SELECT `version` + FROM `cnet_security_schema_history` + WHERE `success` = true + ORDER BY `id` DESC LIMIT 1; + """), result -> result.next() ? result.getString("version") : null); + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpgrade.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpgrade.java new file mode 100644 index 0000000..7a8fe94 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/SQLSchemaUpgrade.java @@ -0,0 +1,28 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.schema; + +import de.craftsblock.cnet.modules.security.token.driver.sql.SQLWorker; + +public abstract class SQLSchemaUpgrade extends SQLWorker { + + private final SQLSchemaUpdater updater; + private final String version; + + public SQLSchemaUpgrade(SQLSchemaUpdater updater, String version) { + super(updater.getConnectionSupplier()); + this.updater = updater; + this.version = version; + } + + public abstract boolean upgrade(); + + public abstract boolean downgrade(); + + public SQLSchemaUpdater getUpdater() { + return updater; + } + + public String getVersion() { + return version; + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_09.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_09.java new file mode 100644 index 0000000..d94fe5b --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_09.java @@ -0,0 +1,55 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.schema.upgarde; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.SQLSchemaUpdater; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.SQLSchemaUpgrade; + +public class SQLSchemaUpdate2026_03_09 extends SQLSchemaUpgrade { + + public SQLSchemaUpdate2026_03_09(SQLSchemaUpdater updater) { + super(updater, "2026-03-09"); + } + + @Override + public boolean upgrade() { + boolean success = performScript("/sql/schema/install.sql"); + success &= createTrigger("INSERT"); + success &= createTrigger("UPDATE"); + return success; + } + + @Override + public boolean downgrade() { + return performScript("/sql/schema/deinstall.sql"); + } + + private boolean createTrigger(String variation) { + try { + this.update(this.preparedStatement(""" + CREATE TRIGGER IF NOT EXISTS `enforce_foreign_keys_on_%s` + BEFORE %s + ON `cnet_security_entity_scopes` + FOR EACH ROW + BEGIN + IF NEW.token_id IS NULL AND NEW.group_id IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Either token_id or group_id must be provided.'; + END IF; + + IF NEW.token_id IS NOT NULL AND NEW.group_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Only one of token_id or group_id can be provided, not both.'; + END IF; + END + """.formatted(variation.toLowerCase(), variation.toUpperCase()))); + return true; + } catch (Throwable t) { + CraftsNetSecurity.getInstance().getLogger().error( + "Failed to create trigger in variation %s: %s", + t, variation, t.getMessage() + ); + return false; + } + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_21.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_21.java new file mode 100644 index 0000000..63af480 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/schema/upgarde/SQLSchemaUpdate2026_03_21.java @@ -0,0 +1,38 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.schema.upgarde; + +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.SQLSchemaUpdater; +import de.craftsblock.cnet.modules.security.token.driver.sql.schema.SQLSchemaUpgrade; + +public class SQLSchemaUpdate2026_03_21 extends SQLSchemaUpgrade { + + public SQLSchemaUpdate2026_03_21(SQLSchemaUpdater updater) { + super(updater, "2026-03-21"); + } + + @Override + public boolean upgrade() { + this.update(this.preparedStatement("ALTER TABLE `cnet_security_entity_scopes` " + + "ADD UNIQUE `unique_token_scope` (`token_id`, `scope_id`);")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_entity_scopes` " + + "ADD UNIQUE `unique_group_scope` (`group_id`, `scope_id`);")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_token_groups` " + + "ADD UNIQUE `unique_token_group` (`token_id`, `group_id`);")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_groups` " + + "ADD UNIQUE `unique_group_name` (`name`, `id`);")); + return true; + } + + @Override + public boolean downgrade() { + this.update(this.preparedStatement("ALTER TABLE `cnet_security_entity_scopes` " + + "DROP UNIQUE `unique_token_scope`;")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_entity_scopes` " + + "DROP UNIQUE `unique_group_scope`;")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_token_groups` " + + "DROP UNIQUE `unique_token_group`;")); + this.update(this.preparedStatement("ALTER TABLE `cnet_security_groups` " + + "DROP UNIQUE `unique_group_name`;")); + return true; + } + +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLBiConsumer.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLBiConsumer.java new file mode 100644 index 0000000..92e349a --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLBiConsumer.java @@ -0,0 +1,19 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.util; + +import java.sql.SQLException; +import java.util.function.BiConsumer; + +@FunctionalInterface +public interface SQLBiConsumer extends BiConsumer { + + void acceptThrows(T t, U u) throws SQLException; + + @Override + default void accept(T t, U u) { + try { + this.acceptThrows(t, u); + } catch (SQLException e) { + throw new RuntimeException("There has been an sql exception!", e); + } + } +} diff --git a/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLFunction.java b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLFunction.java new file mode 100644 index 0000000..af48876 --- /dev/null +++ b/token-sql/src/main/java/de/craftsblock/cnet/modules/security/token/driver/sql/util/SQLFunction.java @@ -0,0 +1,20 @@ +package de.craftsblock.cnet.modules.security.token.driver.sql.util; + +import java.sql.SQLException; +import java.util.function.BiConsumer; +import java.util.function.Function; + +@FunctionalInterface +public interface SQLFunction extends Function { + + R applyThrows(T t) throws SQLException; + + @Override + default R apply(T t) { + try { + return this.applyThrows(t); + } catch (SQLException e) { + throw new RuntimeException("There has been an sql exception!", e); + } + } +} diff --git a/token-sql/src/main/resources/addon.json b/token-sql/src/main/resources/addon.json new file mode 100644 index 0000000..c2442d7 --- /dev/null +++ b/token-sql/src/main/resources/addon.json @@ -0,0 +1,22 @@ +{ + "name": "CraftsNetSecurityTokenSQLDriver", + "main": "de.craftsblock.cnet.modules.security.token.CraftsNetSecurityTokenSQLDriver", + "version": "1.0.0", + "description": "A sql driver for craftsnet security", + "website": "https://craftsnet.de", + "author": [ + "Philipp Maywald", + "CraftsBlock" + ], + "depends": [ + "CraftsNetSecurity", + "CraftsNetSecurityToken" + ], + "repositories": [ + "https://repo.craftsblock.de/releases", + "https://repo.craftsblock.de/experimental" + ], + "dependencies": [ + "de.craftsblock.craftscore:sql:3.8.13-pre9" + ] +} \ No newline at end of file diff --git a/token-sql/src/main/resources/sql/schema/deinstall.sql b/token-sql/src/main/resources/sql/schema/deinstall.sql new file mode 100644 index 0000000..71dd3b9 --- /dev/null +++ b/token-sql/src/main/resources/sql/schema/deinstall.sql @@ -0,0 +1,21 @@ +DROP VIEW IF EXISTS `cnet_security_group_scopes`; +DROP VIEW IF EXISTS `cnet_security_token_scopes`; +DROP VIEW IF EXISTS `cnet_security_token_groups_view`; + +DROP TRIGGER IF EXISTS `enforce_foreign_keys_on_insert`; +DROP TRIGGER IF EXISTS `enforce_foreign_keys_on_update`; + +ALTER TABLE `cnet_security_entity_scopes` + DROP FOREIGN KEY `entity_group`, + DROP FOREIGN KEY `entity_scope`, + DROP FOREIGN KEY `entity_token`; + +ALTER TABLE `cnet_security_token_groups` + DROP FOREIGN KEY `token_group`, + DROP FOREIGN KEY `group_token`; + +DROP TABLE IF EXISTS `cnet_security_entity_scopes`; +DROP TABLE IF EXISTS `cnet_security_schema_history`; +DROP TABLE IF EXISTS `cnet_security_scopes`; +DROP TABLE IF EXISTS `cnet_security_tokens`; +DROP TABLE IF EXISTS `cnet_security_groups`; \ No newline at end of file diff --git a/token-sql/src/main/resources/sql/schema/install.sql b/token-sql/src/main/resources/sql/schema/install.sql new file mode 100644 index 0000000..a5c7c46 --- /dev/null +++ b/token-sql/src/main/resources/sql/schema/install.sql @@ -0,0 +1,119 @@ +CREATE TABLE + IF NOT EXISTS `cnet_security_entity_scopes` ( + `id` bigint (20) UNSIGNED NOT NULL AUTO_INCREMENT, + `scope_id` bigint (20) UNSIGNED NOT NULL, + `token_id` bigint (20) UNSIGNED NULL, + `group_id` varchar(255) NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `entity_scope` (`scope_id`), + KEY `entity_token` (`token_id`), + KEY `entity_group` (`group_id`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE TABLE + IF NOT EXISTS `cnet_security_groups` ( + `id` bigint (20) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE `unique_name` (`name`), + KEY `group_name` (`name`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE TABLE + IF NOT EXISTS `cnet_security_schema_history` ( + `id` int (11) UNSIGNED NOT NULL AUTO_INCREMENT, + `version` varchar(50) NOT NULL, + `installed_on` timestamp NULL DEFAULT current_timestamp(), + `execution_time` int (11) DEFAULT NULL, + `success` tinyint (1) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE TABLE + IF NOT EXISTS `cnet_security_scopes` ( + `id` bigint (20) UNSIGNED NOT NULL AUTO_INCREMENT, + `value` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE `unique_value` (`value`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE TABLE + IF NOT EXISTS `cnet_security_tokens` ( + `id` bigint (20) UNSIGNED NOT NULL, + `hash` varchar(255) NOT NULL, + `data_container` mediumblob NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE TABLE + IF NOT EXISTS `cnet_security_token_groups` ( + `token_id` bigint (20) UNSIGNED NOT NULL, + `group_id` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`token_id`, `group_id`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; + +CREATE +OR REPLACE VIEW `cnet_security_group_scopes` AS +SELECT + `cnet_security_groups`.`name` AS `group`, + `cnet_security_scopes`.`value` AS `scope`, + `cnet_security_groups`.`created_at` AS `created_at`, + `cnet_security_scopes`.`created_at` AS `granted_at` +FROM + ( + ( + `cnet_security_groups` + join `cnet_security_entity_scopes` on ( + `cnet_security_groups`.`name` = `cnet_security_entity_scopes`.`group_id` + ) + ) + join `cnet_security_scopes` on ( + `cnet_security_entity_scopes`.`scope_id` = `cnet_security_scopes`.`id` + ) + ); + +CREATE +OR REPLACE VIEW `cnet_security_token_scopes` AS +SELECT + `cnet_security_tokens`.`id` AS `token_id`, + `cnet_security_scopes`.`value` AS `scope`, + `cnet_security_tokens`.`created_at` AS `created_at`, + `cnet_security_scopes`.`created_at` AS `granted_at` +FROM + ( + ( + `cnet_security_tokens` + join `cnet_security_entity_scopes` on ( + `cnet_security_tokens`.`id` = `cnet_security_entity_scopes`.`token_id` + ) + ) + join `cnet_security_scopes` on ( + `cnet_security_entity_scopes`.`scope_id` = `cnet_security_scopes`.`id` + ) + ); + +CREATE +OR REPLACE VIEW `cnet_security_token_groups_view` AS +SELECT + `cnet_security_token_groups`.`token_id`, + `cnet_security_groups`.`name` AS group_name, + `cnet_security_groups`.`created_at` AS granted_at +FROM + `cnet_security_token_groups` + JOIN `cnet_security_groups` ON ( + `cnet_security_token_groups`.`group_id` = `cnet_security_groups`.`name` + ); + +ALTER TABLE `cnet_security_entity_scopes` + ADD CONSTRAINT `entity_group` FOREIGN KEY (`group_id`) REFERENCES `cnet_security_groups` (`name`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `entity_scope` FOREIGN KEY (`scope_id`) REFERENCES `cnet_security_scopes` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `entity_token` FOREIGN KEY (`token_id`) REFERENCES `cnet_security_tokens` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `cnet_security_token_groups` + ADD CONSTRAINT `token_group` FOREIGN KEY (`token_id`) REFERENCES `cnet_security_tokens` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/token/build.gradle b/token/build.gradle new file mode 100644 index 0000000..64815bf --- /dev/null +++ b/token/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "java" + id "application" +} + +application { + mainClass = "de.craftsblock.cnet.modules.security.token.CraftsNetSecurityTokenSQLDriver" +} + +dependencies { + implementation project(":common") + + // https://repo.craftsblock.de/#/releases/de/craftsblock/craftscore/sql + implementation "de.craftsblock.craftscore:sql" +} \ No newline at end of file diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityToken.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityToken.java new file mode 100644 index 0000000..6155bdf --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/CraftsNetSecurityToken.java @@ -0,0 +1,88 @@ +package de.craftsblock.cnet.modules.security.token; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.auth.AuthChain; +import de.craftsblock.cnet.modules.security.token.adapter.HttpTokenAuthAdapter; +import de.craftsblock.cnet.modules.security.token.adapter.HttpTokenAuthType; +import de.craftsblock.cnet.modules.security.token.driver.StoreDriver; +import de.craftsblock.cnet.modules.security.token.group.GroupManager; +import de.craftsblock.craftsnet.addon.Addon; +import de.craftsblock.craftsnet.addon.meta.annotations.Depends; +import de.craftsblock.craftsnet.addon.meta.annotations.Meta; +import org.jetbrains.annotations.NotNull; + +import java.util.EnumMap; + +@Meta(name = "CraftsNetSecurityToken") +@Depends(CraftsNetSecurity.class) +public class CraftsNetSecurityToken extends Addon { + + public static final String VERSION = CraftsNetSecurity.VERSION; + private static final EnumMap HTTP_TOKEN_LOCATIONS = new EnumMap<>(HttpTokenAuthType.class); + + private GroupManager groupManager; + private TokenManager tokenManager; + + private StoreDriver storeDriver; + + @Override + public void onLoad() { + super.onLoad(); + + this.groupManager = new GroupManager(); + this.tokenManager = new TokenManager(); + + AuthChain authChain = CraftsNetSecurity.getAuthChain(); + authChain.append(new HttpTokenAuthAdapter(HTTP_TOKEN_LOCATIONS)); + } + + @Override + public void onEnable() { + super.onEnable(); + } + + @Override + public void onDisable() { + super.onDisable(); + this.storeDriver.close(); + } + + public synchronized static void setGroupManager(@NotNull GroupManager groupManager) { + getInstance().groupManager = groupManager; + } + + public synchronized static @NotNull GroupManager getGroupManager() { + return getInstance().groupManager; + } + + public synchronized static void setTokenManager(@NotNull TokenManager tokenManager) { + getInstance().tokenManager = tokenManager; + } + + public synchronized static @NotNull TokenManager getTokenManager() { + return getInstance().tokenManager; + } + + public synchronized static void setStoreDriver(@NotNull StoreDriver storeDriver) { + getInstance().storeDriver = storeDriver; + } + + public synchronized static StoreDriver getStoreDriver() { + return getInstance().storeDriver; + } + + public static String setHttpTokenLocation(HttpTokenAuthType type) { + return setHttpTokenLocation(type, type.ensureDefaultLocation()); + } + + public static String setHttpTokenLocation(@NotNull HttpTokenAuthType type, @NotNull String location) { + synchronized (HTTP_TOKEN_LOCATIONS) { + return HTTP_TOKEN_LOCATIONS.put(type, location); + } + } + + public static CraftsNetSecurityToken getInstance() { + return getAddon(CraftsNetSecurityToken.class); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java new file mode 100644 index 0000000..e6214f9 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/Token.java @@ -0,0 +1,95 @@ +package de.craftsblock.cnet.modules.security.token; + +import com.google.gson.JsonObject; +import de.craftsblock.cnet.modules.security.token.group.OptionalGroup; +import de.craftsblock.craftscore.json.Json; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; +import org.jetbrains.annotations.UnmodifiableView; +import org.springframework.security.crypto.bcrypt.BCrypt; + +import java.util.*; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +public record Token(long id, @NotNull String hash, @NotNull @UnmodifiableView Collection scopes, + @NotNull @UnmodifiableView Collection groups, @NotNull TokenDataContainer tokenDataContainer) { + + public Token(long id, @NotNull String hash, @NotNull Collection scopes, + @NotNull Collection groups, @NotNull TokenDataContainer tokenDataContainer) { + this.id = id; + this.hash = hash; + this.groups = Collections.unmodifiableCollection(groups); + this.scopes = Collections.unmodifiableCollection(scopes); + this.tokenDataContainer = tokenDataContainer; + } + + @Override + public @NotNull @Unmodifiable Collection scopes() { + return Stream.concat( + scopes.stream(), + groups.stream().filter(OptionalGroup::persisted) + .flatMap(group -> group.scopes().stream()) + ).distinct().toList(); + } + + public @NotNull @UnmodifiableView Collection directScopes() { + return scopes; + } + + public Collection groupNames() { + return groups.stream().map(OptionalGroup::name).toList(); + } + + 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) + .set("groups", this.groups.stream().map(OptionalGroup::name).toList()); + + 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"), + OptionalGroup.fromList(json.getStringList("groups")), + tokenDataContainer + ); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/TokenDataContainer.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/TokenDataContainer.java new file mode 100644 index 0000000..a02faef --- /dev/null +++ b/token/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/token/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java new file mode 100644 index 0000000..14ce058 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/TokenManager.java @@ -0,0 +1,151 @@ +package de.craftsblock.cnet.modules.security.token; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.StoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.cnet.modules.security.token.event.TokenCreateEvent; +import de.craftsblock.cnet.modules.security.token.event.cache.RevalidateTokenCacheEvent; +import de.craftsblock.cnet.modules.security.token.group.OptionalGroup; +import de.craftsblock.cnet.modules.security.token.util.CreatedToken; +import de.craftsblock.cnet.modules.security.token.util.TokenParts; +import de.craftsblock.cnet.modules.security.token.util.TokenUtil; +import de.craftsblock.craftscore.cache.LruCache; +import de.craftsblock.craftscore.utils.id.Snowflake; +import de.craftsblock.craftsnet.utils.PassphraseUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.security.crypto.bcrypt.BCrypt; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class TokenManager { + + private final LruCache tokenCache; + + public TokenManager() { + this(25); + } + + public TokenManager(int cacheSize) { + this.tokenCache = new LruCache<>(cacheSize); + } + + public synchronized void persist(Token token) { + StoreDriver.getInstance().saveToken(token); + tokenCache.put(token.id(), token); + } + + public synchronized void delete(Token token) { + this.delete(token.id()); + } + + public synchronized void delete(long id) { + StoreDriver.getInstance().deleteToken(id); + removeCache(id); + } + + public synchronized Token get(long id) { + if (tokenCache.containsKey(id)) { + return tokenCache.get(id); + } + + TokenStoreDriver driver = StoreDriver.getInstance(); + if (!driver.existsToken(id)) { + return null; + } + + Token token = driver.loadToken(id); + tokenCache.put(token.id(), token); + return token; + } + + public synchronized Token getValidated(String token) { + TokenParts parts = TokenUtil.splitToTokenParts(token); + if (parts == null) { + return null; + } + + try { + Token realToken = get(parts.id()); + if (realToken == null || !realToken.validate(parts.secret())) { + return null; + } + + return realToken; + } finally { + PassphraseUtils.erase(parts.secret()); + } + } + + public synchronized CreatedToken createPersisted(String... scopes) { + return this.createPersisted(List.of(scopes), Collections.emptyList()); + } + + public synchronized CreatedToken createPersisted(String[] scopes, String... groups) { + return this.createPersisted(List.of(scopes), List.of(groups)); + } + + public synchronized CreatedToken createPersisted(Collection scopes, String... groups) { + return this.createPersisted(scopes, List.of(groups)); + } + + public synchronized CreatedToken createPersisted(Collection scopes, Collection groups) { + CreatedToken createdToken = create(scopes, groups); + persist(createdToken.token()); + return createdToken; + } + + public CreatedToken create(String... scopes) { + return this.create(List.of(scopes)); + } + + public CreatedToken create(String[] scopes, String... groups) { + return this.create(List.of(scopes), List.of(groups)); + } + + public CreatedToken create(Collection scopes, String... groups) { + return this.create(scopes, List.of(groups)); + } + + public CreatedToken create(Collection scopes, Collection groups) { + long id = Snowflake.generate(); + byte[] secret = TokenUtil.newSecureSecret(); + String secretHash = BCrypt.hashpw(secret, BCrypt.gensalt()); + + try { + Token token = new Token(id, secretHash, scopes, OptionalGroup.fromList(groups), + new TokenDataContainer()); + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenCreateEvent(token)); + return new CreatedToken( + token, + TokenUtil.mergeTokenParts(id, secret) + ); + } finally { + PassphraseUtils.erase(secret); + } + } + + public synchronized void clearCache() { + this.tokenCache.clear(); + CraftsNetSecurity.getInstance().getListenerRegistry().call(new RevalidateTokenCacheEvent()); + } + + public synchronized void removeCache(Token token) { + this.removeCache(token.id()); + } + + public synchronized void removeCache(long id) { + this.tokenCache.remove(id); + CraftsNetSecurity.getInstance().getListenerRegistry().call(new RevalidateTokenCacheEvent(id)); + } + + public static @NotNull TokenManager getInstance() { + return CraftsNetSecurityToken.getTokenManager(); + } + + public static void setInstance(@NotNull TokenManager tokenManager) { + CraftsNetSecurityToken.setTokenManager(tokenManager); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java new file mode 100644 index 0000000..5e32bec --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthAdapter.java @@ -0,0 +1,97 @@ +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.TokenManager; +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 org.jetbrains.annotations.NotNull; + +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 @NotNull EnumMap authTypes; + + public HttpTokenAuthAdapter(@NotNull EnumMap authTypes) { + this.authTypes = authTypes; + } + + @Override + public AuthResult authenticate(Exchange exchange) { + AtomicReference authResultReference = new AtomicReference<>(); + synchronized (authTypes) { + if (authTypes.isEmpty()) { + CraftsNetSecurity.getInstance().getLogger().warning("No http token auth type is set up!"); + return AuthResult.failure("Not allowed!"); + } + + 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 = TokenManager.getInstance().getValidated(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/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java new file mode 100644 index 0000000..7c69d53 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/HttpTokenAuthType.java @@ -0,0 +1,40 @@ +package de.craftsblock.cnet.modules.security.token.adapter; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public enum HttpTokenAuthType { + + HEADER("Authorization"), + COOKIE(), + SESSION(), + ; + + private final String defaultLocation; + + HttpTokenAuthType() { + this(null); + } + + HttpTokenAuthType(String defaultLocation) { + this.defaultLocation = defaultLocation; + } + + public @Nullable String getDefaultLocation() { + return defaultLocation; + } + + public @NotNull String ensureDefaultLocation() { + return Objects.requireNonNull( + getDefaultLocation(), + "There is no default location present for the location " + this + ); + } + + public boolean hasDefaultLocation() { + return defaultLocation != null; + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java new file mode 100644 index 0000000..310b6cc --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/adapter/WebSocketTokenAuthAdapter.java @@ -0,0 +1,171 @@ +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.TokenManager; +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.addon.meta.Startup; +import de.craftsblock.craftsnet.api.http.status.HttpStatus; +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.autoregister.meta.Instantiate; +import de.craftsblock.craftsnet.events.sockets.ClientDisconnectEvent; +import de.craftsblock.craftsnet.events.sockets.message.IncomingSocketMessageEvent; +import de.craftsblock.craftsnet.events.sockets.message.OutgoingSocketMessageEvent; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; +import org.jetbrains.annotations.UnmodifiableView; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +@ApiStatus.Internal +@AutoRegister(startup = Startup.LOAD, instantiate = Instantiate.NEW) +public class WebSocketTokenAuthAdapter implements ListenerAdapter, AuthAdapter.WebSocket { + + private static final @NotNull String MESSAGE_LITERAL_WRONG_AUTH = "Not allowed!"; + private static final @NotNull Json MESSAGE_WRONG_AUTH = Json.empty() + .set("success", false) + .set("error.code", HttpStatus.ClientError.BAD_REQUEST.getCode()) + .set("error.message", MESSAGE_LITERAL_WRONG_AUTH); + + private static final @NotNull Map> AUTHENTICATED_CLIENTS = new ConcurrentHashMap<>(); + private static final @UnmodifiableView + @NotNull Map> AUTHENTICATED_CLIENTS_VIEW = Collections.unmodifiableMap(AUTHENTICATED_CLIENTS); + + @Override + public AuthResult authenticate(SocketExchange exchange) { + exchange.context().put(new RequireAuth()); + return AuthResult.skip(); + } + + @EventHandler(priority = EventPriority.NORMAL) + public void handleDisconnect(ClientDisconnectEvent event) { + final WebSocketClient client = event.getClient(); + final Context context = client.getContext(); + final Token token = context.getTyped(Token.class); + if (token == null) { + return; + } + + synchronized (AUTHENTICATED_CLIENTS) { + Collection clients = AUTHENTICATED_CLIENTS.get(token.id()); + if (clients == null) { + return; + } + + clients.remove(client); + if (clients.isEmpty()) { + AUTHENTICATED_CLIENTS.remove(token.id(), clients); + } + } + } + + @EventHandler(priority = EventPriority.NORMAL, 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("de/craftsblock/cnet/modules/security/token")) { + failAuth(client, "NO TOKEN"); + return; + } + + Token token = TokenManager.getInstance().getValidated(json.getString("de/craftsblock/cnet/modules/security/token")); + if (token == null) { + failAuth(client, "WRONG TOKEN"); + return; + } + + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenUsedEvent(token)); + context.put(token); + context.put(new Authenticated(System.currentTimeMillis())); + context.remove(RequireAuth.class); + + synchronized (AUTHENTICATED_CLIENTS) { + AUTHENTICATED_CLIENTS.computeIfAbsent(token.id(), id -> new ConcurrentLinkedQueue<>()) + .add(client); + } + } 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); + } + + public static boolean isAuthenticated(@NotNull WebSocketClient client) { + return client.getContext().containsKey(Authenticated.class); + } + + public static @Range(from = -2, to = Long.MAX_VALUE) long getAuthenticationTimestamp(@NotNull WebSocketClient client) { + final Context context = client.getContext(); + if (context.containsKey(RequireAuth.class)) { + return -1; + } + + final Authenticated authenticated = client.getContext().getTyped(Authenticated.class); + if (authenticated == null) { + return -2; + } + + return authenticated.timestamp(); + } + + public static @UnmodifiableView @NotNull Map> getAuthenticatedClients() { + return AUTHENTICATED_CLIENTS_VIEW; + } + + private static class RequireAuth { + } + + private record Authenticated(long timestamp) { + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/Driver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/Driver.java new file mode 100644 index 0000000..4fbdd63 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/Driver.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.token.driver; + +import de.craftsblock.cnet.modules.security.token.driver.GroupStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; + +sealed interface Driver extends AutoCloseable + permits GroupStoreDriver, TokenStoreDriver { + + @Override + void close(); + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/GroupStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/GroupStoreDriver.java new file mode 100644 index 0000000..502da9c --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/GroupStoreDriver.java @@ -0,0 +1,29 @@ +package de.craftsblock.cnet.modules.security.token.driver; + +import de.craftsblock.cnet.modules.security.token.group.Group; +import de.craftsblock.cnet.modules.security.token.group.GroupManager; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +public non-sealed interface GroupStoreDriver extends Driver { + + default void reload() { + GroupManager.getInstance().clearCache(); + } + + boolean existsGroup(@NotNull String name); + + Group loadGroup(@NotNull String name); + + void saveGroup(@NotNull Group group); + + default void deleteGroup(@NotNull String name) { + this.deleteGroup(loadGroup(name)); + } + + void deleteGroup(@NotNull Group group); + + @NotNull Collection getAllGroupNames(); + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/StoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/StoreDriver.java new file mode 100644 index 0000000..1ee7c73 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/StoreDriver.java @@ -0,0 +1,41 @@ +package de.craftsblock.cnet.modules.security.token.driver; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.CraftsNetSecurityToken; +import org.jetbrains.annotations.NotNull; + +public interface StoreDriver + extends AutoCloseable, GroupStoreDriver, TokenStoreDriver { + + @Override + default void close() { + synchronized (this) { + getGroupStoreDriver().close(); + getTokenStoreDriver().close(); + } + } + + default void reload() { + synchronized (this) { + getGroupStoreDriver().reload(); + getTokenStoreDriver().reload(); + } + } + + default GroupStoreDriver getGroupStoreDriver() { + return this; + } + + default TokenStoreDriver getTokenStoreDriver() { + return this; + } + + static StoreDriver getInstance() { + return CraftsNetSecurityToken.getStoreDriver(); + } + + static void setInstance(@NotNull StoreDriver storeDriver) { + CraftsNetSecurityToken.setStoreDriver(storeDriver); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/TokenStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/TokenStoreDriver.java new file mode 100644 index 0000000..02f84d9 --- /dev/null +++ b/token/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.TokenManager; +import de.craftsblock.cnet.modules.security.token.event.TokenDeleteEvent; +import de.craftsblock.cnet.modules.security.token.event.TokenPersistEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; + +import java.util.Collection; + +public non-sealed interface TokenStoreDriver extends Driver { + + default void reload() { + TokenManager.getInstance().clearCache(); + } + + boolean existsToken(@Range(from = 0, to = Long.MAX_VALUE) long id); + + Token loadToken(@Range(from = 0, to = Long.MAX_VALUE) long id); + + default void saveToken(@NotNull Token token) { + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenPersistEvent(token)); + } + + default void deleteToken(@Range(from = 0, to = Long.MAX_VALUE) long id) { + this.deleteToken(loadToken(id)); + } + + default void deleteToken(@NotNull Token token) { + CraftsNetSecurity.getInstance().getListenerRegistry().call(new TokenDeleteEvent(token)); + } + + @NotNull Collection getAllTokenIds(); + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/WrappedStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/WrappedStoreDriver.java new file mode 100644 index 0000000..e323390 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/WrappedStoreDriver.java @@ -0,0 +1,79 @@ +package de.craftsblock.cnet.modules.security.token.driver; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.group.Group; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +public class WrappedStoreDriver implements StoreDriver { + + private final G groupStoreDriver; + private final T tokenStoreDriver; + + public WrappedStoreDriver(G groupStoreDriver, T tokenStoreDriver) { + this.groupStoreDriver = groupStoreDriver; + this.tokenStoreDriver = tokenStoreDriver; + } + + @Override + public boolean existsGroup(@NotNull String name) { + return this.groupStoreDriver.existsGroup(name); + } + + @Override + public Group loadGroup(@NotNull String name) { + return this.groupStoreDriver.loadGroup(name); + } + + @Override + public void saveGroup(@NotNull Group group) { + this.groupStoreDriver.saveGroup(group); + } + + @Override + public void deleteGroup(@NotNull Group group) { + this.groupStoreDriver.deleteGroup(group); + } + + @Override + public @NotNull Collection getAllGroupNames() { + return this.groupStoreDriver.getAllGroupNames(); + } + + @Override + public boolean existsToken(long id) { + return this.tokenStoreDriver.existsToken(id); + } + + @Override + public Token loadToken(long id) { + return this.tokenStoreDriver.loadToken(id); + } + + @Override + public void saveToken(@NotNull Token token) { + this.tokenStoreDriver.saveToken(token); + } + + @Override + public void deleteToken(@NotNull Token token) { + this.tokenStoreDriver.deleteToken(token); + } + + @Override + public @NotNull Collection getAllTokenIds() { + return this.tokenStoreDriver.getAllTokenIds(); + } + + @Override + public G getGroupStoreDriver() { + return groupStoreDriver; + } + + @Override + public T getTokenStoreDriver() { + return tokenStoreDriver; + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/AbstractFileStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/AbstractFileStoreDriver.java new file mode 100644 index 0000000..7da7f27 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/AbstractFileStoreDriver.java @@ -0,0 +1,115 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.driver.file.FileDriverHotReloadManager; +import de.craftsblock.cnet.modules.security.token.driver.file.FileGroupStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.file.FileTokenStoreDriver; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftscore.json.JsonParser; +import de.craftsblock.craftsnet.logging.Logger; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +abstract sealed class AbstractFileStoreDriver implements AutoCloseable + permits FileGroupStoreDriver, FileTokenStoreDriver { + + public static final int WARN_AT_FILE_SIZE = 1024 * 1024 * 15; + + final @NotNull Path file; + final @NotNull Path directory; + private final AtomicReference json = new AtomicReference<>(); + + private final @NotNull FileDriverHotReloadManager hotReloadManager; + private boolean closed = false; + + public AbstractFileStoreDriver(Path file) { + this.file = file; + this.directory = file.toAbsolutePath().getParent(); + + try { + if (Files.notExists(directory)) { + Files.createDirectories(directory); + } + + if (Files.notExists(file)) { + Files.createFile(file); + } + + long size = Files.size(file); + if (size >= WARN_AT_FILE_SIZE) { + Logger logger = CraftsNetSecurity.getInstance().getLogger(); + logger.warning( + "The store (%s) is larger than %s MB (%s MB), which may cause slowdowns!", + file, WARN_AT_FILE_SIZE / 1024 / 1024, size / 1024 / 1024 + ); + logger.warning("Please consider using a database."); + } + + this.reload(); + this.hotReloadManager = new FileDriverHotReloadManager(this); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read file: " + e.getMessage(), e); + } + } + + protected void json(Consumer consumer) { + ensureOpen(); + + synchronized (this.json) { + consumer.accept(this.json.get()); + } + } + + protected R json(Function function) { + ensureOpen(); + + synchronized (this.json) { + return function.apply(this.json.get()); + } + } + + public void reload() { + ensureOpen(); + synchronized (this.json) { + this.json.set(JsonParser.parse(file)); + } + } + + public void ensureOpen() { + if (closed) { + throw new IllegalStateException("No operations allowed after closure!"); + } + } + + @Override + public void close() { + ensureOpen(); + try { + this.hotReloadManager.close(); + this.json.set(null); + } finally { + this.closed = true; + } + } + + public @NotNull Path getFile() { + return file; + } + + public @NotNull Path getDirectory() { + return directory; + } + + public boolean isClosed() { + return closed; + } + + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileDriverHotReloadManager.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileDriverHotReloadManager.java new file mode 100644 index 0000000..c4bd458 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileDriverHotReloadManager.java @@ -0,0 +1,63 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.craftsnet.utils.reflection.TypeUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; + +class FileDriverHotReloadManager extends Thread implements AutoCloseable { + + private final @NotNull AbstractFileStoreDriver driver; + private final @NotNull WatchService watchService; + + public FileDriverHotReloadManager(@NotNull AbstractFileStoreDriver driver) { + super("Token file watcher"); + try { + this.driver = driver; + this.watchService = FileSystems.getDefault().newWatchService(); + driver.getDirectory().register(this.watchService, StandardWatchEventKinds.ENTRY_MODIFY); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create: " + getClass().getSimpleName(), e); + } + + this.start(); + } + + @Override + public void run() { + 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 = driver.getDirectory().resolve(path); + if (realPath.equals(driver.getFile().toAbsolutePath())) { + CraftsNetSecurity.getInstance().getLogger().debug("Detected file system change, " + + "reloading %s file.", driver.getFile().getFileName()); + driver.reload(); + } + } + key.reset(); + } + } catch (InterruptedException | ClosedWatchServiceException ignored) { + } + } + + @Override + public void close() { + try { + this.watchService.close(); + this.interrupt(); + this.join(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Failed to close: " + e.getMessage(), e); + } + } +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileGroupStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileGroupStoreDriver.java new file mode 100644 index 0000000..d2d8f9b --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileGroupStoreDriver.java @@ -0,0 +1,66 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.token.driver.GroupStoreDriver; +import de.craftsblock.cnet.modules.security.token.group.Group; +import de.craftsblock.craftscore.json.Json; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.Collection; + +public final class FileGroupStoreDriver extends AbstractFileStoreDriver implements GroupStoreDriver { + + FileGroupStoreDriver(Path file) { + super(file); + } + + @Override + public void reload() { + GroupStoreDriver.super.reload(); + super.reload(); + } + + @Override + public boolean existsGroup(@NotNull String name) { + return this.json(json -> { + return json.contains(name); + }); + } + + @Override + public Group loadGroup(@NotNull String name) { + Json group = this.json(json -> { + return json.getJson(name); + }); + + if (group == null) { + return null; + } + + return Group.fromJson(group); + } + + @Override + public void saveGroup(@NotNull Group group) { + this.json(json -> { + json.set(group.name(), group.toJson()); + json.save(file); + }); + } + + @Override + public void deleteGroup(@NotNull Group group) { + this.json(json -> { + json.remove(group.name()); + json.save(file); + }); + } + + @Override + public @NotNull Collection getAllGroupNames() { + return this.json(json -> { + return json.keySet(); + }); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileStoreDriver.java new file mode 100644 index 0000000..eb05f86 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileStoreDriver.java @@ -0,0 +1,30 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.token.driver.WrappedStoreDriver; + +import java.nio.file.Path; + +public final class FileStoreDriver extends WrappedStoreDriver { + + FileStoreDriver(FileGroupStoreDriver groupStoreDriver, FileTokenStoreDriver tokenStoreDriver) { + super(groupStoreDriver, tokenStoreDriver); + } + + @Override + public FileGroupStoreDriver getGroupStoreDriver() { + return super.getGroupStoreDriver(); + } + + @Override + public FileTokenStoreDriver getTokenStoreDriver() { + return super.getTokenStoreDriver(); + } + + public static FileStoreDriver create(Path groupsFile, Path tokensFile) { + return new FileStoreDriver( + new FileGroupStoreDriver(groupsFile), + new FileTokenStoreDriver(tokensFile) + ); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java new file mode 100644 index 0000000..b52b974 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/driver/file/FileTokenStoreDriver.java @@ -0,0 +1,73 @@ +package de.craftsblock.cnet.modules.security.token.driver.file; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.driver.TokenStoreDriver; +import de.craftsblock.craftscore.json.Json; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; + +public final class FileTokenStoreDriver extends AbstractFileStoreDriver implements TokenStoreDriver { + + FileTokenStoreDriver(@NotNull Path tokensFile) { + super(tokensFile); + } + + @Override + public void reload() { + TokenStoreDriver.super.reload(); + super.reload(); + } + + @Override + public boolean existsToken(long id) { + return this.json(json -> { + return json.contains(String.valueOf(id)); + }); + } + + @Override + public Token loadToken(long id) { + Json token = this.json(json -> { + return json.getJson(String.valueOf(id)); + }); + + if (token == null) { + return null; + } + + return Token.fromJson(token); + } + + @Override + public void saveToken(@NotNull Token token) { + this.json(json -> { + json.set(String.valueOf(token.id()), token.toJson()); + json.save(file); + TokenStoreDriver.super.saveToken(token); + }); + } + + @Override + public void deleteToken(@NotNull Token token) { + this.json(json -> { + json.remove(String.valueOf(token.id())); + json.save(file); + TokenStoreDriver.super.deleteToken(token); + }); + } + + @Override + public @NotNull Collection getAllTokenIds() { + Set stringIds = this.json(json -> { + return json.keySet(); + }); + + return stringIds.stream() + .map(Long::parseLong) + .toList(); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java new file mode 100644 index 0000000..8264172 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenCreateEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenEvent; + +public final class TokenCreateEvent extends TokenEvent { + + public TokenCreateEvent(Token token) { + super(token); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java new file mode 100644 index 0000000..4e70ab5 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenDeleteEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenEvent; + +public final class TokenDeleteEvent extends TokenEvent { + + public TokenDeleteEvent(Token token) { + super(token); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenEvent.java new file mode 100644 index 0000000..ee28fed --- /dev/null +++ b/token/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/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java new file mode 100644 index 0000000..c336709 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenPersistEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenEvent; + +public final class TokenPersistEvent extends TokenEvent { + + public TokenPersistEvent(Token token) { + super(token); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java new file mode 100644 index 0000000..bd2ad2d --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/TokenUsedEvent.java @@ -0,0 +1,12 @@ +package de.craftsblock.cnet.modules.security.token.event; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.event.TokenEvent; + +public final class TokenUsedEvent extends TokenEvent { + + public TokenUsedEvent(Token token) { + super(token); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateCacheEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateCacheEvent.java new file mode 100644 index 0000000..464b026 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateCacheEvent.java @@ -0,0 +1,16 @@ +package de.craftsblock.cnet.modules.security.token.event.cache; + +import de.craftsblock.craftscore.event.Event; + +public sealed class RevalidateCacheEvent extends Event + permits RevalidateGroupCacheEvent, RevalidateTokenCacheEvent { + + public T getSubject() { + return null; + } + + public boolean hasSubject() { + return false; + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateGroupCacheEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateGroupCacheEvent.java new file mode 100644 index 0000000..a9df526 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateGroupCacheEvent.java @@ -0,0 +1,27 @@ +package de.craftsblock.cnet.modules.security.token.event.cache; + +import de.craftsblock.cnet.modules.security.token.event.cache.RevalidateCacheEvent; + +public final class RevalidateGroupCacheEvent extends RevalidateCacheEvent { + + private final String subject; + + public RevalidateGroupCacheEvent() { + this(null); + } + + public RevalidateGroupCacheEvent(String subject) { + this.subject = subject; + } + + @Override + public String getSubject() { + return subject; + } + + @Override + public boolean hasSubject() { + return subject != null; + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateTokenCacheEvent.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateTokenCacheEvent.java new file mode 100644 index 0000000..c8c9f04 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/event/cache/RevalidateTokenCacheEvent.java @@ -0,0 +1,25 @@ +package de.craftsblock.cnet.modules.security.token.event.cache; + +public final class RevalidateTokenCacheEvent extends RevalidateCacheEvent { + + private final long subject; + + public RevalidateTokenCacheEvent() { + this(-1); + } + + public RevalidateTokenCacheEvent(long subject) { + this.subject = subject; + } + + @Override + public Long getSubject() { + return subject; + } + + @Override + public boolean hasSubject() { + return subject >= 0; + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/exception/CachedEntityOutdatedException.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/exception/CachedEntityOutdatedException.java new file mode 100644 index 0000000..16b651e --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/exception/CachedEntityOutdatedException.java @@ -0,0 +1,9 @@ +package de.craftsblock.cnet.modules.security.token.exception; + +public class CachedEntityOutdatedException extends RuntimeException { + + public CachedEntityOutdatedException(String message) { + super(message); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/Group.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/Group.java new file mode 100644 index 0000000..2ca1e0b --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/Group.java @@ -0,0 +1,74 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import de.craftsblock.craftscore.json.Json; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; + +import java.util.*; + +public final class Group { + + private final @NotNull String name; + private final @NotNull Collection scopes; + private final @NotNull + @UnmodifiableView Collection scopesView; + + public Group(@NotNull String name, @NotNull Collection scopes) { + this.name = name; + this.scopes = new ArrayList<>(scopes.stream().distinct().toList()); + this.scopesView = Collections.unmodifiableCollection(this.scopes); + } + + public void addScopes(String... scopes) { + for (String scope : scopes) { + if (!hasScope(scope)) { + this.scopes.add(scope); + } + } + } + + public void removeScopes(String... scopes) { + this.scopes.removeAll(Arrays.asList(scopes)); + } + + public boolean hasScope(String scope) { + return scopes.contains(scope); + } + + public boolean hasScopes(String... scopes) { + return this.scopes.containsAll(Arrays.asList(scopes)); + } + + public @NotNull String name() { + return name; + } + + public @NotNull @UnmodifiableView Collection scopes() { + return scopesView; + } + + public Json toJson() { + return Json.empty() + .set("name", name) + .set("scopes", scopes); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (Group) obj; + return Objects.equals(this.name, that.name) && + Objects.equals(this.scopes, that.scopes); + } + + @Override + public int hashCode() { + return Objects.hash(name, scopes); + } + + public static Group fromJson(Json json) { + return new Group(json.getString("name"), json.getStringList("scopes")); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupManager.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupManager.java new file mode 100644 index 0000000..42e5702 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupManager.java @@ -0,0 +1,118 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.CraftsNetSecurityToken; +import de.craftsblock.cnet.modules.security.token.driver.GroupStoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.StoreDriver; +import de.craftsblock.cnet.modules.security.token.event.cache.RevalidateGroupCacheEvent; +import de.craftsblock.craftscore.cache.LruCache; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Consumer; + +public class GroupManager { + + private final LruCache groupCache; + + public GroupManager() { + this(25); + } + + public GroupManager(int cacheSize) { + this.groupCache = new LruCache<>(cacheSize); + } + + public synchronized @NotNull Group createOrUpdate(@NotNull String name, @NotNull Consumer<@NotNull Group> updater) { + GroupStoreDriver driver = StoreDriver.getInstance(); + if (driver.existsGroup(name)) { + return Objects.requireNonNull(this.update(name, updater)); + } + + Group group = this.create(name); + updater.accept(group); + driver.saveGroup(group); + return group; + } + + public synchronized @NotNull Group create(@NotNull String name, @NotNull String @NotNull ... scopes) { + GroupStoreDriver driver = StoreDriver.getInstance(); + Group existing = get(name); + if (existing != null) { + return existing; + } + + Group group = new Group(name, Arrays.asList(scopes)); + driver.saveGroup(group); + groupCache.put(name, group); + return group; + } + + public synchronized @Nullable Group update(@NotNull String name, @NotNull Consumer<@NotNull Group> updater) { + GroupStoreDriver driver = StoreDriver.getInstance(); + Group group = get(name); + if (group == null) { + return null; + } + + updater.accept(group); + driver.saveGroup(group); + return group; + } + + public synchronized @Nullable Group get(@NotNull String name) { + if (groupCache.containsKey(name)) { + return groupCache.get(name); + } + + GroupStoreDriver driver = StoreDriver.getInstance(); + Group group = driver.loadGroup(name); + if (group == null) { + return null; + } + + groupCache.put(name, group); + return group; + } + + public synchronized void delete(@NotNull Group group) { + this.delete(group.name()); + } + + public synchronized void delete(@NotNull String name) { + StoreDriver.getInstance().deleteGroup(name); + removeCache(name); + } + + public synchronized void clearCache() { + groupCache.clear(); + CraftsNetSecurity.getInstance().getListenerRegistry().call(new RevalidateGroupCacheEvent()); + } + + public synchronized void removeCache(@NotNull Group group) { + this.removeCache(group.name()); + } + + public synchronized void removeCache(@NotNull String name) { + Group removed = this.groupCache.remove(name); + String realGroupName; + if (removed == null) { + realGroupName = name; + } else { + realGroupName = removed.name(); + } + + CraftsNetSecurity.getInstance().getListenerRegistry().call(new RevalidateGroupCacheEvent(realGroupName)); + } + + public static @NotNull GroupManager getInstance() { + return CraftsNetSecurityToken.getGroupManager(); + } + + public static void setInstance(@NotNull GroupManager groupManager) { + CraftsNetSecurityToken.setGroupManager(groupManager); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequest.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequest.java new file mode 100644 index 0000000..1fce104 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequest.java @@ -0,0 +1,9 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.List; + +@ApiStatus.Internal +record GroupRequest(List groups) { +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequirement.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequirement.java new file mode 100644 index 0000000..6df12a7 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupRequirement.java @@ -0,0 +1,61 @@ +package de.craftsblock.cnet.modules.security.token.group; + +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 GroupRequirement + permits GroupRequirement.Http, GroupRequirement.WebSocket { + + default boolean injectRequest(Context context, RouteRegistry.EndpointMapping mapping) { + if (mapping.isPresent(getAnnotation(), "value")) { + List groups = mapping.getRequirements(getAnnotation(), "value"); + context.put(new GroupRequest(groups)); + } else { + context.put(new GroupRequest(Collections.emptyList())); + } + + return true; + } + + Class getAnnotation(); + + @AutoRegister(startup = Startup.LOAD) + final class Http extends WebRequirement implements GroupRequirement { + + public Http() { + super(RequireGroup.class); + } + + @Override + public boolean applies(Request request, RouteRegistry.EndpointMapping mapping) { + return injectRequest(request.getExchange().context(), mapping); + } + + } + + @AutoRegister(startup = Startup.LOAD) + final class WebSocket extends WebSocketRequirement implements GroupRequirement { + + public WebSocket() { + super(RequireGroup.class); + } + + @Override + public boolean applies(WebSocketClient client, RouteRegistry.EndpointMapping mapping) { + return injectRequest(client.getContext(), mapping); + } + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupResolveMiddleware.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupResolveMiddleware.java new file mode 100644 index 0000000..c5cc5a8 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/GroupResolveMiddleware.java @@ -0,0 +1,91 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.group.GroupRequest; +import de.craftsblock.cnet.modules.security.token.group.UsedGroups; +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.http.status.HttpStatus; +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.Collections; +import java.util.function.Consumer; + +@ApiStatus.Internal +@AutoRegister(startup = Startup.LOAD) +public class GroupResolveMiddleware implements ListenerAdapter { + + private final Json MISSING_GROUPS_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(GroupRequest.class)) { + return; + } + + if (!context.containsKey(Token.class)) { + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("NO TOKEN"); + } + + onFailure.accept(subject); + return; + } + + final Token token = context.getTyped(Token.class); + final GroupRequest result = context.getTyped(GroupRequest.class); + if (token.groupNames().containsAll(result.groups())) { + context.remove(GroupRequest.class); + context.put(new UsedGroups(Collections.unmodifiableList(result.groups()))); + return; + } + + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("GROUP MISMATCH"); + } + + onFailure.accept(subject); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreWhenCancelled = true) + public void handleRequest(RouteRequestEvent event) { + final Exchange exchange = event.getExchange(); + handle(exchange, event, exchange.response(), response -> { + if (!response.headersSent()) { + response.setStatus(HttpStatus.ClientError.BAD_REQUEST); + } + + if (!response.sendingFile()) { + response.print(MISSING_GROUPS_MESSAGE); + } + }); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreWhenCancelled = true) + public void handleWebSocketMessage(IncomingSocketMessageEvent event) { + final SocketExchange exchange = event.getExchange(); + handle(exchange, event, exchange.client(), client -> { + client.sendMessage(MISSING_GROUPS_MESSAGE); + client.close(ClosureCode.NORMAL, "Not allowed!"); + }); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/OptionalGroup.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/OptionalGroup.java new file mode 100644 index 0000000..3900cc6 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/OptionalGroup.java @@ -0,0 +1,46 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; + +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +public record OptionalGroup(@NotNull String name, @NotNull Optional optionalGroup) { + + public boolean persisted() { + return optionalGroup.isPresent(); + } + + public @Nullable Group group() { + return optionalGroup.orElse(null); + } + + public @NotNull @UnmodifiableView Collection scopes() { + Group group = group(); + if (group == null) { + return Collections.emptyList(); + } + + return group.scopes(); + } + + public static OptionalGroup of(@NotNull String name, @Nullable Group group) { + if (group != null) { + return new OptionalGroup(group.name(), Optional.of(group)); + } + + return new OptionalGroup(name, Optional.empty()); + } + + public static OptionalGroup fromString(String name) { + return of(name, GroupManager.getInstance().get(name)); + } + + public static Collection fromList(Collection names) { + return names.stream().map(OptionalGroup::fromString).toList(); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/RequireGroup.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/RequireGroup.java new file mode 100644 index 0000000..70ac6dc --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/RequireGroup.java @@ -0,0 +1,18 @@ +package de.craftsblock.cnet.modules.security.token.group; + +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 RequireGroup { + + @RequirementStore + String[] value(); + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/UsedGroups.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/UsedGroups.java new file mode 100644 index 0000000..f0d17b6 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/group/UsedGroups.java @@ -0,0 +1,6 @@ +package de.craftsblock.cnet.modules.security.token.group; + +import java.util.Collection; + +public record UsedGroups(Collection groups) { +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java new file mode 100644 index 0000000..b44bd25 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/TokenPostSetupListener.java @@ -0,0 +1,37 @@ +package de.craftsblock.cnet.modules.security.token.listener; + +import de.craftsblock.cnet.modules.security.CraftsNetSecurity; +import de.craftsblock.cnet.modules.security.token.CraftsNetSecurityToken; +import de.craftsblock.cnet.modules.security.token.driver.StoreDriver; +import de.craftsblock.cnet.modules.security.token.driver.file.FileStoreDriver; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.EventPriority; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import de.craftsblock.craftsnet.events.addons.AllAddonsLoadedEvent; +import org.jetbrains.annotations.ApiStatus; + +import java.nio.file.Path; + +@ApiStatus.Internal +@AutoRegister(startup = Startup.LOAD) +public class TokenPostSetupListener implements ListenerAdapter { + + @EventHandler(priority = EventPriority.HIGHEST) + public void registerFallbackDriver(AllAddonsLoadedEvent event) { + StoreDriver currentDriver = StoreDriver.getInstance(); + if (currentDriver != null) { + return; + } + + Path dataPath = CraftsNetSecurity.getInstance().getDataPath(); + FileStoreDriver driver = FileStoreDriver.create( + dataPath.resolve("groups.json"), + dataPath.resolve("tokens.json") + ); + + CraftsNetSecurityToken.setStoreDriver(driver); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/WebSocketRevalidateCacheListener.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/WebSocketRevalidateCacheListener.java new file mode 100644 index 0000000..23763d8 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/listener/WebSocketRevalidateCacheListener.java @@ -0,0 +1,90 @@ +package de.craftsblock.cnet.modules.security.token.listener; + +import de.craftsblock.cnet.modules.security.token.Token; +import de.craftsblock.cnet.modules.security.token.adapter.WebSocketTokenAuthAdapter; +import de.craftsblock.cnet.modules.security.token.driver.StoreDriver; +import de.craftsblock.cnet.modules.security.token.event.cache.RevalidateCacheEvent; +import de.craftsblock.cnet.modules.security.token.event.cache.RevalidateTokenCacheEvent; +import de.craftsblock.cnet.modules.security.token.group.UsedGroups; +import de.craftsblock.cnet.modules.security.token.scope.UsedScopes; +import de.craftsblock.craftscore.event.EventHandler; +import de.craftsblock.craftscore.event.ListenerAdapter; +import de.craftsblock.craftscore.json.Json; +import de.craftsblock.craftsnet.addon.meta.Startup; +import de.craftsblock.craftsnet.api.http.status.HttpStatus; +import de.craftsblock.craftsnet.api.utils.Context; +import de.craftsblock.craftsnet.api.websocket.ClosureCode; +import de.craftsblock.craftsnet.api.websocket.WebSocketClient; +import de.craftsblock.craftsnet.autoregister.meta.AutoRegister; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +@ApiStatus.Internal +@AutoRegister(startup = Startup.LOAD) +public class WebSocketRevalidateCacheListener implements ListenerAdapter { + + private static final String MESSAGE_NO_LONGER_AUTHENTICATED = Json.empty() + .set("success", false) + .set("error.code", HttpStatus.ClientError.UNAUTHORIZED.getCode()) + .set("error.message", "No longer authenticated!") + .toString(); + + @EventHandler + public void handleCacheRevalidation(RevalidateCacheEvent event) { + if (event instanceof RevalidateTokenCacheEvent && event.hasSubject()) { + Collection clients = WebSocketTokenAuthAdapter.getAuthenticatedClients() + .get(event.getSubject()); + + if (clients == null || clients.isEmpty()) { + return; + } + + clients.forEach(this::revalidateWebSocketClient); + return; + } + + for (Collection clients : WebSocketTokenAuthAdapter.getAuthenticatedClients().values()) { + clients.forEach(this::revalidateWebSocketClient); + } + } + + private void revalidateWebSocketClient(WebSocketClient client) { + final Context context = client.getContext(); + final Token token = context.getTyped(Token.class); + final UsedScopes usedScopes = context.getTyped(UsedScopes.class); + final UsedGroups usedGroups = context.getTyped(UsedGroups.class); + if (token == null || usedScopes == null || usedGroups == null) { + clientNoLongerAuthenticated(client); + return; + } + + StoreDriver storeDriver = StoreDriver.getInstance(); + Token freshToken = storeDriver.loadToken(token.id()); + if (token.equals(freshToken)) { + context.put(freshToken); + return; + } + + if (freshToken != null + && freshToken.scopes().containsAll(usedScopes.scopes()) + && freshToken.groupNames().containsAll(usedGroups.groups())) { + context.put(freshToken); + return; + } + + clientNoLongerAuthenticated(client); + } + + private void clientNoLongerAuthenticated(@NotNull WebSocketClient client) { + final Context context = client.getContext(); + context.remove(Token.class); + context.remove(UsedGroups.class); + context.remove(UsedScopes.class); + + client.sendMessage(MESSAGE_NO_LONGER_AUTHENTICATED); + client.close(ClosureCode.SERVER_ERROR, "No longer authenticated"); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/RequireScope.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/RequireScope.java new file mode 100644 index 0000000..7074c19 --- /dev/null +++ b/token/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/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequest.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequest.java new file mode 100644 index 0000000..abbb2a1 --- /dev/null +++ b/token/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/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java new file mode 100644 index 0000000..fe295f6 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeRequirement.java @@ -0,0 +1,62 @@ +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(); + + @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); + } + + } + + @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/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java new file mode 100644 index 0000000..0c6175e --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/ScopeResolveMiddleware.java @@ -0,0 +1,89 @@ +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.http.status.HttpStatus; +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.Collections; +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", HttpStatus.ClientError.BAD_REQUEST.getCode()) + .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"); + } + + onFailure.accept(subject); + return; + } + + final Token token = context.getTyped(Token.class); + final ScopeRequest result = context.getTyped(ScopeRequest.class); + if (token.scopes().containsAll(result.scopes())) { + context.remove(ScopeRequest.class); + context.put(new UsedScopes(Collections.unmodifiableList(result.scopes()))); + return; + } + + event.setCancelled(true); + if (event instanceof EventWithCancelReason withCancelReason) { + withCancelReason.setCancelReason("SCOPE MISMATCH"); + } + + onFailure.accept(subject); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreWhenCancelled = true) + public void handleRequest(RouteRequestEvent event) { + final Exchange exchange = event.getExchange(); + handle(exchange, event, exchange.response(), response -> { + if (!response.headersSent()) { + response.setStatus(HttpStatus.ClientError.BAD_REQUEST); + } + + if (!response.sendingFile()) { + response.print(MISSING_SCOPES_MESSAGE); + } + }); + } + + @EventHandler(priority = EventPriority.HIGH, 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/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/UsedScopes.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/UsedScopes.java new file mode 100644 index 0000000..21d2969 --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/scope/UsedScopes.java @@ -0,0 +1,6 @@ +package de.craftsblock.cnet.modules.security.token.scope; + +import java.util.Collection; + +public record UsedScopes(Collection scopes) { +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/CreatedToken.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/CreatedToken.java new file mode 100644 index 0000000..d0a261e --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/CreatedToken.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 CreatedToken(Token token, byte[] plain) implements AutoCloseable { + + public String plainStringify() { + return PassphraseUtils.stringify(plain); + } + + @Override + public void close() { + PassphraseUtils.erase(plain); + } + +} diff --git a/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenParts.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenParts.java new file mode 100644 index 0000000..0299224 --- /dev/null +++ b/token/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/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java b/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java new file mode 100644 index 0000000..61f3bee --- /dev/null +++ b/token/src/main/java/de/craftsblock/cnet/modules/security/token/util/TokenUtil.java @@ -0,0 +1,78 @@ +package de.craftsblock.cnet.modules.security.token.util; + +import de.craftsblock.cnet.modules.security.token.util.TokenParts; +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; + } + + public static byte[] getTokenPartSeparatorBytes() { + return TOKEN_PART_SEPARATOR_BYTES; + } + +} diff --git a/token/src/main/resources/addon.json b/token/src/main/resources/addon.json new file mode 100644 index 0000000..ab47651 --- /dev/null +++ b/token/src/main/resources/addon.json @@ -0,0 +1,20 @@ +{ + "name": "CraftsNetSecurityToken", + "main": "de.craftsblock.cnet.modules.security.token.CraftsNetSecurityToken", + "description": "Protect your api with an permission based token system.", + "website": "https://craftsblock.de", + "version": "1.0.0", + "authors": [ + "Philipp Maywald", + "CraftsBlock" + ], + "depends": [ + "CraftsNetSecurity" + ], + "repositories": [ + "https://repo.craftsblock.de/releases" + ], + "dependencies": [ + "org.springframework.security:spring-security-crypto:7.1.0-M3" + ] +} \ No newline at end of file