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 95e7729..1f8c99f 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/User.java +++ b/src/main/java/dev/coph/flightscore/backend/user/User.java @@ -15,21 +15,24 @@ public class User { private String email; private String phoneNumber; private Role role; + private boolean blocked; - public User(ULID id, String firstname, String lastname, String email, String phoneNumber) { + public User(ULID id, String firstname, String lastname, String email, String phoneNumber, boolean blocked) { this.id = id; this.firstName = firstname; this.lastName = lastname; this.email = email; this.phoneNumber = phoneNumber; + this.blocked = blocked; } - public User(ULID id, String firstname, String lastname, String email, String phoneNumber, Role role) { + public User(ULID id, String firstname, String lastname, String email, String phoneNumber, boolean blocked, Role role) { this.id = id; this.firstName = firstname; this.lastName = lastname; this.email = email; this.phoneNumber = phoneNumber; this.role = role; + this.blocked = blocked; } } 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 fcd451c..2c66f98 100644 --- a/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java +++ b/src/main/java/dev/coph/flightscore/backend/user/UserProvider.java @@ -5,10 +5,13 @@ 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.Hash; 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.simplecache.Cache; +import dev.coph.simplecache.CacheBuilder; import dev.coph.simplelogger.Logger; import dev.coph.simplesql.database.Column; import dev.coph.simplesql.database.attributes.ColumnType; @@ -19,6 +22,7 @@ import dev.coph.simpleutilities.ulid.ULID; import lombok.extern.slf4j.Slf4j; import java.sql.Timestamp; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -32,6 +36,8 @@ public class UserProvider implements Provider { private byte[] JWT_SECRET; private long JWT_EXPIRATION_TIME; + private Cache userCache; + public UserProvider(Backend backend) { this.backend = backend; } @@ -52,17 +58,18 @@ public class UserProvider implements Provider { .createMethod(CreateMethod.IF_NOT_EXISTS) .table("users") .column("id", DataType.BINARY, 26, true) - .column("fistname", DataType.VARCHAR, 255) + .column("firstname", DataType.VARCHAR, 255) .column("lastname", DataType.VARCHAR, 255) .column("email", DataType.VARCHAR, 255, ColumnType.UNIQUE, true) .column("phoneNumber", DataType.VARCHAR, 255) .column("password", DataType.VARCHAR, 255, true) + .column(new Column("blocked", DataType.BOOLEAN, true).defaultValue(false)) .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")); - ; + query.query(tableCreate); tableCreate = Query.tableCreate() .createMethod(CreateMethod.IF_NOT_EXISTS) @@ -74,12 +81,18 @@ public class UserProvider implements Provider { .primaryKey(List.of("token_hash")) .index(List.of("user_id")) .foreignKey(List.of("user_id"), "users", List.of("id")); - ; + query.query(tableCreate); } @Override public void onEnable(Query query) { + this.userCache = CacheBuilder.newBuilder() + .maximumSize(1000) + .expireAfterWrite(Duration.ofMinutes(10)) + .loader(this::loadUser) + .build(); + String jwtSecretString = backend.configurationManager().configuration(Config.class).jwt_secret(); if (jwtSecretString == null) { throw new RuntimeException("JWT_SECRET is not set in config.yml"); @@ -93,6 +106,49 @@ public class UserProvider implements Provider { JWT_EXPIRATION_TIME = jwtExpirationMinutes * 60 * 1000; } + private User loadUser(ULID userId) { + AtomicReference userReference = new AtomicReference<>(); + var select = Query.select() + .table("users") + .condition("id", userId.toString()) + .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(userId)); + return; + } + User user = new User( + new ULID(resultSet.getBytes("id")), + resultSet.getString("firstname"), + resultSet.getString("lastname"), + resultSet.getString("email"), + resultSet.getString("phoneNumber"), + resultSet.getBoolean("blocked"), + 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"), + resultSet.getBoolean("blocked") + ); + userReference.set(user); + } + }, () -> logger.warn("User not found with id: " + userId), e -> logger.error("Error logging in user", e)); + }); + Query query = new Query(backend.databaseAdapter()); + query.executeQuery(select); + return userReference.get(); + } + public LoginActionResult login(String email, String password) { Query query = new Query(backend.databaseAdapter()); @@ -102,8 +158,13 @@ public class UserProvider implements Provider { .table("users") .condition("email", email) .columnKey("password") + .columnKey("blocked") .resultActionAfterQuery(srs -> { srs.next(resultSet -> { + if (resultSet.getBoolean("blocked")) { + logger.warn("Blocked User with email: " + email + " tried to login."); + return; + } var hashedPassword = resultSet.getString("password"); if (CCrypt.checkPassword(password, hashedPassword)) { success.set(true); @@ -136,6 +197,7 @@ public class UserProvider implements Provider { resultSet.getString("lastname"), resultSet.getString("email"), resultSet.getString("phoneNumber"), + resultSet.getBoolean("blocked"), role ); userReference.set(user); @@ -145,7 +207,8 @@ public class UserProvider implements Provider { resultSet.getString("firstname"), resultSet.getString("lastname"), resultSet.getString("email"), - resultSet.getString("phoneNumber") + resultSet.getString("phoneNumber"), + resultSet.getBoolean("blocked") ); userReference.set(user); } @@ -158,6 +221,7 @@ public class UserProvider implements Provider { logger.error("User-Details not found with email: " + email); return new LoginActionResult(false, "Error logging in user"); } + userCache.put(user.id(), user); long expiresAt = System.currentTimeMillis() + JWT_EXPIRATION_TIME; String accessToken = createAccessToken(user, expiresAt); @@ -169,7 +233,103 @@ public class UserProvider implements Provider { var insert = Query.insert() .table("refresh_tokens") .entry("user_id", user.id().toString()) - .entry("token", CCrypt.hashPassword(refreshToken, CCrypt.gensalt())) + .entry("token", Hash.hashString(refreshToken)) + .entry("expires_at", Timestamp.from(Instant.ofEpochMilli(expiresAt))); + query.async(true).executeQuery(insert); + + return new LoginActionResult(user, accessToken, refreshToken); + } + + public LoginActionResult refreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isEmpty()) { + return new LoginActionResult(false, "Invalid refresh token"); + } + Query query = new Query(backend.databaseAdapter()); + AtomicReference userReference = new AtomicReference<>(); + var select = Query.select() + .table("refresh_tokens") + .condition("token_hash", Hash.hashString(refreshToken)) + .resultActionAfterQuery(srs -> { + srs.next(resultSet -> { + if (resultSet.getBoolean("revoked")) { + return; + } + + if (resultSet.getTimestamp("expires_at").before(Timestamp.from(Instant.now()))) { + return; + } + byte[] userIdBytes = resultSet.getBytes("user_id"); + if (userIdBytes == null) { + return; + } + + ULID userId = new ULID(userIdBytes); + userReference.set(userCache.get(userId)); + + }, () -> logger.debug("Refresh token not found"), e -> logger.error("Error refreshing token", e)); + }); + query.executeQuery(select); + + User user = userReference.get(); + if (user == null) { + return new LoginActionResult(false, "Invalid refresh token"); + } + + var update = Query.update() + .table("refresh_tokens") + .condition("token_hash", Hash.hashString(refreshToken)) + .entry("revoked", true); + query.query(update); + + long expiresAt = System.currentTimeMillis() + JWT_EXPIRATION_TIME; + String accessToken = createAccessToken(user, expiresAt); + if (accessToken == null) { + return new LoginActionResult(false, "Error creating JWT"); + } + refreshToken = TokenGenerator.generateRefreshToken(); + + var insert = Query.insert() + .table("refresh_tokens") + .entry("user_id", user.id().toString()) + .entry("token", Hash.hashString(refreshToken)) + .entry("expires_at", Timestamp.from(Instant.ofEpochMilli(expiresAt))); + query.async(true).executeQuery(insert); + + return new LoginActionResult(user, accessToken, refreshToken); + } + + public LoginActionResult register(String firstName, String lastName, String email, String phoneNumber, String password) { + Role role = backend.roleProvider().defaultRole(); + + var userId = ULID.randomUlid(); + var hashedPassword = CCrypt.hashPassword(password, CCrypt.gensalt()); + Query query = new Query(backend.databaseAdapter()); + + var insert = Query.insert() + .table("users") + .entry("id", userId.toString()) + .entry("firstname", firstName) + .entry("lastname", lastName) + .entry("email", email) + .entry("phoneNumber", phoneNumber) + .entry("password", hashedPassword) + .entry("role", role.id().toString()); + query.executeQuery(insert); + + User user = new User(userId, firstName, lastName, email, phoneNumber, false, role); + userCache.put(user.id(), 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(); + + insert = Query.insert() + .table("refresh_tokens") + .entry("user_id", user.id().toString()) + .entry("token", Hash.hashString(refreshToken)) .entry("expires_at", Timestamp.from(Instant.ofEpochMilli(expiresAt))); query.async(true).executeQuery(insert); 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 3c6ae24..28ff0a2 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,13 +11,15 @@ import java.util.HashSet; @Accessors(fluent = true) public class Role { - public Role(ULID id, String name) { + public Role(ULID id, String name, boolean defaultRole) { this.id = id; this.name = name; + this.defaultRole = defaultRole; } private ULID id; private String name; + private boolean defaultRole; 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 3c58f04..d988d7a 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 @@ -4,17 +4,24 @@ 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.Column; 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 lombok.Getter; +import lombok.experimental.Accessors; +import org.checkerframework.checker.units.qual.C; import java.util.HashMap; import java.util.List; +@Accessors(fluent = true) public class RoleProvider implements Provider { private final Logger logger = Logger.of("RoleProvider"); private HashMap roles = new HashMap<>(); + @Getter + private Role defaultRole; private final Backend backend; @@ -40,8 +47,10 @@ public class RoleProvider implements Provider { .table("roles") .column("id", DataType.BINARY, 26, true) .column("name", DataType.VARCHAR, 255) + .column(new Column("default", DataType.BOOLEAN, true).defaultValue(false)) .primaryKey(List.of("id")); query.query(tableCreate); + tableCreate = Query.tableCreate() .createMethod(CreateMethod.IF_NOT_EXISTS) .table("role_permissions") @@ -61,8 +70,13 @@ public class RoleProvider implements Provider { srs.forEach(resultSet -> { var roleIdBytes = resultSet.getBytes("id"); var roleName = resultSet.getString("name"); + var isDefault = resultSet.getBoolean("default"); var roleId = new ULID(roleIdBytes); - roles.put(roleId, new Role(roleId, roleName)); + Role role = new Role(roleId, roleName, isDefault); + roles.put(roleId, role); + if (isDefault) { + defaultRole = role; + } }, () -> logger.warn("Error loading roles: No roles found"), e -> logger.error("Error loading roles", e)); logger.success("Loaded " + roles.size() + " roles!"); }); @@ -94,11 +108,14 @@ public class RoleProvider implements Provider { 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/Hash.java b/src/main/java/dev/coph/flightscore/backend/utils/Hash.java new file mode 100644 index 0000000..e0885b0 --- /dev/null +++ b/src/main/java/dev/coph/flightscore/backend/utils/Hash.java @@ -0,0 +1,29 @@ +package dev.coph.flightscore.backend.utils; + +import dev.coph.simplelogger.GenericLogger; +import dev.coph.simplelogger.Logger; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Hash { + + public static String hashString(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedHash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + StringBuilder hexString = new StringBuilder(); + for (byte b : encodedHash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + GenericLogger.error("Error hashing string", e); + return null; + } + } +}