diff --git a/.gitignore b/.gitignore index 3ddf897..5d393b1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ replay_pid* .idea cmake-build-*/ *.iws +work/ out/ build/ .idea_modules/ diff --git a/pom.xml b/pom.xml index ba4e6b5..1a1bb24 100644 --- a/pom.xml +++ b/pom.xml @@ -3,10 +3,17 @@ xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.0 + + me.braydon LicenseServer - 1.0-SNAPSHOT + 1.0.0 + A simple open-source licensing server for your products. 20 @@ -15,36 +22,48 @@ UTF-8 - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - ${java.version} - ${java.version} - - - - org.apache.maven.plugins - maven-shade-plugin - 3.1.0 - - - package - - shade - - - - - - - - src/main/resources - true - - - + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + org.mindrot + jbcrypt + 0.4 + compile + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + compile + + + + + com.google.code.gson + gson + 2.10.1 + compile + + + + + org.springframework.boot + spring-boot-starter-web + compile + + + org.springframework.boot + spring-boot-starter-test + test + + \ No newline at end of file diff --git a/src/main/java/me/braydon/license/LicenseServer.java b/src/main/java/me/braydon/license/LicenseServer.java new file mode 100644 index 0000000..b475ad6 --- /dev/null +++ b/src/main/java/me/braydon/license/LicenseServer.java @@ -0,0 +1,39 @@ +package me.braydon.license; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + +/** + * @author Braydon + */ +@SpringBootApplication +@Slf4j +public class LicenseServer { + public static final Gson GSON = new GsonBuilder() + .serializeNulls() + .create(); + + @SneakyThrows + public static void main(@NonNull String[] args) { + File config = new File("application.yml"); + if (!config.exists()) { // Saving the default config if it doesn't exist locally + Files.copy(Objects.requireNonNull(LicenseServer.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved + config.getAbsolutePath() + ); + return; + } + log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config + SpringApplication.run(LicenseServer.class, args); // Load the application + } +} diff --git a/src/main/java/me/braydon/license/common/RandomUtils.java b/src/main/java/me/braydon/license/common/RandomUtils.java new file mode 100644 index 0000000..9fca53a --- /dev/null +++ b/src/main/java/me/braydon/license/common/RandomUtils.java @@ -0,0 +1,30 @@ +package me.braydon.license.common; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +/** + * @author Braydon + */ +@UtilityClass +public final class RandomUtils { + /** + * The license key format to use. + */ + private static final String LICENSE_KEY_FORMAT = "%04X-%04X-%04X-%04X"; + + /** + * Generate a random license key. + * + * @return the random license key + */ + @NonNull + public static String generateLicenseKey() { + int segments = LICENSE_KEY_FORMAT.split("-").length; // The amount of segments + Object[] parts = new Object[segments]; + for (int i = 0; i < segments; i++) { // Generate a random part for each segment + parts[i] = (int) (Math.random() * 0xFFFF); + } + return String.format(LICENSE_KEY_FORMAT, parts); + } +} diff --git a/src/main/java/me/braydon/license/controller/LicenseController.java b/src/main/java/me/braydon/license/controller/LicenseController.java new file mode 100644 index 0000000..82b3fa9 --- /dev/null +++ b/src/main/java/me/braydon/license/controller/LicenseController.java @@ -0,0 +1,59 @@ +package me.braydon.license.controller; + +import com.google.gson.JsonObject; +import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; +import me.braydon.license.LicenseServer; +import me.braydon.license.exception.APIException; +import me.braydon.license.model.License; +import me.braydon.license.service.LicenseService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * @author Braydon + */ +@RestController +@RequestMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE) +public final class LicenseController { + /** + * The {@link LicenseService} to use. + */ + @NonNull private final LicenseService service; + + @Autowired + public LicenseController(@NonNull LicenseService service) { + this.service = service; + } + + /** + * This route handle checking of licenses. + * + * @param body the body of the request + * @return the response entity + * @see License for license + * @see ResponseEntity for response entity + */ + @GetMapping("/check") + @ResponseBody + public ResponseEntity check(@NonNull HttpServletRequest request, @RequestBody @NonNull String body) { + try { // Attempt to check the license + String ip = request.getRemoteAddr(); // The IP of the requester + + JsonObject jsonObject = LicenseServer.GSON.fromJson(body, JsonObject.class); + String key = jsonObject.get("key").getAsString(); // Get the key + String product = jsonObject.get("product").getAsString(); // Get the product + String hwid = jsonObject.get("hwid").getAsString(); // Get the hwid + + service.check(key, product, ip, hwid); // Check the license + return ResponseEntity.ok().build(); // Return OK + } catch (APIException ex) { // Handle the exception + return ResponseEntity.status(ex.getStatus()) + .body(Map.of("error", ex.getLocalizedMessage())); + } + } +} diff --git a/src/main/java/me/braydon/license/exception/APIException.java b/src/main/java/me/braydon/license/exception/APIException.java new file mode 100644 index 0000000..d35fb30 --- /dev/null +++ b/src/main/java/me/braydon/license/exception/APIException.java @@ -0,0 +1,25 @@ +package me.braydon.license.exception; + +import lombok.Getter; +import lombok.NonNull; +import org.springframework.http.HttpStatus; + +/** + * Represents an API exception. + * + * @author Braydon + */ +@Getter +public class APIException extends RuntimeException { + /** + * The status of this exception. + * + * @see HttpStatus for status + */ + @NonNull private final HttpStatus status; + + public APIException(@NonNull HttpStatus status, @NonNull String message) { + super(message); + this.status = status; + } +} diff --git a/src/main/java/me/braydon/license/exception/LicenseHwidLimitExceededException.java b/src/main/java/me/braydon/license/exception/LicenseHwidLimitExceededException.java new file mode 100644 index 0000000..b22df78 --- /dev/null +++ b/src/main/java/me/braydon/license/exception/LicenseHwidLimitExceededException.java @@ -0,0 +1,17 @@ +package me.braydon.license.exception; + +import me.braydon.license.model.License; +import org.springframework.http.HttpStatus; + +/** + * This exception is raised when + * a {@link License} has its HWID + * limit exceeded. + * + * @author Braydon + */ +public class LicenseHwidLimitExceededException extends APIException { + public LicenseHwidLimitExceededException() { + super(HttpStatus.BAD_REQUEST, "License key HWID limit has been exceeded"); + } +} diff --git a/src/main/java/me/braydon/license/exception/LicenseIpLimitExceededException.java b/src/main/java/me/braydon/license/exception/LicenseIpLimitExceededException.java new file mode 100644 index 0000000..0dddf54 --- /dev/null +++ b/src/main/java/me/braydon/license/exception/LicenseIpLimitExceededException.java @@ -0,0 +1,17 @@ +package me.braydon.license.exception; + +import me.braydon.license.model.License; +import org.springframework.http.HttpStatus; + +/** + * This exception is raised when + * a {@link License} has its IP + * limit exceeded. + * + * @author Braydon + */ +public class LicenseIpLimitExceededException extends APIException { + public LicenseIpLimitExceededException() { + super(HttpStatus.BAD_REQUEST, "License key IP limit has been exceeded"); + } +} diff --git a/src/main/java/me/braydon/license/exception/LicenseNotFoundException.java b/src/main/java/me/braydon/license/exception/LicenseNotFoundException.java new file mode 100644 index 0000000..c25d77f --- /dev/null +++ b/src/main/java/me/braydon/license/exception/LicenseNotFoundException.java @@ -0,0 +1,16 @@ +package me.braydon.license.exception; + +import me.braydon.license.model.License; +import org.springframework.http.HttpStatus; + +/** + * This exception is raised when + * a {@link License} is not found. + * + * @author Braydon + */ +public class LicenseNotFoundException extends APIException { + public LicenseNotFoundException() { + super(HttpStatus.NOT_FOUND, "License not found"); + } +} diff --git a/src/main/java/me/braydon/license/model/License.java b/src/main/java/me/braydon/license/model/License.java new file mode 100644 index 0000000..73c3dd9 --- /dev/null +++ b/src/main/java/me/braydon/license/model/License.java @@ -0,0 +1,92 @@ +package me.braydon.license.model; + +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; +import me.braydon.license.exception.APIException; +import me.braydon.license.exception.LicenseHwidLimitExceededException; +import me.braydon.license.exception.LicenseIpLimitExceededException; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.Date; +import java.util.Set; + +/** + * Represents a license key. + * + * @author Braydon + */ +@Document("keys") +@Setter +@Getter +@ToString +public class License { + /** + * The key of this license. + */ + @Id @NonNull private String key; + + /** + * The product this license is for. + */ + @NonNull private String product; + + /** + * The optional description of this license. + */ + private String description; + + /** + * The amount of uses this license has. + */ + private int uses; + + /** + * The IPs used on this license. + *

+ * These IPs are encrypted using AES-256. + *

+ */ + private Set ips; + + /** + * The hardware IDs that were used on this license. + */ + private Set hwids; + + /** + * The limit of IPs that can be used on this license. + */ + private int ipLimit; + + /** + * The limit of HWIDs that can be used on this license. + */ + private int hwidLimit; + + /** + * The date this license was created. + */ + @NonNull private Date created; + + /** + * Invoked when this license is used. + * + * @param ip the ip used + * @param hwid the hardware id used + */ + public void use(@NonNull String ip, @NonNull String hwid) throws APIException { + if (!ips.contains(ip) && ips.size() >= ipLimit) { // IP limit has been exceeded + throw new LicenseIpLimitExceededException(); + } + if (!hwids.contains(hwid) && hwids.size() >= hwidLimit) { // HWID limit has been exceeded + throw new LicenseHwidLimitExceededException(); + } + // The license was used + uses++; // Increment uses + ips.add(ip); // Add the used IP + hwids.add(hwid); // Add the used HWID + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/license/repository/LicenseRepository.java b/src/main/java/me/braydon/license/repository/LicenseRepository.java new file mode 100644 index 0000000..40890fc --- /dev/null +++ b/src/main/java/me/braydon/license/repository/LicenseRepository.java @@ -0,0 +1,29 @@ +package me.braydon.license.repository; + +import lombok.NonNull; +import me.braydon.license.model.License; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * The repository for {@link License}'s. + * + * @author Braydon + */ +@Repository +public interface LicenseRepository extends MongoRepository { + /** + * Get the license that has the given + * key and is for the given product. + * + * @param key the key to get + * @param product the product the key is for + * @return the optional license + * @see License for license + */ + @Query("{ key: ?0, product: ?1 }") + Optional getLicense(@NonNull String key, @NonNull String product); +} diff --git a/src/main/java/me/braydon/license/service/LicenseService.java b/src/main/java/me/braydon/license/service/LicenseService.java new file mode 100644 index 0000000..2a41067 --- /dev/null +++ b/src/main/java/me/braydon/license/service/LicenseService.java @@ -0,0 +1,102 @@ +package me.braydon.license.service; + +import jakarta.annotation.PostConstruct; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import me.braydon.license.common.RandomUtils; +import me.braydon.license.exception.APIException; +import me.braydon.license.exception.LicenseNotFoundException; +import me.braydon.license.model.License; +import me.braydon.license.repository.LicenseRepository; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.HashSet; +import java.util.Optional; + +/** + * The service for managing {@link License}'s. + * + * @author Braydon + */ +@Service +@Slf4j +public final class LicenseService { + /** + * The {@link LicenseRepository} to use. + */ + @NonNull private final LicenseRepository repository; + + /** + * The salt to use for hashing license keys. + */ + @Value("${key-salt}") + @NonNull private String keySalt; + + @Autowired + public LicenseService(@NonNull LicenseRepository repository) { + this.repository = repository; + } + + @PostConstruct + public void onInitialize() { + String key = RandomUtils.generateLicenseKey(); + log.info(create(key, + "CloudSpigot", + "Testing " + Math.random(), Integer.MAX_VALUE, Integer.MAX_VALUE).toString()); + System.out.println("key = " + key); + } + + /** + * Create a new license key. + * + * @param key the key of the license + * @param product the product the license is for + * @param description the optional description of the license + * @param ipLimit the IP limit of the license + * @param hwidLimit the HWID limit of the license + * @return the created license + * @see License for license + */ + public License create(@NonNull String key, @NonNull String product, + String description, int ipLimit, int hwidLimit) { + // Create the new license + License license = new License(); + license.setKey(BCrypt.hashpw(key, keySalt)); // Hash the key + license.setProduct(product); // Use the given product + license.setDescription(description); // Use the given description, if any + license.setIps(new HashSet<>()); + license.setHwids(new HashSet<>()); + license.setIpLimit(ipLimit); // Use the given IP limit + license.setHwidLimit(hwidLimit); // Use the given HWID limit + license.setCreated(new Date()); + repository.insert(license); // Insert the newly created license + return license; + } + + /** + * Check the given license. + * + * @param key the key to check + * @param product the product of the license + * @param ip the ip using the license + * @param hwid the hwid using the license + * @throws APIException if there was an error checking the license + * @see License for license + */ + public void check(@NonNull String key, @NonNull String product, + @NonNull String ip, @NonNull String hwid) throws APIException { + Optional optionalLicense = repository.getLicense(BCrypt.hashpw(key, keySalt), product); // Get the license + if (optionalLicense.isEmpty()) { // License key not found + log.error("License key {} for product {} not found", key, product); // Log the error + throw new LicenseNotFoundException(); + } + License license = optionalLicense.get(); // The license found + license.use(ip, hwid); // Use the license + repository.save(license); // Save the used license + log.info("License key {} for product {} was used by {} ({})", key, product, ip, hwid); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9a2912c --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +# Server Configuration +server: + address: 0.0.0.0 + port: 7500 + +# The salt to use when hashing license keys. +# This salt should be changed from the default. +key-salt: "$2a$10$/nQyzQDMkCf97ZlJLLWa3O" + +# Log Configuration +logging: + file: + path: "./logs" + +# Spring Configuration +spring: + data: + # MongoDB - This is used to store persistent data + mongodb: + uri: "mongodb://licenseServer:p4$$w0rd@127.0.0.1:27017/licenseServer?authSource=admin" + auto-index-creation: true # Automatically create collection indexes + + # Ignore + application: + name: "License Server" + banner: + location: "classpath:banner.txt" \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..9536b41 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,8 @@ + _ _ ___ + | | (_)__ ___ _ _ ___ ___ / __| ___ _ ___ _____ _ _ + | |__| / _/ -_) ' \(_-