Table of Contents
Background
“Speed is exactly what you don’t want in a password hash function.”
- Thomas Ptacek
Until just recently, Mojang (or should I say Microsoft?), the development team behind Minecraft, did not provide any sort of multi-factor authentication for player accounts - including premium accounts. As a result, security breaches and credential leaks became common, leading to the creation of the well-known MCLeaks database. It remains puzzling why it took nearly a decade for Mojang to implement additional security and authentication measures for the protection of premium accounts, especially with their team being flooded with customer support requests and tickets related to account issues on a daily basis.
Indeed, the lack of security was also a problem for server owners. Impersonation of players and staff members was rampant and widespread, leading to unnecessary confusion and vulnerability across the entire Minecraft: Java Edition community.
The common workaround was to install some sort of multi-factor authentication plugin, many of which were written by less-experienced developers lacking requisite knowledge in best security practices (let alone Java conventions). In my review, I only found a select few which were adequate and maybe one or two that were designed well despite obfuscation.
I will not name specific examples, but many plugins made no attempt to protect user passwords - they lacked minimum password length and complexity requirements and used either outdated or “broken” hashing functions, or none at all - that is, stored in plaintext. For the servers using these plugins, if an adversary ever gained unauthorized access to the server files or database, its players’ logins would be immediately compromised. Even worse, if the players used the same password on other online services (finances, education, social media, etc.) then those accounts could also be compromised.
Knowing I would be in need of a secure authentication protocol for my own Minecraft server network and that the vast majority of the available multi-factor plugins did not meet my standards, I decided to develop my own open-source solution.
Implementation
Without any sort of proficiency in security, it becomes extraordinarily easy to make one or multiple common mistake(s) in software design. For protecting passwords, these include:
- Implementation of outdated or “broken” hashing algorithms
- Implementation of any fast hashing algorithm
- Implementation of a custom hashing algorithm
- No implementation of any hashing algorithm
- No minimum password requirements (length, complexity, entropy, etc.)
- No defense against brute-force attacks
- No defense against rainbow table attacks
- Vulnerability to SQL injection
The developer should always be aware of any threats posed by potential adversaries - otherwise, end users may become future victims. Moreover, the developer should prepare for not if, but when a breach will occur. For example, assume a worst case scenario where an attacker has gained access to the filesystem or database containing the user credentials on disk. If the developer has stored credentials in plaintext, they are immediately compromised. If the developer has stored credentials in unsalted MD5 digests, they are probably immediately compromised due to rainbow table lookups (e.g. CrackStation, HashKiller, etc.) or brute-force attacks (e.g. hashcat).
In a “best case of the worst case” scenario, an attacker has gained access to the filesystem or database containing user credentials but is still defeated due to correct design decisions made by the developer. For protecting passwords, the best practices include:
- Implementation of a secure password hashing algorithm
- Implementation of a slow (!) hashing algorithm
- Strict minimum password requirements
- Resistance against brute-force attacks
- Input salinization to defeat rainbow table attacks
- Input sanitation via prepared statements
The developer should also be aware with the most up-to-date information. Specifically, for protecting passwords, knowledge of publicly available cryptanalysis against the foremost cryptographic hashing algorithms is essential. For professional work, employers and government agencies normally set minimum standards which must be met to satisfy internal policy and compliance with the law. The National Institute of Standards and Technology, or NIST for short, sets many standards in the realm of cyber security - depending on the environment, compliance with NIST standards (among others) is compulsory.
In the context of Minecraft, servers owners and developers possess more freedom - compliance with any official standard in any capacity is entirely optional. Technically, this is both a blessing and a curse; while it allows developers to implement any algorithms they want (including those lacking compliance with NIST standards), some of these algorithms are secure and others are not (at least, not for passwords). For those developers lacking requisite knowledge in security, compliance with standards should be viewed as favorable to all non-compliant alternatives. For those who “know what they’re doing”, I would argue the freedom to choose is favorable.
While there are several secure (i.e. meeting the above “best practices”) and standardized algorithms available under NIST, such as PBKDF2 (my second/third pick, contested by Argon2), in designing DuoAuth, despite its non-compliance, I implemented what I believe is still the best possible option for protecting passwords and other sensitive credentials on disk. The algorithm I chose has withstood all scrutiny and has remained unbroken since its inception at USENIX in 1999. Is it also the default algorithm implemented inside numerous Linux (OpenBSD, SUSE Linux) distributions.
bcrypt
Based on the Blowfish block cipher, bcrypt was designed by Niels Provos and David Mazières to be arbitrarily slow and adaptive over time due to its cost factor parameter. In short, the cost factor controls the number of successive iterations the algorithm is applied to produce its 192-bit digests (typically, only 184 bits of the output are stored). For instance, a cost factor of 5 equates to 32 iterations since 25 = 32. In this manner, the cost factor can be adjusted by the developer over time to keep up with increasing processing power, allowing bcrypt to stay resistant against brute-force attacks in an exponential fashion.
Blowfish is technically a fast block cipher aside from its slow key exchange schedule, which bcrypt takes advantage of in a practice known as key stretching. The best public cryptanalysis of Blowfish indicates weaknesses against its 64-bit block size, such as the 2016 SWEET32 vulnerability, which leverages a birthday attack against ciphers with a 64-bit block size. This is not truly applicable in the context of bcrypt, as birthday attack susceptibility requires large amounts of data (e.g. HTTPS traffic analysis) and because Blowfish is used in a fundamentally different way inside of bcrypt than for symmetric encryption - as a result, this condition will never exist in bcrypt.
Essentially, bcrypt operates as follows:
- Specify a cost factor
- Generate a random salt
- Collect a password (plaintext)
- Derive an encryption key from the password using the salt and specified cost factor
- Use the key to encrypt a well-known string into ciphertext
- Store the cost factor, salt, and ciphertext together in a single delimited field
As mentioned previously, bcrypt digests are 192 bits in length but are truncated to the first 184 bits before they are stored on disk. For example, bcrypt("password", 12)
produces:
$2a$12$afnTB/PDPTG1MasgmJwXROvsRRldA3DNIL3Po2EZOFclCzR2ABPS2
-
$2a
(sometimes$2y
) denotes a bcrypt hash by standard -
$12
denotes the cost factor (212 = 4096 iterations) -
afnTB/PDPTG1MasgmJwXROvsRRldA3DNIL3Po2EZOFclCzR2ABPS2
denotes the salt and ciphertext, both of which have been Base64 encoded and concatenated together
As with any hash function, this operation is irreversible. There is no known process that an attacker can follow to derive the input from the output - if there was, the algorithm would be deemed broken and insecure. This differs from encryption, which always implies reversibility (i.e. the same operation can be performed to produce the plaintext from the ciphertext and vice versa). Hash functions perform a one-way “many-to-one” mapping of data from an arbitrary length to a fixed length whereas encryption performs a two-way reversible encipherment.
When a user attempts to authenticate:
- Retrieve the stored cost factor and salt
- Derive an encryption key from the user’s provided password, cost factor and salt
- Encrypt the same well-known string
If the generated ciphertext matches the stored ciphertext, the password is a match and authentication is granted.
One common “drawback” of bcrypt is its maximum password length of 72 bytes, which can be easily resolved in a number of ways. This limitation is due to the first operation of the ExpandKey
function XOR (⊕) the 18 4-byte subkeys against the password.
To avoid this problem in DuoAuth, I decided to pre-hash the plaintext with SHA-256. By doing so, user input is always fixed to a specific length before being processed through the bcrypt algorithm since SHA-256, as the name suggests, produces 256-bit (32-byte) digests. In function notation, this would be bcrypt(SHA256("password"), 12)
, which is not theoretically more secure than just using bcrypt("password", 12)
(cascaded encipherment occurs naturally in the wild, but does not provide additional security in most cases assuming all implemented algorithms are individually unbroken), but it does resolve the aforementioned password length limitation of using bcrypt on its own.
In DuoAuth, I specifically implemented jeremyh’s jBCrypt Java implementation of bcrypt, which is MIT-licensed on GitHub. To hash user input, I used hashpw()
, and to check authentication attempts, I used checkpw()
as demonstrated in my AuthCommand.java
class:
// hashpw()
final String digest = AuthUtil.getSecureBCryptHash(
AuthUtil.getSecureSHA512Hash(password),
costFactor
);
// checkpw()
final boolean bcrypt = Bcrypt.checkpw(
AuthUtil.getSecureSHA512Hash(password),
digest
);
I ensured these computations would always run asynchronously (i.e. off the main server thread) to avoid impacting in-game performance and user experience. This was done by implementing TaskChain, an MIT-licensed library by aikar used to, as the name implies, chain tasks together in sequence of either synchronous or asynchronous context. For example:
TaskChainManager.newChain()
.async(() -> { /* Some long operation */ })
.sync(() -> { /* Print results */ })
.async(() -> { /* Some other long operation */ })
.sync(() -> { /* Bukkit API stuff */ })
.execute();
Since bcrypt is designed to be slow, it makes sense to select an appropriate cost factor (as slow as possible for attackers, but not intolerably slow for honest users) and run the calculations asynchronously. On my SoYouStart i7-4790k dedicated servers, I found a cost factor of 17 (131,072 iterations) to be appropriate.
In designing DuoAuth, I made the cost factor configurable in the plugin’s config.yml
file:
# The cost factor of the bcrypt algorithm
# For best security measures, it is recommended to set this value in respect to the maximum amount of
# time you are willing to have your players wait to be authenticated (normally 12-17)
cost-factor: 15
To store the digests on disk, DuoAuth writes out .json
object files to its ./data/
directory in ./data/<uuid>.json
format where <uuid>
is the player’s UUID, or unique identifier. In a future version of the project, I intend to add support for SQLite and MySQL (and possibly others) but until then, this solution will suffice. This also currently avoids the necessity for most input sanitation via prepared statements, as SQL injection is an impossibility.
After adding support for bcrypt, I felt that DuoAuth met my standards and was good enough to be used by the Minecraft community. I had designed the plugin to be adaptive with time and its configuration permitted server owners to set minimum password requirements among other security settings. What else could I add? After a short while, I found my answer.
RFC6238
As mentioned above, adherence to standard can be beneficial in the context of secure software design. (R)equest (f)or (C)omments, or RFCs for short, are formal standards documenting specifications for particular technologies implemented on the Internet. RFC6238, for example, documents compliance requirements for the (T)ime-Based (O)ne-(T)ime (P)assword Algorithm (TOTP), the protocol used by numerous mobile authentication apps such as Google Authenticator and Authy.
TOTP is a variant of the (H)MAC-Based (O)ne-(T)ime (P)assword Algorithm (HOTP) as described in RFC4226. Essentially, TOTP specifies the calculation of a one-time password value, based on a representation of the counter as a time factor. This 6-digit password is valid for a short window of time - normally only 30 seconds. Users of mobile authentication apps are familiar with this process when they login to their various online accounts - they enter their normal account password and are prompted for their TOTP code, which is available in their mobile application.
Due to the assumed blind luck of an attacker, this protocol is not fool-proof. Imagining another worst case scenario, if an attacker has gained access to a user’s password and happens to correctly guess the current 6-digit code generated by the TOTP algorithm, the attacker is not defeated. Given that the probability of a correct guess is 1/1,000,000 = 0.0001%, the developer can reasonably assume this will never occur. Nevertheless, it is not impossible.
Due to the pigeonhole principle, a similar statement can technically be made about bcrypt and any other “many-to-one” hash function. One metric used to determine the strength (and weakness) of a hash function is its resistance to birthday attacks and other cryptanalysis to find collisions (i.e. two different inputs producing the same output). Given that there is no known way to produce collisions with bcrypt in a targeted fashion, the probability of a random collision is far less than 0.0001%, but again, it is not impossible.
By combining these two forms of authentication together (bcrypt + RFC6238), the probability of an attacker defeating both by random chance is not 0%, but can be assumed to never occur.
To implement TOTP in DuoAuth, I used the GoogleAuth library by wstrange under the BSD-3-Clause license. I wrote a simple class to hook into its API and named it GoogleAuth.java
:
package me.foncused.duoauth.spigot.lib.wstrange;
import com.warrenstrange.googleauth.GoogleAuthenticator;
import com.warrenstrange.googleauth.GoogleAuthenticatorKey;
import com.warrenstrange.googleauth.GoogleAuthenticatorQRGenerator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/*
Copyright (c) 2013 Warren Strange
Copyright (c) 2014-2017 Enrico M. Crisostomo
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
public class GoogleAuth {
private final GoogleAuthenticator ga;
private final Map<UUID, GoogleAuthenticatorKey> creds;
public GoogleAuth() {
this.ga = new GoogleAuthenticator();
this.creds = new ConcurrentHashMap<>();
}
public GoogleAuthenticatorKey generateRfc6238Credentials(final UUID uuid) {
this.creds.put(uuid, this.ga.createCredentials());
return this.creds.get(uuid);
}
public GoogleAuthenticatorKey getCreds(final UUID uuid) {
return this.creds.get(uuid);
}
public boolean containsCreds(final UUID uuid) {
return this.creds.containsKey(uuid);
}
public void removeCreds(final UUID uuid) {
this.creds.remove(uuid);
}
public boolean authorize(final String secret, final int code) {
return this.ga.authorize(secret, code);
}
public String getAuthUrl(final String issuer, final String account, final GoogleAuthenticatorKey key) {
return GoogleAuthenticatorQRGenerator.getOtpAuthURL(issuer, account, key);
}
public String getAuthTotpUrl(final String issuer, final String account, final GoogleAuthenticatorKey key) {
return GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(issuer, account, key);
}
}
Similar to bcrypt, I utilized these methods in my AuthCommand.java
class:
GoogleAuthenticatorKey key = this.ga.getCreds(uuid);
if(key == null) {
AuthUtil.alertOne(player, this.lm.getGenerating());
AuthUtil.notify("Generating authentication secret for user " + u + " (" + name + ")...");
key = this.ga.generateRfc6238Credentials(uuid);
}
AuthUtil.alertOne(player, AuthMessage.SECRET_KEY.toString() + key.getKey());
final TextComponent tc = new TextComponent(AuthMessage.QR.toString() + "Click me!");
tc.setClickEvent(
new ClickEvent(
ClickEvent.Action.OPEN_URL,
this.ga.getAuthUrl(this.cm.getCodeIssuer(), name, key)
)
);
tc.setHoverEvent(
new HoverEvent(
HoverEvent.Action.SHOW_TEXT,
new ComponentBuilder(this.lm.getPleaseSaveQr()).create()
)
);
AuthUtil.alertOneTextComponent(player, tc);
This code generates the authentication secret and provides a link to the QR image to be scanned into a mobile authentication app. It also instructs the user to save the image somewhere safe in case the secret is ever lost or deleted from the app, a situation I personally found myself in after my Samsung Galaxy S6 took a dive in a swimming pool in Cancún, Mexico in 2017.
Checking a provided TOTP code by the user is trivial. After retrieving the secret
from disk, the developer uses the authorize()
method to test the code:
final boolean rfc6238 = this.ga.authorize(secret, Integer.parseInt(code));
If the result is true
, the provided code is valid. Otherwise, authentication is denied.
In DuoAuth, my AuthCommand.java
command executor expects two arguments whenever the /auth
command is used. The expected format is /auth <password> <code>
where <password>
is the user’s password (checked against the stored bcrypt(SHA256())
digest) and <code>
is the 6-digit TOTP code stored in the user’s mobile authentication app. To authenticate successfully, both credentials must be correct or authentication is denied.
This is precisely why I named the plugin DuoAuth, as authentication requires a duo of user credentials similar to most online services supporting multi-factor login.
Configuration
The complete configuration for the plugin is contained inside the plugin’s config.yml
file. Nearly all imaginable settings are configurable, including the bcrypt cost factor, minimum password requirements, deauthentication parameters and even restricted player chat, movement and interaction settings using the relevant event handlers:
# @plugin DuoAuth
# @version 1.1.11
# @author foncused
# Are you using BungeeCord?
# If set to true, DuoAuth.jar should also be installed as a BungeeCord plugin
# This is only useful for blocking proxy-level commands (/server <server>, /send <player> <server>)
bungeecord: false
# The cost factor of the bcrypt algorithm
# For best security measures, it is recommended to set this value in respect to the maximum amount of
# time you are willing to have your players wait to be authenticated (normally 12-17)
cost-factor: 15
# Command options for /auth command
command:
# The cooldown time in seconds for using the /auth command (bypassed by 'duoauth.bypass')
cooldown: 20
# The maximum number of incorrect authentication attempts before players are locked out (bypassed by 'duoauth.unlimited')
# Disabled by setting to 0
attempts: 5
# Password options
# For best security measures, set a min-length of at least 8, both-cases to true,
# numbers to true, special-chars to true, and change default (Password1234#) as
# this default config.yml is public on GitHub; be advised the default password
# WILL NOT WORK if it does not meet the other requirements
password:
# The default password to be used when players join with permission 'duoauth.enforced'
default: "Password1234#"
# The minimum length of a provided password
min-length: 8
# true to enforce at least one uppercase (A-Z) and one lowercase letter (a-z), false if not
both-cases: true
# true to enforce at least one number (0-9), false if not
numbers: true
# true to enforce at least one special character in the provided 'special-charset', false if not
special-chars: true
special-charset: "@#$%^&+="
# Code options
code:
# The issuer of the OTP URL
# This will be the title displayed next to the 2FA code in the mobile authentication app
issuer: "DuoAuth"
# Deauthentication options
# For best security measures, set ip-changes to true
deauth:
# true to immediately deauthenticate players whenever their IP addresses change, false if not
ip-changes: true
# The time in hours to deauthenticate players after the last successful authentication
timeout: 72
# true to deauthenticate players even if they are online (can be annoying), false if not
timeout-online: false
# The heartbeat in minutes to check if players should be deauthenticated
timeout-check-heartbeat: 10
# Unlock options
unlock:
# The time in hours to automatically unlock locked players
# Disabled by setting to 0
timeout: 120
# The heartbeat in minutes to check if players should be unlocked
timeout-check-heartbeat: 15
# Database
# The method used for storing the digests
# This is not currently configurable; for now, DuoAuth will only store data in .json files in the /data directory
database: "json"
# true to allow chat prior to authentication, false if not
chat: false
# true to restrict yaw and pitch prior to authentication, false if not
restrict-movement: false
BungeeCord
Understanding that many servers eventually transform into networks (including mine), it was important to add BungeeCord support to DuoAuth as well. Specifically, for users granted special permissions in BungeeCord’s config.yml
file to access commands such as /server <server>
and /send <player> <server>
, BungeeCord integration would be necessary. Otherwise, bypassing authentication on the fallback server would be possible by connecting to another server on the proxy via /server <server>
as proxy-level commands would still be permitted by DuoAuth prior to authentication since DuoAuth runs as a Spigot plugin.
To integrate with BungeeCord and prohibit proxy-level functionality prior to authentication, I turned DuoAuth into a universal .jar
plugin. To do so, I added a bungee.yml
file pointing at me.foncused.duoauth.bungee.DuoAuth
, an entry point for the plugin when loaded by BungeeCord (or Waterfall, a fork of BungeeCord):
name: DuoAuth
main: me.foncused.duoauth.bungee.DuoAuth
version: 1.1.11
author: foncused
package me.foncused.duoauth.bungee;
import me.foncused.duoauth.bungee.event.Event;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.plugin.Plugin;
public class DuoAuth extends Plugin {
private ProxyServer proxy;
@Override
public void onEnable() {
this.proxy = this.getProxy();
this.proxy.registerChannel("duoauth:filter");
this.registerEvents();
}
private void registerEvents() {
this.proxy.getPluginManager().registerListener(this, new Event(this));
}
}
This class registers a custom BungeeCord channel named duoauth:filter
and an event listener to handle proxy-level commands. The event listener is implemented in Event.java
:
package me.foncused.duoauth.bungee.event;
import me.foncused.duoauth.bungee.DuoAuth;
import me.foncused.duoauth.spigot.enumerable.AuthMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.Connection;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.event.ChatEvent;
import net.md_5.bungee.api.event.PluginMessageEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.event.EventHandler;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
public class Event implements Listener {
private final DuoAuth plugin;
private final ProxyServer server;
private final Set<UUID> auths;
public Event(final DuoAuth plugin) {
this.plugin = plugin;
this.server = this.plugin.getProxy();
this.auths = new HashSet<>();
}
@EventHandler
public void onChat(final ChatEvent event) {
if(event.isCommand() && (!(event.getMessage().toLowerCase().matches("^/(auth|2fa).*$")))) {
final Connection sender = event.getSender();
if(sender instanceof ProxiedPlayer) {
final ProxiedPlayer player = (ProxiedPlayer) sender;
if(this.auths.contains(player.getUniqueId())) {
player.sendMessage(
(AuthMessage.PREFIX_ALERT.toString() + AuthMessage.PLAYER_NOT_AUTHED.toString())
.replaceAll("&", "§")
);
event.setCancelled(true);
}
}
}
}
@EventHandler
public void onPluginMessage(final PluginMessageEvent event) {
if(!(event.getTag().equals("duoauth:filter"))) {
return;
}
final ByteArrayInputStream bais = new ByteArrayInputStream(event.getData());
final DataInputStream dis = new DataInputStream(bais);
try {
final String action = dis.readUTF();
final Logger logger = this.server.getLogger();
final String prefix = "[DuoAuth] ";
switch(action) {
case "Add": {
final String u = dis.readUTF();
logger.log(Level.INFO, prefix + "Adding filter to " + u);
this.auths.add(UUID.fromString(u));
break;
}
case "Remove": {
final String u = dis.readUTF();
logger.log(Level.INFO, prefix + "Removing filter from " + u);
this.auths.remove(UUID.fromString(u));
break;
}
default:
logger.log(
Level.INFO,
prefix + "Proxy received plugin message " +
"with unknown action '" + action + "' - this will be ignored!"
);
break;
}
dis.close();
try {
bais.close();
} catch(final IOException e) {
e.printStackTrace();
}
} catch(final Exception e) {
e.printStackTrace();
}
}
}
Database
As mentioned previously, DuoAuth utilizes its own “database” by storing credentials in .json
format. The specific class handling this functionality is named AuthDatabase.java
:
package me.foncused.duoauth.spigot.database;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import me.foncused.duoauth.spigot.DuoAuth;
import me.foncused.duoauth.spigot.enumerable.DatabaseProperty;
import me.foncused.duoauth.spigot.util.AuthUtil;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class AuthDatabase {
private final DuoAuth plugin;
public AuthDatabase(final DuoAuth plugin) {
this.plugin = plugin;
}
public JsonElement readProperty(final UUID uuid, final DatabaseProperty property) {
final JsonObject object = this.read(uuid);
final String p = property.toString();
if(object != null && object.has(p)) {
return object.get(p);
}
this.readError(uuid, property);
return null;
}
private JsonObject read(final UUID uuid) {
try {
final FileReader reader = new FileReader(this.getJsonPath(uuid));
final JsonObject object = new JsonParser().parse(reader).getAsJsonObject();
reader.close();
return object;
} catch(final IOException e) {
e.printStackTrace();
}
return null;
}
public Set<UUID> readAll() {
final File[] files = new File(this.getDataFolder()).listFiles();
if(files != null) {
final Set<UUID> uuids = new HashSet<>();
for(final File file : files) {
final String name = file.getName();
if(name.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\.json$")) {
uuids.add(UUID.fromString(name.split("\\.")[0]));
}
}
return Collections.unmodifiableSet(uuids);
}
return null;
}
private void readError(final UUID uuid, final DatabaseProperty property) {
AuthUtil.consoleSevere(this.getJsonFile(uuid) + ": Unable to read property '" + property.toString() + "' from file");
}
public <O> boolean writeProperty(final UUID uuid, final DatabaseProperty property, final O data) {
final JsonObject object = this.read(uuid);
if(object != null) {
final String p = property.toString();
object.add(p, new Gson().toJsonTree(data));
final boolean written = this.write(uuid, object);
if(written) {
AuthUtil.console(this.getJsonFile(uuid) + ": " + p + " -> " + data);
return true;
} else {
this.writeError(uuid, property);
}
}
this.writeError(uuid, property);
return false;
}
public boolean write(
final UUID uuid,
final String password,
final String secret,
final boolean authed,
final int attempts,
final InetAddress ip
) {
final JsonObject object = new JsonObject();
object.addProperty(DatabaseProperty.PASSWORD.toString(), password);
object.addProperty(DatabaseProperty.SECRET.toString(), secret);
object.addProperty(DatabaseProperty.AUTHED.toString(), authed);
object.addProperty(DatabaseProperty.ATTEMPTS.toString(), attempts);
object.addProperty(DatabaseProperty.IP.toString(), ip.getHostAddress());
object.addProperty(DatabaseProperty.TIMESTAMP.toString(), AuthUtil.getFormattedTime(AuthUtil.getDateFormat()));
return this.write(uuid, object);
}
private boolean write(final UUID uuid, final JsonObject object) {
try {
final String dataPath = this.getDataFolder();
final File data = new File(dataPath);
if(!(data.exists()) && (!(data.mkdirs()))) {
AuthUtil.consoleSevere("Unable to create directory " + dataPath);
return false;
}
if(data.exists()) {
final String jsonPath = this.getJsonPath(uuid);
final File json = new File(jsonPath);
if(!(json.exists()) && (!(json.createNewFile()))) {
AuthUtil.consoleSevere("Unable to create file " + jsonPath);
return false;
}
final FileWriter writer = new FileWriter(json);
writer.write(object.toString());
writer.close();
return true;
}
return false;
} catch(final IOException e) {
e.printStackTrace();
}
return false;
}
private void writeError(final UUID uuid, final DatabaseProperty property) {
AuthUtil.consoleSevere(this.getJsonFile(uuid) + ": Unable to write property '" + property.toString() + "' to file");
}
public boolean contains(final UUID uuid) {
return new File(this.getJsonPath(uuid)).exists();
}
public boolean delete(final UUID uuid) {
return new File(this.getJsonPath(uuid)).delete();
}
private String getJsonPath(final UUID uuid) {
return this.getDataFolder() + this.getJsonFile(uuid);
}
private String getJsonFile(final UUID uuid) {
return uuid.toString() + ".json";
}
private String getDataFolder() {
return this.plugin.getDataFolder().getPath() + "/data/";
}
}
In a future release of DuoAuth, when I decide to support other types of databases such as SQLite or MySQL, this class will likely be turned into an interface for my classes to implement. Supporting SQL will require input sanitation via prepared statements to avoid the risk of SQL injection. Until then, credentials will be stored in the following format:
{
"Password":"$2a$14$hQ7cCEvjnZ.30haqtUKOGOAyGao3MerifrDPHXLTG5wRCmNd22vgW",
"Secret":"P5PFU7XMFR65SBNO",
"Authed":false,
"Attempts":1,
"IP":"127.0.0.1",
"Timestamp":"08/17/2019 17:44:26:571"
}
Results
While this project is technically complete, I do still need to provide updates when new versions of Minecraft: Java Edition are released if they happen to impact my code. This has not really been the case with DuoAuth since it does not use NMS or other version-dependent functionality. Fortunately, the Spigot API has been designed to remain forward-compatible - this description has been proven accurate over the years thanks to the hundreds (thousands) of hours of work put into the project.
DuoAuth has matured to meet my expectations and has satisfied my standards for what should be a secure, adaptive and open-source authentication mechanism for the Minecraft community. It is my sole hope that other server owners will benefit from its use and trust it to protect their server against impersonation.
The project is viewable in a public GitHub repository and builds are available on spigotmc.org - links to both are provided for convenience:
- GitHub: https://github.com/foncused/DuoAuth
- Spigot: https://www.spigotmc.org/resources/duoauth.80609/