Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Edit `config/offlineauth/config.json` to customize:
## Security Notice

- **Auto-login (same IP)** is convenient, but insecure on public computers/networks. Always warn players to protect their accounts.
- Passwords are stored in plain text (by default), use at your own risk or extend for encryption.
- Passwords are securely hashed using PBKDF2 with a random salt and stored under `config/offlineauth/auth_hash.json` on the server.

## How to Install

Expand Down
2 changes: 1 addition & 1 deletion README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
## 安全提醒

- **自动登录(同IP)功能** 虽然便捷,但在网吧/公共电脑环境下存在被盗号风险。务必提醒玩家注意账号安全。
- 密码默认明文存储,仅供学习/小型服使用,如需高安全请自行扩展加密方案
- 密码现已使用 PBKDF2 算法和随机盐进行安全哈希,并存储于服务器的 `config/offlineauth/auth_hash.json` 中

## 安装方法

Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ dependencies {
// If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"],
// then special handling is done to allow a setup of a vanilla dependency without the use of an external repository.
minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}"
implementation files('libs/trueuuid-1.0.2.jar')
implementation files('libs/trueuuid-1.0.5.jar')
// Example mod dependency with JEI - using fg.deobf() ensures the dependency is remapped to your development mappings
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
// compileOnly fg.deobf("mezz.jei:jei-${mc_version}-common-api:${jei_version}")
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ mod_name=OfflineAuth
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=GNU LGPL 3.0
# The mod version. See https://semver.org/
mod_version=1.0.1
mod_version=1.0.3
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
Expand Down
102 changes: 93 additions & 9 deletions src/main/java/cn/alini/offlineauth/JsonAuthStorage.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,36 @@

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.mojang.logging.LogUtils;
import org.slf4j.Logger;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Base64;
import java.util.Arrays;

