diff --git a/src/main/java/dev/coph/flightscore/backend/Backend.java b/src/main/java/dev/coph/flightscore/backend/Backend.java index 1d2ef08..550e0cf 100644 --- a/src/main/java/dev/coph/flightscore/backend/Backend.java +++ b/src/main/java/dev/coph/flightscore/backend/Backend.java @@ -83,6 +83,9 @@ public class Backend { providerManager.createAllDatabaseTables(); logger.success("Database tables created!"); + logger.info("Enabling providers..."); + providerManager.enableAllProviders(); + logger.success("Providers enabled!"); logger.info("Starting web server..."); webServer.start(); diff --git a/src/main/java/dev/coph/flightscore/backend/action/result/ActionResult.java b/src/main/java/dev/coph/flightscore/backend/action/result/ActionResult.java new file mode 100644 index 0000000..acd5ee7 --- /dev/null +++ b/src/main/java/dev/coph/flightscore/backend/action/result/ActionResult.java @@ -0,0 +1,16 @@ +package dev.coph.flightscore.backend.action.result; + +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +public class ActionResult { + private final boolean success; + private final String message; + + public ActionResult(boolean success, String message) { + this.success = success; + this.message = message; + } +} diff --git a/src/main/java/dev/coph/flightscore/backend/action/result/LoginActionResult.java b/src/main/java/dev/coph/flightscore/backend/action/result/LoginActionResult.java index bea21ed..ab9527d 100644 --- a/src/main/java/dev/coph/flightscore/backend/action/result/LoginActionResult.java +++ b/src/main/java/dev/coph/flightscore/backend/action/result/LoginActionResult.java @@ -1,11 +1,28 @@ package dev.coph.flightscore.backend.action.result; import dev.coph.flightscore.backend.user.User; +import lombok.Getter; +import lombok.experimental.Accessors; -public class LoginActionResult { +@Getter +@Accessors(fluent = true) +public class LoginActionResult extends ActionResult{ - private User user; - private String accessToken; - private String refreshToken; + private final User user; + private final String accessToken; + private final String refreshToken; + + public LoginActionResult(boolean success, String message) { + super(success, message); + this.user = null; + this.accessToken = null; + this.refreshToken = null; + } + public LoginActionResult(User user, String accessToken, String refreshToken) { + super(true, "Login successful"); + this.user = user; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } } diff --git a/src/main/java/dev/coph/flightscore/backend/config/Config.java b/src/main/java/dev/coph/flightscore/backend/config/Config.java index f7b22a0..e36a04e 100644 --- a/src/main/java/dev/coph/flightscore/backend/config/Config.java +++ b/src/main/java/dev/coph/flightscore/backend/config/Config.java @@ -33,4 +33,7 @@ public class Config extends AbstractConfiguration { @DefaultValue("7a0c878e20c039349ef3fc5e0bebcedeb1441c123c118b243ee196dc6246cfd7") private String jwt_secret; + @DefaultValue("15") + private Long jwt_expirationInMinutes; + } diff --git a/src/main/java/dev/coph/flightscore/backend/provider/Provider.java b/src/main/java/dev/coph/flightscore/backend/provider/Provider.java index be5261f..502158a 100644 --- a/src/main/java/dev/coph/flightscore/backend/provider/Provider.java +++ b/src/main/java/dev/coph/flightscore/backend/provider/Provider.java @@ -4,7 +4,11 @@ import dev.coph.simplesql.query.Query; public interface Provider { int priority(); + String key(); - public void createDatabaseTables(Query query); + + void createDatabaseTables(Query query); + + void onEnable(Query query); } diff --git a/src/main/java/dev/coph/flightscore/backend/provider/ProviderManager.java b/src/main/java/dev/coph/flightscore/backend/provider/ProviderManager.java index f28cd4e..92c45c8 100644 --- a/src/main/java/dev/coph/flightscore/backend/provider/ProviderManager.java +++ b/src/main/java/dev/coph/flightscore/backend/provider/ProviderManager.java @@ -13,17 +13,22 @@ public class ProviderManager { this.backend = backend; } - public void createAllDatabaseTables(){ + public void createAllDatabaseTables() { Query query = new Query(backend.databaseAdapter()); providers.values().stream().sorted((a, b) -> Integer.compare(b.priority(), a.priority())).forEach(provider -> provider.createDatabaseTables(query)); + } + + public void enableAllProviders() { + Query query = new Query(backend.databaseAdapter()); + providers.values().stream().sorted((a, b) -> Integer.compare(b.priority(), a.priority())).forEach(provider -> provider.onEnable(query)); query.execute(); } - public void registerProvider(Provider provider){ + public void registerProvider(Provider provider) { providers.put(provider.key(), provider); } - public T provider(String key){ + public T provider(String key) { return (T) providers.get(key); } diff --git a/src/main/java/dev/coph/flightscore/backend/user/User.java b/src/main/java/dev/coph/flightscore/backend/user/User.java index e2de283..95e7729 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/User.java +++ b/src/main/java/dev/coph/flightscore/backend/user/User.java @@ -16,4 +16,20 @@ public class User { private String phoneNumber; private Role role; + public User(ULID id, String firstname, String lastname, String email, String phoneNumber) { + this.id = id; + this.firstName = firstname; + this.lastName = lastname; + this.email = email; + this.phoneNumber = phoneNumber; + } + + public User(ULID id, String firstname, String lastname, String email, String phoneNumber, Role role) { + this.id = id; + this.firstName = firstname; + this.lastName = lastname; + this.email = email; + this.phoneNumber = phoneNumber; + this.role = role; + } } diff --git a/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java b/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java index f501e66..fcd451c 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java +++ b/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java @@ -2,21 +2,36 @@ package dev.coph.flightscore.backend.user; import dev.coph.flightscore.backend.Backend; import dev.coph.flightscore.backend.action.result.LoginActionResult; +import dev.coph.flightscore.backend.config.Config; import dev.coph.flightscore.backend.provider.Provider; +import dev.coph.flightscore.backend.user.role.Role; +import dev.coph.flightscore.backend.utils.TokenGenerator; +import dev.coph.simpleauthentication.cryptography.CCrypt; +import dev.coph.simpleauthentication.jwt.JWT; +import dev.coph.simpleauthentication.jwt.JwtException; import dev.coph.simplelogger.Logger; import dev.coph.simplesql.database.Column; import dev.coph.simplesql.database.attributes.ColumnType; import dev.coph.simplesql.database.attributes.CreateMethod; import dev.coph.simplesql.database.attributes.DataType; import dev.coph.simplesql.query.Query; -import org.checkerframework.checker.units.qual.C; +import dev.coph.simpleutilities.ulid.ULID; +import lombok.extern.slf4j.Slf4j; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +@Slf4j public class UserProvider implements Provider { private Logger logger = Logger.of("UserProvider"); private final Backend backend; + private byte[] JWT_SECRET; + private long JWT_EXPIRATION_TIME; + public UserProvider(Backend backend) { this.backend = backend; } @@ -39,14 +54,15 @@ public class UserProvider implements Provider { .column("id", DataType.BINARY, 26, true) .column("fistname", DataType.VARCHAR, 255) .column("lastname", DataType.VARCHAR, 255) - .column("email", DataType.VARCHAR, 255, true) + .column("email", DataType.VARCHAR, 255, ColumnType.UNIQUE, true) .column("phoneNumber", DataType.VARCHAR, 255) .column("password", DataType.VARCHAR, 255, true) .column("role", DataType.BINARY, 26) .primaryKey(List.of("id")) .index(List.of("email")) .index(List.of("email", "password")) - .foreignKey(List.of("role"), "roles", List.of("id"));; + .foreignKey(List.of("role"), "roles", List.of("id")); + ; query.query(tableCreate); tableCreate = Query.tableCreate() .createMethod(CreateMethod.IF_NOT_EXISTS) @@ -57,12 +73,124 @@ public class UserProvider implements Provider { .column(new Column("revoked", DataType.BOOLEAN, true).defaultValue(false)) .primaryKey(List.of("token_hash")) .index(List.of("user_id")) - .foreignKey(List.of("user_id"), "users", List.of("id"));; + .foreignKey(List.of("user_id"), "users", List.of("id")); + ; query.query(tableCreate); } - public LoginActionResult login(String email, String password) { + @Override + public void onEnable(Query query) { + String jwtSecretString = backend.configurationManager().configuration(Config.class).jwt_secret(); + if (jwtSecretString == null) { + throw new RuntimeException("JWT_SECRET is not set in config.yml"); + } + JWT_SECRET = jwtSecretString.getBytes(); + Long jwtExpirationMinutes = backend.configurationManager().configuration(Config.class).jwt_expirationInMinutes(); + if (jwtExpirationMinutes == null) { + throw new RuntimeException("JWT_EXPIRATION_MINUTES is not set in config.yml"); + } + JWT_EXPIRATION_TIME = jwtExpirationMinutes * 60 * 1000; + } + + public LoginActionResult login(String email, String password) { + Query query = new Query(backend.databaseAdapter()); + + logger.debug("Logging in user with email: " + email); + AtomicBoolean success = new AtomicBoolean(false); + var select = Query.select() + .table("users") + .condition("email", email) + .columnKey("password") + .resultActionAfterQuery(srs -> { + srs.next(resultSet -> { + var hashedPassword = resultSet.getString("password"); + if (CCrypt.checkPassword(password, hashedPassword)) { + success.set(true); + } + }, () -> logger.warn("User not found with email: " + email), e -> logger.error("Error logging in user", e)); + }); + query.executeQuery(select); + if (!success.get()) { + logger.warn("Invalid login credentials for user with email: " + email); + return new LoginActionResult(false, "Invalid login credentials"); + } + + AtomicReference userReference = new AtomicReference<>(); + select = Query.select() + .table("users") + .condition("email", email) + .resultActionAfterQuery(srs -> { + srs.next(resultSet -> { + byte[] roleBytes = resultSet.getBytes("role"); + if (roleBytes != null) { + ULID roleId = new ULID(roleBytes); + Role role = backend.roleProvider().role(roleId); + if (role == null) { + logger.warn("User '%s' has role but role not found.".formatted(email)); + return; + } + User user = new User( + new ULID(resultSet.getBytes("id")), + resultSet.getString("firstname"), + resultSet.getString("lastname"), + resultSet.getString("email"), + resultSet.getString("phoneNumber"), + role + ); + userReference.set(user); + } else { + User user = new User( + new ULID(resultSet.getBytes("id")), + resultSet.getString("firstname"), + resultSet.getString("lastname"), + resultSet.getString("email"), + resultSet.getString("phoneNumber") + ); + userReference.set(user); + } + }, () -> logger.warn("User not found with email: " + email), e -> logger.error("Error logging in user", e)); + }); + query.executeQuery(select); + + User user = userReference.get(); + if (user == null) { + logger.error("User-Details not found with email: " + email); + return new LoginActionResult(false, "Error logging in user"); + } + + long expiresAt = System.currentTimeMillis() + JWT_EXPIRATION_TIME; + String accessToken = createAccessToken(user, expiresAt); + if (accessToken == null) { + return new LoginActionResult(false, "Error creating JWT"); + } + String refreshToken = TokenGenerator.generateRefreshToken(); + + var insert = Query.insert() + .table("refresh_tokens") + .entry("user_id", user.id().toString()) + .entry("token", CCrypt.hashPassword(refreshToken, CCrypt.gensalt())) + .entry("expires_at", Timestamp.from(Instant.ofEpochMilli(expiresAt))); + query.async(true).executeQuery(insert); + + return new LoginActionResult(user, accessToken, refreshToken); + } + + private String createAccessToken(User user, long expiresAt) { + try { + return new JWT.Builder() + .audience("flightscore-api") + .issuer("flightscore-api") + .subject(user.email()) + .claim("id", user.id().toString()) + .claim("role", user.role().id().toString()) + .expiresAt(expiresAt / 1000) + .issuedAt(System.currentTimeMillis() / 1000) + .sign(JWT_SECRET); + } catch (JwtException e) { + logger.error("Error creating JWT", e); + } return null; } + } diff --git a/src/main/java/dev/coph/flightscore/backend/user/permission/Permission.java b/src/main/java/dev/coph/flightscore/backend/user/permission/Permission.java index 09fab8c..2d6c6ee 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/permission/Permission.java +++ b/src/main/java/dev/coph/flightscore/backend/user/permission/Permission.java @@ -7,6 +7,10 @@ import lombok.experimental.Accessors; @Getter @Accessors(fluent = true) public class Permission { + public Permission(ULID id, String name) { + this.id = id; + this.name = name; + } private ULID id; private String name; diff --git a/src/main/java/dev/coph/flightscore/backend/user/permission/PermissionProvider.java b/src/main/java/dev/coph/flightscore/backend/user/permission/PermissionProvider.java index ada07a2..ab2389c 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/permission/PermissionProvider.java +++ b/src/main/java/dev/coph/flightscore/backend/user/permission/PermissionProvider.java @@ -2,14 +2,19 @@ package dev.coph.flightscore.backend.user.permission; import dev.coph.flightscore.backend.Backend; import dev.coph.flightscore.backend.provider.Provider; +import dev.coph.simplelogger.Logger; import dev.coph.simplesql.database.attributes.CreateMethod; import dev.coph.simplesql.database.attributes.DataType; import dev.coph.simplesql.query.Query; +import dev.coph.simpleutilities.ulid.ULID; +import java.util.HashMap; import java.util.List; public class PermissionProvider implements Provider { private final Backend backend; + private final Logger logger = Logger.of("PermissionProvider"); + private final HashMap permissions = new HashMap<>(); public PermissionProvider(Backend backend) { this.backend = backend; @@ -36,5 +41,32 @@ public class PermissionProvider implements Provider { ; query.query(tableCreate); } + + @Override + public void onEnable(Query query) { + var select = Query.select() + .table("permissions") + .resultActionAfterQuery(srs -> { + srs.forEach(resultSet -> { + var permissionIdBytes = resultSet.getBytes("id"); + var permissionName = resultSet.getString("name"); + var roleId = new ULID(permissionIdBytes); + permissions.put(roleId, new Permission(roleId, permissionName)); + }, () -> logger.warn("Error loading permissions: No permissions found"), e -> logger.error("Error loading permissions", e)); + logger.success("Loaded " + permissions.size() + " permissions!"); + }); + } + + public Permission permission(ULID id) { + return permissions.get(id); + } + + public HashMap permissions() { + return permissions; + } + + public Permission permission(String name) { + return permissions.values().stream().filter(permission -> permission.name().equalsIgnoreCase(name)).findFirst().orElse(null); + } } diff --git a/src/main/java/dev/coph/flightscore/backend/user/role/Role.java b/src/main/java/dev/coph/flightscore/backend/user/role/Role.java index 80cccdb..3c6ae24 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/role/Role.java +++ b/src/main/java/dev/coph/flightscore/backend/user/role/Role.java @@ -11,6 +11,11 @@ import java.util.HashSet; @Accessors(fluent = true) public class Role { + public Role(ULID id, String name) { + this.id = id; + this.name = name; + } + private ULID id; private String name; private HashSet permissions = new HashSet<>(); diff --git a/src/main/java/dev/coph/flightscore/backend/user/role/RoleProvider.java b/src/main/java/dev/coph/flightscore/backend/user/role/RoleProvider.java index 23ec12a..3c58f04 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/role/RoleProvider.java +++ b/src/main/java/dev/coph/flightscore/backend/user/role/RoleProvider.java @@ -2,13 +2,20 @@ package dev.coph.flightscore.backend.user.role; import dev.coph.flightscore.backend.Backend; import dev.coph.flightscore.backend.provider.Provider; +import dev.coph.flightscore.backend.user.permission.Permission; +import dev.coph.simplelogger.Logger; import dev.coph.simplesql.database.attributes.CreateMethod; import dev.coph.simplesql.database.attributes.DataType; import dev.coph.simplesql.query.Query; +import dev.coph.simpleutilities.ulid.ULID; +import java.util.HashMap; import java.util.List; public class RoleProvider implements Provider { + private final Logger logger = Logger.of("RoleProvider"); + private HashMap roles = new HashMap<>(); + private final Backend backend; public RoleProvider(Backend backend) { @@ -45,5 +52,53 @@ public class RoleProvider implements Provider { .foreignKey(List.of("permissionId"), "permissions", List.of("id")); query.query(tableCreate); } + + @Override + public void onEnable(Query query) { + var select = Query.select() + .table("roles") + .resultActionAfterQuery(srs -> { + srs.forEach(resultSet -> { + var roleIdBytes = resultSet.getBytes("id"); + var roleName = resultSet.getString("name"); + var roleId = new ULID(roleIdBytes); + roles.put(roleId, new Role(roleId, roleName)); + }, () -> logger.warn("Error loading roles: No roles found"), e -> logger.error("Error loading roles", e)); + logger.success("Loaded " + roles.size() + " roles!"); + }); + query.query(select); + select = Query.select() + .table("role_permissions") + .resultActionAfterQuery(srs -> { + srs.forEach(resultSet -> { + var roleIdBytes = resultSet.getBytes("roleId"); + var permissionIdBytes = resultSet.getBytes("permissionId"); + var roleId = new ULID(roleIdBytes); + var permissionId = new ULID(permissionIdBytes); + Role role = roles.get(roleId); + if (role == null) { + logger.warn("Role not found for permission: " + permissionId); + return; + } + Permission permission = backend.permissionProvider().permission(permissionId); + if (permission == null) { + logger.warn("Permission not found for role: " + roleId); + return; + } + role.permissions().add(permission); + }, () -> logger.warn("Error loading role permissions: No role permissions found"), e -> logger.error("Error loading role permissions", e)); + }); + query.query(select); + } + + public Role role(ULID id) { + return roles.get(id); + } + public HashMap roles() { + return roles; + } + public Role role(String name) { + return roles.values().stream().filter(role -> role.name().equalsIgnoreCase(name)).findFirst().orElse(null); + } } diff --git a/src/main/java/dev/coph/flightscore/backend/utils/TokenGenerator.java b/src/main/java/dev/coph/flightscore/backend/utils/TokenGenerator.java new file mode 100644 index 0000000..30b5964 --- /dev/null +++ b/src/main/java/dev/coph/flightscore/backend/utils/TokenGenerator.java @@ -0,0 +1,16 @@ +package dev.coph.flightscore.backend.utils; + +import java.security.SecureRandom; +import java.util.Base64; + +public class TokenGenerator { + + private static final SecureRandom secureRandom = new SecureRandom(); + private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding(); + + public static String generateRefreshToken() { + byte[] randomBytes = new byte[64]; + secureRandom.nextBytes(randomBytes); + return base64Encoder.encodeToString(randomBytes); + } +}