package io.github.lucaseasedup.logit.account; import io.github.lucaseasedup.logit.LogItCoreObject; import io.github.lucaseasedup.logit.common.ReportedException; import io.github.lucaseasedup.logit.security.model.HashingModel; import io.github.lucaseasedup.logit.security.model.HashingModelDecoder; import io.github.lucaseasedup.logit.storage.StorageEntry; import io.github.lucaseasedup.logit.util.IniUtils; import io.github.lucaseasedup.logit.util.Validators; import it.sauronsoftware.base64.Base64; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.UUID; import java.util.logging.Level; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; /** * Represents a single account in an {@code AccountManager}. * * <p> Every {@code Account} has its own {@code StorageEntry} instance * underlain so that it could be saved to a {@code Storage} as well as * selected and reconstructed using {@link AccountManager#selectAccount}. * * <p>Default values for entry keys:<br><br> * * <table> * <tr><td><b>Key</b></td><td><b>Default value</b></td></tr> * <tr><td>username</td><td><i>n/a; required</i></td></tr> * <tr><td>uuid</td><td>{@code ""}</td></tr> * <tr><td>salt</td><td>{@code ""}</td></tr> * <tr><td>password</td><td>{@code ""}</td></tr> * <tr><td>hashing_algorithm</td><td>{@code ""}</td></tr> * <tr><td>ip</td><td>{@code ""}</td></tr> * <tr><td>login_session</td><td>{@code ""}</td></tr> * <tr><td>email</td><td>{@code ""}</td></tr> * <tr><td>last_active_date</td><td>{@code "-1"}</td></tr> * <tr><td>reg_date</td><td>{@code "-1"}</td></tr> * <tr><td>is_locked</td><td>{@code "0"}</td></tr> * <tr><td>login_history</td><td>{@code ""}</td></tr> * <tr><td>display_name</td><td>{@code ""}</td></tr> * <tr><td>persistence</td><td>{@code ""}</td></tr> * </table> */ public final class Account extends LogItCoreObject { /** * Creates a new {@code Account} object, with all entry keys filled with * their defaults. * * <p> A new {@code StorageEntry} instance will be created for * this account to hold its data in a storage-oriented manner. * * <p> After you finish filling this {@code Account} with data, * use {@code AccountManager#insertAccount(Account)} or * {@code AccountManager#insertAccounts(Account...)} to insert the new * account to the storage. * * @param username * A username for this account, which will be saved to the underlying * storage entry as soon as it is created. * * @throws IllegalArgumentException * If {@code username} is {@code null} or blank. */ public Account(String username) { if (StringUtils.isBlank(username)) throw new IllegalArgumentException("Null or blank username"); this.entry = new StorageEntry(); this.entry.put(keys().username(), username.toLowerCase()); fillWithDefaults(); } /** * Creates a new {@code Account} object based on a {@code StorageEntry}. * * @param entry * A storage entry to hold data for this account. * * @param fillWithDefaults * If {@code true}, the missing entry keys will be filled with their * defaults. * * @throws IllegalArgumentException * If {@code entry} is {@code null}, does not contain * the username key or the username in this entry is {@code null} * or blank. */ /* package */ Account(StorageEntry entry, boolean fillWithDefaults) { if (entry == null) throw new IllegalArgumentException("Null storage entry"); if (!entry.containsKey(keys().username())) throw new IllegalArgumentException("Missing entry key: username"); if (StringUtils.isBlank(entry.get(keys().username()))) throw new IllegalArgumentException("Null or blank username"); this.entry = entry; if (fillWithDefaults) { fillWithDefaults(); } } /** * Creates a new {@code Account} object based on a {@code StorageEntry}, * filling all the missing keys with their defaults. * * @param entry * A storage entry to hold data for this account. * * @throws IllegalArgumentException * If {@code entry} is {@code null}, does not contain * the username key or the username in this entry is {@code null} * or blank. * * @see #Account(String) */ public Account(StorageEntry entry) { this(entry, true); } /** * Returns the username. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>username</i>. * * @return The username. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getUsername() { if (!entry.containsKey(keys().username())) throw new IllegalArgumentException("Missing entry key: username"); return entry.get(keys().username()).toLowerCase(); } /** * Returns the UUID. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>uuid</i>. * * @return The UUID. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getUuid() { if (!entry.containsKey(keys().uuid())) throw new IllegalArgumentException("Missing entry key: uuid"); return entry.get(keys().uuid()); } /** * Changes the UUID. * * @param uuid * The new UUID. * * @throws IllegalArgumentException * If {@code uuid} is {@code null}. */ public void setUuid(UUID uuid) { if (uuid == null) throw new IllegalArgumentException("Null uuid"); entry.put(keys().uuid(), uuid.toString()); } /** * Removes the UUID. */ public void removeUuid() { entry.put(keys().uuid(), ""); } /** * Checks whether passwords match. * * <p> The given password will be hashed using the algorithm specified * in the hashing_algorithm key. If this key does not represent a valid * hashing algorithm, the default hashing algorithm (stored in the config * file) will be used instead. * * <p> If passwords have been disabled as of the config file, * this method will always return {@code true}. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>salt</i>, <i>password</i>, <i>hashing_algorithm</i>. * * @param password * The password to be checked. * * @return {@code true} if the password is correct; {@code false} otherwise. * * @throws IllegalArgumentException * If {@code password} is {@code null}, or if the underlying entry * does not contain the required keys. */ public boolean checkPassword(String password) { if (password == null) throw new IllegalArgumentException("Null password"); if (getConfig("secret.yml").getBoolean("passwords.disable")) return true; if (!entry.containsKey(keys().salt())) throw new IllegalArgumentException("Missing entry key: salt"); if (!entry.containsKey(keys().password())) throw new IllegalArgumentException("Missing entry key: password"); if (!entry.containsKey(keys().hashing_algorithm())) throw new IllegalArgumentException("Missing entry key: hashing_algorithm"); String hash = entry.get(keys().password()); HashingModel hashingModel = getSecurityHelper().getDefaultHashingModel(); if (!getConfig("secret.yml").getBoolean("debug.forceHashingAlgorithm")) { String userHashingAlgorithm = entry.get(keys().hashing_algorithm()); if (!StringUtils.isBlank(userHashingAlgorithm)) { hashingModel = HashingModelDecoder.decode(userHashingAlgorithm); } } if (getConfig("secret.yml").getBoolean("passwords.useSalt")) { String salt = entry.get(keys().salt()); return hashingModel.verify(password, salt, hash); } else { return hashingModel.verify(password, hash); } } /** * Changes the password. * * <p> The password will be hashed * using the default algorithm specified in the config file. * * <p> If passwords have been disabled as of the config file, * no action will be taken. * * @param newPassword * The new password. * * @throws IllegalArgumentException * If {@code newPassword} is {@code null}. */ public void changePassword(String newPassword) { if (newPassword == null) throw new IllegalArgumentException("Null newPassword"); if (getConfig("secret.yml").getBoolean("passwords.disable")) return; HashingModel hashingModel = getSecurityHelper().getDefaultHashingModel(); String newHash; if (getConfig("secret.yml").getBoolean("passwords.useSalt")) { String newSalt = hashingModel.generateSalt(); newHash = hashingModel.getHash(newPassword, newSalt); entry.put(keys().salt(), newSalt); } else { newHash = hashingModel.getHash(newPassword); } entry.put(keys().password(), newHash); entry.put(keys().hashing_algorithm(), hashingModel.encode()); } /** * Returns the IP address. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>ip</i>. * * @return The IP address. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getIp() { if (!entry.containsKey(keys().ip())) throw new IllegalArgumentException("Missing entry key: ip"); return entry.get(keys().ip()); } /** * Changes the IP address. * * @param ip * The new IP address. * * @throws IllegalArgumentException * If {@code ip} is {@code null} or is not a valid IPv4/6 address. */ public void setIp(String ip) { if (ip == null) throw new IllegalArgumentException("Null ip"); if (!Validators.validateIp(ip)) throw new IllegalArgumentException("ip is not a valid IPv4/6 address"); entry.put(keys().ip(), ip); } /** * Removes the IP address. */ public void removeIp() { entry.put(keys().ip(), ""); } /** * Returns the login-session string. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>login_session</i>. * * @return The login-session string. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getLoginSession() { if (!entry.containsKey(keys().login_session())) throw new IllegalArgumentException("Missing entry key: login_session"); return entry.get(keys().login_session()); } /** * Saves login session. * * @param ip * The player IP address. * @param time * The UNIX time of when the login session was saved. * * @throws IllegalArgumentException * If {@code ip} is {@code null} or is not a valid IPv4/6 address, * or if {@code time} is negative. */ public void saveLoginSession(String ip, long time) { if (ip == null) throw new IllegalArgumentException("Null ip"); if (!Validators.validateIp(ip)) throw new IllegalArgumentException("ip is not a valid IPv4/6 address"); if (time < 0) throw new IllegalArgumentException("Negative time"); entry.put(keys().login_session(), ip + ";" + time); } /** * Erases the login session. */ public void eraseLoginSession() { entry.put(keys().login_session(), ""); } /** * Returns the e-mail address. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>email</i>. * * @return The e-mail address. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getEmail() { if (!entry.containsKey(keys().email())) throw new IllegalArgumentException("Missing entry key: email"); return entry.get(keys().email()).toLowerCase(); } /** * Changes the e-mail address. * * @param email * The new e-mail address. * * @throws IllegalArgumentException * If {@code email} is {@code null} or is not a valid e-mail address. */ public void setEmail(String email) { if (email == null) throw new IllegalArgumentException("Null email"); if (!Validators.validateEmail(email)) throw new IllegalArgumentException("email is not a valid e-mail address"); entry.put(keys().email(), email.toLowerCase()); } public void removeEmail() { entry.put(keys().email(), ""); } /** * Returns the last-active date. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>last_active_date</i>. * * @return The last-active date in UNIX time. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public long getLastActiveDate() { if (!entry.containsKey(keys().last_active_date())) throw new IllegalArgumentException("Missing entry key: last_active_date"); return Long.parseLong(entry.get(keys().last_active_date())); } /** * Changes the last-active date. * * @param unixTime * The new last-active date in UNIX time. */ public void setLastActiveDate(long unixTime) { entry.put(keys().last_active_date(), String.valueOf(unixTime)); } /** * Returns the registration date. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>reg_date</i>. * * @return The registration date in UNIX time. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public long getRegistrationDate() { if (!entry.containsKey(keys().reg_date())) throw new IllegalArgumentException("Missing entry key: reg_date"); return Long.parseLong(entry.get(keys().reg_date())); } /** * Changes the registration date. * * @param unixTime * The new registration date in UNIX time. */ public void setRegistrationDate(long unixTime) { entry.put(keys().reg_date(), String.valueOf(unixTime)); } /** * Checks whether this account has been locked. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>is_locked</i>. * * @return {@code true} if this account is locked; {@code false} otherwise. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public boolean isLocked() { if (!entry.containsKey(keys().is_locked())) throw new IllegalArgumentException("Missing entry key: is_locked"); return entry.get(keys().is_locked()).equals("1"); } /** * Locks or unlocks this account. * * <p> Locked accounts disallow their owners to join the game. * * @param locked * Whether this account should be locked or unlocked. */ public void setLocked(boolean locked) { entry.put(keys().is_locked(), locked ? "1" : "0"); } /** * Returns the login history. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>login_history</i>. * * @return The login history. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public List<String> getLoginHistory() { if (!entry.containsKey(keys().login_history())) throw new IllegalArgumentException("Missing entry key: login_history"); return new ArrayList<>(Arrays.asList( LOGIN_HISTORY_SEPARATOR_PATTERN.split( entry.get(keys().login_history()) ) )); } /** * Records a player login. * * @param unixTime * The UNIX time of the recorded login. * * @param ip * An IP address of the player who tried to log in. * * @param succeeded * Whether the login succeeded or failed. By <i>succeeded</i> I mean * that the entered password was correct. * * @throws IllegalArgumentException * If {@code unixTime} is negative, or if {@code ip} is not null * but is not a valid IPv4/6 address. */ public void recordLogin(long unixTime, String ip, boolean succeeded) { if (unixTime < 0) throw new IllegalArgumentException("Negative unixTime"); if (ip != null && !Validators.validateIp(ip)) throw new IllegalArgumentException("ip is not a valid IPv4/6 address"); if (!entry.containsKey(keys().login_history())) throw new IllegalArgumentException("Missing entry key: login_history"); List<String> records = getLoginHistory(); int recordsToKeep = getConfig("config.yml") .getInt("loginHistory.recordsToKeep"); for (int i = 0, n = records.size() - recordsToKeep + 1; i < n; i++) { records.remove(0); } if (ip == null) { records.add(unixTime + ";?.?.?.?;" + succeeded); } else { records.add(unixTime + ";" + ip + ";" + succeeded); } StringBuilder historyBuilder = new StringBuilder(); for (String record : records) { if (!record.isEmpty()) { historyBuilder.append(record); historyBuilder.append(LOGIN_HISTORY_SEPARATOR); } } entry.put(keys().login_history(), historyBuilder.toString()); } /** * Returns the display name. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>display_name</i>. * * @return The display name. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. */ public String getDisplayName() { if (!entry.containsKey(keys().display_name())) throw new IllegalArgumentException("Missing entry key: display_name"); return entry.get(keys().display_name()); } /** * Changes the display name. * * @param displayName * The new display name. * * @throws IllegalArgumentException * If {@code displayName} is {@code null}. */ public void setDisplayName(String displayName) { if (displayName == null) throw new IllegalArgumentException("Null displayName"); entry.put(keys().display_name(), displayName); } /** * Returns the persistence data as a {@code Map<String, String>}. * * <p> This method requires the following keys to exist in the underlying * storage entry: <i>persistence</i>. * * @return The persistence data, or {@code null} if an I/O error occurred * whilst the deserialization process. * * @throws IllegalArgumentException * If the underlying entry does not contain the required keys. * * @throws ReportedException * If an I/O error occurred while deserializing the persistence, * and the error was reported to the logger. */ public Map<String, String> getPersistence() { if (!entry.containsKey(keys().persistence())) throw new IllegalArgumentException("Missing entry key: persistence"); String persistenceString = entry.get(keys().persistence()); Map<String, String> persistence = new LinkedHashMap<>(); if (persistenceString != null) { if (getConfig("secret.yml").getBoolean("debug.encodePersistence")) { persistenceString = Base64.decode(persistenceString); } try { persistence = IniUtils.unserialize( persistenceString ).get("persistence"); } catch (IOException ex) { log(Level.WARNING, "Could not unserialize persistence" + " {username: " + getUsername() + "}", ex); ReportedException.throwNew(ex); return null; } if (persistence == null) { return new LinkedHashMap<>(); } } return persistence; } /** * Saves persistence data. * * @param persistence * The new persistence data. * * @throws IllegalArgumentException * If {@code persistence} is {@code null}. * * @throws ReportedException * If an I/O error occurred while serializing the persistence, * and the error was reported to the logger. */ public void savePersistence(Map<String, String> persistence) { if (persistence == null) throw new IllegalArgumentException("Null persistence"); if (!getConfig("secret.yml").getBoolean("debug.writePersistence")) return; Map<String, Map<String, String>> persistenceIni = new HashMap<>(1); persistenceIni.put("persistence", persistence); try { String persistenceString = IniUtils.serialize(persistenceIni); if (getConfig("secret.yml").getBoolean("debug.encodePersistence")) { persistenceString = Base64.encode(persistenceString); } entry.put(keys().persistence(), persistenceString); } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(); } } /** * Clones this {@code Account}. * * <p> A new {@code StorageEntry} is created as a copy of the * original entry. * * @param username * A username for the cloned account. * * @return The cloned {@code Account} object. */ public Account clone(String username) { if (StringUtils.isBlank(username)) throw new IllegalArgumentException("Null or blank username"); StorageEntry entryClone = entry.copy(); entryClone.put(keys().username(), username.toLowerCase()); entryClone.clearKeyDirty(keys().username()); Account accountClone = new Account(entryClone, false); return accountClone; } /** * Enqueues a new save-callback to be called when this account * gets updated in a {@code Storage}. * * <p> Once the callback gets called, it is removed from the queue. * * @param callback * The save-callback to be enqueued. */ public void enqueueSaveCallback(SaveCallback callback) { if (callback == null) throw new IllegalArgumentException("Null callback"); saveCallbacks.add(callback); } /* package */ void runSaveCallbacks(boolean success) { while (!saveCallbacks.isEmpty()) { saveCallbacks.remove().onSave(success); } } /** * Fills with defaults keys that are missing in this account. */ private void fillWithDefaults() { if (!entry.containsKey(keys().uuid())) { entry.put(keys().uuid(), ""); } if (!entry.containsKey(keys().salt())) { entry.put(keys().salt(), ""); } if (!entry.containsKey(keys().password())) { entry.put(keys().password(), ""); } if (!entry.containsKey(keys().hashing_algorithm())) { entry.put(keys().hashing_algorithm(), ""); } if (!entry.containsKey(keys().ip())) { entry.put(keys().ip(), ""); } if (!entry.containsKey(keys().login_session())) { entry.put(keys().login_session(), ""); } if (!entry.containsKey(keys().email())) { entry.put(keys().email(), ""); } if (!entry.containsKey(keys().last_active_date())) { entry.put(keys().last_active_date(), "-1"); } if (!entry.containsKey(keys().reg_date())) { entry.put(keys().reg_date(), "-1"); } if (!entry.containsKey(keys().is_locked())) { entry.put(keys().is_locked(), "0"); } if (!entry.containsKey(keys().login_history())) { entry.put(keys().login_history(), ""); } if (!entry.containsKey(keys().display_name())) { entry.put(keys().display_name(), ""); } if (!entry.containsKey(keys().persistence())) { entry.put(keys().persistence(), ""); } } /** * Returns the underlying storage entry. * * <p> <b>Do not use unless you know what you're doing!</b> * * @return The account entry. */ public StorageEntry getEntry() { return entry; } /* package */ void setEntry(StorageEntry entry) { if (entry == null) throw new IllegalArgumentException(); this.entry = entry; } public void bufferLock() { if (bufferLocked) throw new IllegalStateException("Account already buffer-locked"); bufferLocked = true; } public void bufferUnlock() { bufferLocked = false; } public boolean isBufferLocked() { return bufferLocked; } /** * @see Account#enqueueSaveCallback(SaveCallback) */ public static interface SaveCallback { /** * Called after a save process. * * @param success * Whether the account was successfully saved in a * {@code Storage}. */ public void onSave(boolean success); } /** * Used for {@link #recordLogin(long, String, boolean)}. */ public static final boolean LOGIN_SUCCESS = true; /** * Used for {@link #recordLogin(long, String, boolean)}. */ public static final boolean LOGIN_FAIL = false; public static final String LOGIN_HISTORY_SEPARATOR = "|"; private static final Pattern LOGIN_HISTORY_SEPARATOR_PATTERN = Pattern.compile(Pattern.quote(LOGIN_HISTORY_SEPARATOR)); private StorageEntry entry; private final Queue<SaveCallback> saveCallbacks = new LinkedList<>(); private boolean bufferLocked = false; }