public class JsonAuthStorage {
private static final String DIR = "config/offlineauth";
private static final String FILE_NAME = "auth.json";
private static final String FILE_NAME = "auth_hash.json";
private static final String OLD_FILE_NAME = "auth.json";
private static final Path FILE_PATH = Path.of(DIR, FILE_NAME);
private static final Gson gson = new Gson();
private static final Logger LOGGER = LogUtils.getLogger();

// PBKDF2 constants for password hashing
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int ITERATIONS = 10000;
private static final int KEY_LENGTH = 256;
private static final String SALT_SEPARATOR = ":";

private Map<String, String> credentials = new HashMap<>();
private long lastModified = -1;
Expand All @@ -27,8 +44,8 @@ public JsonAuthStorage() {
private void reloadIfChanged() {
File file = FILE_PATH.toFile();
if (!file.exists()) {
credentials = new HashMap<>();
lastModified = -1;
// File missing, might need migration or it's just empty. Let load() handle it.
load();
return;
}
long lm = file.lastModified();
Expand All @@ -41,9 +58,37 @@ private void load() {
credentials = new HashMap<>();
File dir = new File(DIR);
if (!dir.exists()) dir.mkdirs();

File file = FILE_PATH.toFile();
File oldFile = Path.of(DIR, OLD_FILE_NAME).toFile();

// Old to New Migration logic: If new file doesn't exist but old one does
if (!file.exists() && oldFile.exists()) {
boolean migrationSuccess = false;
try (Reader reader = new FileReader(oldFile)) {
Type type = new TypeToken<Map<String, String>>(){}.getType();
Map<String, String> map = gson.fromJson(reader, type);
if (map != null) {
for (Map.Entry<String, String> entry : map.entrySet()) {
// Hash existing plaintext passwords
credentials.put(entry.getKey(), hashPassword(entry.getValue()));
}
}
migrationSuccess = true;
} catch (Exception e) {
LOGGER.error("Failed to migrate old auth.json", e);
}

if (migrationSuccess) {
save(); // Save to new auth_hash.json
oldFile.renameTo(new File(dir, OLD_FILE_NAME + ".migrated")); // Rename old file
}
lastModified = file.exists() ? file.lastModified() : -1;
return;
}

if (!file.exists()) {
save(); // 写一个空文件
save(); // 写一个空文件 (Write an empty file)
lastModified = file.exists() ? file.lastModified() : -1;
return;
}
Expand All @@ -52,7 +97,7 @@ private void load() {
Map<String, String> map = gson.fromJson(reader, type);
if (map != null) credentials.putAll(map);
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("Failed to load auth_hash.json", e);
}
lastModified = file.lastModified();
}
Expand All @@ -63,7 +108,7 @@ private void save() {
try (Writer writer = new FileWriter(FILE_PATH.toFile())) {
gson.toJson(credentials, writer);
} catch (IOException e) {
e.printStackTrace();
LOGGER.error("Failed to save auth_hash.json", e);
}
File file = FILE_PATH.toFile();
lastModified = file.exists() ? file.lastModified() : -1;
Expand All @@ -76,20 +121,59 @@ public boolean isRegistered(String name) {

public void register(String name, String password) {
reloadIfChanged();
credentials.put(name, password);
credentials.put(name, hashPassword(password));
save();
}

public boolean checkPassword(String name, String password) {
reloadIfChanged();
return credentials.containsKey(name) && credentials.get(name).equals(password);
return credentials.containsKey(name) && verifyPassword(password, credentials.get(name));
}

public void changePassword(String name, String newPassword) {
reloadIfChanged();
if (credentials.containsKey(name)) {
credentials.put(name, newPassword);
credentials.put(name, hashPassword(newPassword));
save();
}
}

private String hashPassword(String password) {
try {
// Generate random 16 byte salt
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);

// Hash using PBKDF2 with the generated salt
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
SecretKeyFactory skf = SecretKeyFactory.getInstance(ALGORITHM);
byte[] hash = skf.generateSecret(spec).getEncoded();

// Format: salt:hash (both Base64)
return Base64.getEncoder().encodeToString(salt) + SALT_SEPARATOR + Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException("Failed to hash password", e);
}
}

private boolean verifyPassword(String inputPassword, String storedHash) {
try {
// Split value into salt and hash
String[] parts = storedHash.split(SALT_SEPARATOR);
if (parts.length != 2) return false;
byte[] salt = Base64.getDecoder().decode(parts[0]);
byte[] hash = Base64.getDecoder().decode(parts[1]);

// Hash the input password using the same salt
PBEKeySpec spec = new PBEKeySpec(inputPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
SecretKeyFactory skf = SecretKeyFactory.getInstance(ALGORITHM);
byte[] inputHash = skf.generateSecret(spec).getEncoded();

// Compare new hash with stored hash
return Arrays.equals(hash, inputHash);
} catch (Exception e) {
LOGGER.error("Failed to verify password", e);
return false;
}
}
}
10 changes: 5 additions & 5 deletions src/main/java/cn/alini/offlineauth/OfflineAuthHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class OfflineAuthHandler {
private static final Gson gson = new Gson();
private static final Map<String, AutoLoginInfo> autoLoginMap = new HashMap<>();
private static final Map<String, FailInfo> failMap = new HashMap<>();
static { loadAutoLogin(); loadFail(); }
static { config.load(); loadAutoLogin(); loadFail(); }

private static boolean isOfflinePlayer(ServerPlayer player) {
return !TrueuuidApi.isPremium(player.getName().getString().toLowerCase(Locale.ROOT));
Expand Down Expand Up @@ -76,13 +76,13 @@ private static void loadAutoLogin() {
Type t = new TypeToken<Map<String, AutoLoginInfo>>(){}.getType();
Map<String, AutoLoginInfo> m = gson.fromJson(r, t);
if (m != null) autoLoginMap.putAll(m);
} catch (Exception e) { e.printStackTrace(); }
} catch (Exception e) { LOGGER.error("Failed to load autologin.json", e); }
}
private static void saveAutoLogin() {
File f = new File(AUTOLOGIN_FILE);
try (Writer w = new FileWriter(f)) {
gson.toJson(autoLoginMap, w);
} catch (Exception e) { e.printStackTrace(); }
} catch (Exception e) { LOGGER.error("Failed to save autologin.json", e); }
}
private static void loadFail() {
File f = new File(FAIL_FILE);
Expand All @@ -91,13 +91,13 @@ private static void loadFail() {
Type t = new TypeToken<Map<String, FailInfo>>(){}.getType();
Map<String, FailInfo> m = gson.fromJson(r, t);
if (m != null) failMap.putAll(m);
} catch (Exception e) { e.printStackTrace(); }
} catch (Exception e) { LOGGER.error("Failed to load fail.json", e); }
}
private static void saveFail() {
File f = new File(FAIL_FILE);
try (Writer w = new FileWriter(f)) {
gson.toJson(failMap, w);
} catch (Exception e) { e.printStackTrace(); }
} catch (Exception e) { LOGGER.error("Failed to save fail.json", e); }
}

@SubscribeEvent
Expand Down
1 change: 0 additions & 1 deletion src/main/java/cn/alini/offlineauth/Offlineauth.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ public class Offlineauth {
public Offlineauth() {
//mod成功加载日志
LOGGER.info("OfflineAuth mod loaded successfully!");
new AuthConfig().save();
}
}