Серверная база со своим логином

Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него.

Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент
называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого
соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и
подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а
запись обрезать нельзя: сервер не знает исходного пароля.

Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе:
соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт
называется в Hello, а не в Auth. Иначе клиент получает соль не того
аккаунта и не может вывести ключ.

Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды
лежат рядом с записью, а не в исходниках: число, существующее только в
коде, однажды становится причиной того, что аккаунт невозможно проверить.

Имена уникальны независимо от регистра: Player и player — это один
аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его.

Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает
подбирать одно имя, другое не мешает перебирать много имён с разных
адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на
неверный пароль, и занимает то же время.

Токены живут только в памяти. Записанный на диск токен пережил бы процесс,
который его выдал, а украденный каталог сервера стал бы набором живых
сессий.

Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто
зашёл с одного IP, то есть всех игроков за одним NAT.

Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который
тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего
мы уходим, перенося симуляцию на свой сервер.

Регистрация — команда оператора, а не функция у клиента. Открытая
саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь
поддержки.

Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий
сокет: регистрация, вход, доказательство пароля, токен, его погашение и
появление игроком. Отдельно проверено, что неверный пароль отвергается, а
старое доказательство не принимается против нового вызова. Тик-цикл
замеряется числом тиков за секунду, а не наличием потока.

Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
sasheg dev
2026-10-19 01:45:56 +03:00
parent 7fbb26092f
commit 620f136361
17 changed files with 2576 additions and 14 deletions
@@ -0,0 +1,88 @@
package net.rustnotmc.auth;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* Reads and writes the account file.
*
* Saving goes to a temporary file and is then moved into place. Writing the
* real file directly means a crash halfway through leaves a server with half
* its accounts and no way to know which half, and the damage is only discovered
* the next time somebody tries to log in.
*/
public final class AccountFile {
/** Format marker, so a future format can be told apart from this one. */
private static final String HEADER = "# rsx-accounts v1";
private AccountFile() {
}
public static void load(Path path, AccountStore store) throws IOException {
if (!Files.exists(path)) {
Files.createDirectories(path.toAbsolutePath().getParent());
Files.createFile(path);
return;
}
BufferedReader reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(path), StandardCharsets.UTF_8));
try {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\t");
if (parts.length < 4) {
throw new IOException("malformed account file at line " + lineNumber);
}
long created;
long last;
try {
created = Long.parseLong(parts[2]);
last = Long.parseLong(parts[3]);
} catch (NumberFormatException e) {
throw new IOException("bad timestamp at line " + lineNumber);
}
store.restore(new AccountStore.Account(parts[0], parts[1], created, last));
}
} finally {
reader.close();
}
}
public static void save(Path path, AccountStore store) throws IOException {
Path directory = path.toAbsolutePath().getParent();
Files.createDirectories(directory);
Path temporary = Files.createTempFile(directory, "accounts", ".tmp");
Writer writer = new OutputStreamWriter(
Files.newOutputStream(temporary), StandardCharsets.UTF_8);
try {
writer.write(HEADER);
writer.write('\n');
for (AccountStore.Account account : store.all()) {
writer.write(account.encode());
writer.write('\n');
}
} finally {
writer.close();
}
try {
Files.move(temporary, path,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,167 @@
package net.rustnotmc.auth;
import java.util.Base64;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* The accounts, and where they live.
*
* Names are unique case insensitively, the way players expect: {@code Player}
* and {@code player} are one account, and creating the second one is refused
* rather than silently producing two players who cannot tell which is theirs.
*
* The file format is one account per line, written atomically. A server that
* is killed halfway through saving should come back with the accounts it had,
* not with half of them.
*/
public final class AccountStore {
/** One account. */
public static final class Account {
public final String name;
public final String passwordRecord;
public final long createdAt;
public final long lastLogin;
public Account(String name, String passwordRecord, long createdAt, long lastLogin) {
this.name = name;
this.passwordRecord = passwordRecord;
this.createdAt = createdAt;
this.lastLogin = lastLogin;
}
public Passwords.Record decode() {
return Passwords.Record.decode(this.passwordRecord);
}
public String encode() {
return this.name + "\t" + this.passwordRecord + "\t"
+ this.createdAt + "\t" + this.lastLogin;
}
}
/** Raised when an account cannot be created or found. */
public static final class AccountException extends RuntimeException {
private static final long serialVersionUID = 1L;
public AccountException(String message) {
super(message);
}
}
private final Map<String, Account> byName = new ConcurrentHashMap<String, Account>();
private final Map<String, String> canonicalByKey = new ConcurrentHashMap<String, String>();
/**
* Creates an account.
*
* @throws AccountException if the name is taken, malformed, or the password
* does not meet the policy
*/
public Account create(String name, char[] password, long now) {
String cleaned = name == null ? "" : name.trim();
checkName(cleaned);
if (!Passwords.acceptable(password)) {
throw new AccountException("password " + Passwords.policy());
}
String key = key(cleaned);
if (this.canonicalByKey.containsKey(key)) {
throw new AccountException("name " + cleaned + " is taken");
}
Account account = new Account(cleaned,
Passwords.create(password).encode(), now, 0L);
this.byName.put(cleaned, account);
this.canonicalByKey.put(key, cleaned);
return account;
}
/** Registers an account that already has a record, used when loading. */
public void restore(Account account) {
String key = key(account.name);
this.byName.put(account.name, account);
this.canonicalByKey.put(key, account.name);
}
/** The account under this name, in whatever case it was registered. */
public Account find(String name) {
if (name == null) {
return null;
}
// the canonical name is missing for an account that was never created,
// and ConcurrentHashMap rejects a null key rather than answering
String canonical = this.canonicalByKey.get(key(name));
if (canonical == null) {
return null;
}
return this.byName.get(canonical);
}
public boolean exists(String name) {
return find(name) != null;
}
/** Notes that an account was used, so the file records it next save. */
public void markLogin(String name, long now) {
Account existing = find(name);
if (existing == null) {
return;
}
Account updated = new Account(existing.name, existing.passwordRecord,
existing.createdAt, now);
this.byName.put(existing.name, updated);
}
/**
* Replaces a record, which is how a login upgrades an old iteration count
* without the player having to reset their password.
*/
public void replaceRecord(String name, Passwords.Record record) {
Account existing = find(name);
if (existing == null) {
return;
}
Account updated = new Account(existing.name, record.encode(),
existing.createdAt, existing.lastLogin);
this.byName.put(existing.name, updated);
}
public Collection<Account> all() {
return new LinkedHashMap<String, Account>(this.byName).values();
}
public int size() {
return this.byName.size();
}
public static void checkName(String name) {
if (name.isEmpty()) {
throw new AccountException("name is empty");
}
if (name.length() < 3 || name.length() > 16) {
throw new AccountException("name must be 3 to 16 characters");
}
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '_';
if (!ok) {
throw new AccountException(
"name may use letters, digits and underscore only");
}
}
}
private static String key(String name) {
return name.trim().toLowerCase(Locale.ROOT);
}
static String encode64(byte[] value) {
return Base64.getEncoder().withoutPadding().encodeToString(value);
}
}
@@ -0,0 +1,151 @@
package net.rustnotmc.auth;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Who may try to log in, how often, and for how long.
*
* A password store without a throttle is a password store that can be emptied
* offline at whatever rate the network allows, and PBKDF2's whole purpose is to
* make each guess expensive. This makes the guesses expensive to start.
*
* Two independent buckets, because one alone is not enough: per account stops
* one attacker grinding a single name, per address stops a single attacker
* grinding many names.
*/
public final class LoginThrottle {
/** One subject's record. */
private static final class Bucket {
int failures;
long lockedUntil;
long firstFailureAt;
/** Seconds to wait before the next try is allowed. */
long backoffSeconds(long now) {
if (now < this.lockedUntil) {
return (this.lockedUntil - now + 999L) / 1000L;
}
return 0L;
}
void fail(long now, long baseMillis, long capMillis) {
this.failures++;
if (this.firstFailureAt == 0L) {
this.firstFailureAt = now;
}
long wait = baseMillis;
for (int i = 1; i < this.failures; i++) {
wait *= 2L;
if (wait >= capMillis) {
wait = capMillis;
break;
}
}
this.lockedUntil = now + wait;
}
void succeed() {
this.failures = 0;
this.lockedUntil = 0L;
this.firstFailureAt = 0L;
}
}
private final Map<String, Bucket> byAccount = new ConcurrentHashMap<String, Bucket>();
private final Map<String, Bucket> byAddress = new ConcurrentHashMap<String, Bucket>();
private final long baseMillis;
private final long capMillis;
private final Clock clock;
public LoginThrottle() {
this(500L, 60_000L);
}
public LoginThrottle(long baseMillis, long capMillis) {
this(baseMillis, capMillis, System::currentTimeMillis);
}
/** The clock is injected so the backoff can be tested without sleeping. */
public LoginThrottle(long baseMillis, long capMillis, Clock clock) {
this.baseMillis = baseMillis;
this.capMillis = capMillis;
this.clock = clock;
}
/** Where time comes from. */
public interface Clock {
long now();
}
/** How long until this subject may try again, in seconds. Zero means now. */
public long waitSeconds(String account, String address) {
long now = this.clock.now();
long wait = 0L;
Bucket accountBucket = this.byAccount.get(key(account));
if (accountBucket != null) {
wait = Math.max(wait, accountBucket.backoffSeconds(now));
}
Bucket addressBucket = this.byAddress.get(key(address));
if (addressBucket != null) {
wait = Math.max(wait, addressBucket.backoffSeconds(now));
}
return wait;
}
public boolean allowed(String account, String address) {
return waitSeconds(account, address) <= 0L;
}
/** Records a failed attempt against both buckets. */
public void failed(String account, String address) {
long now = this.clock.now();
bucket(this.byAccount, key(account)).fail(now, this.baseMillis, this.capMillis);
bucket(this.byAddress, key(address)).fail(now, this.baseMillis, this.capMillis);
}
/** Records a success, which clears the account side but not the address. */
public void succeeded(String account) {
Bucket bucket = this.byAccount.get(key(account));
if (bucket != null) {
bucket.succeed();
}
}
/** Forgets everything, for an operator who has dealt with the problem. */
public void clear() {
this.byAccount.clear();
this.byAddress.clear();
}
public int tracked() {
return this.byAccount.size() + this.byAddress.size();
}
private static Bucket bucket(Map<String, Bucket> map, String key) {
Bucket existing = map.get(key);
if (existing != null) {
return existing;
}
Bucket created = new Bucket();
Bucket raced = map.putIfAbsent(key, created);
return raced != null ? raced : created;
}
private static String key(String value) {
if (value == null) {
return "";
}
// accounts are compared case insensitively everywhere else, so the
// throttle keys must be too or a player gets a fresh budget by typing
// their name in different case
return value.trim().toLowerCase(Locale.ROOT);
}
static String encode(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
}
@@ -0,0 +1,163 @@
package net.rustnotmc.auth;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
/**
* Turns a password into what is actually stored.
*
* The password is stretched with PBKDF2-HMAC-SHA256 and a per-account salt. The
* server keeps the stretched form and cannot read the password back out of it,
* so a leaked account file does not hand over every player's login.
*
* Iteration count is stored with the hash, never assumed. A count that only
* exists in this source becomes a compatibility problem the moment it needs to
* change, and an account that cannot be checked is an account nobody can use.
*/
public final class Passwords {
/** The current default. Reasonable for 2026 on a server CPU. */
public static final int DEFAULT_ITERATIONS = 210_000;
private static final int SALT_BYTES = 16;
private static final int HASH_BITS = 256;
/** A hash record: the salt, the work factor and the result. */
public static final class Record {
public final byte[] salt;
public final int iterations;
public final byte[] hash;
public Record(byte[] salt, int iterations, byte[] hash) {
this.salt = salt;
this.iterations = iterations;
this.hash = hash;
}
/**
* Serialised as one line: iterations, then salt, then hash, all
* base64. Three fields on one line with a fixed order is easy to read in
* a diff and easy to parse without a dependency.
*/
public String encode() {
Base64.Encoder enc = Base64.getEncoder().withoutPadding();
return "pbkdf2-sha256$" + this.iterations + "$"
+ enc.encodeToString(this.salt) + "$"
+ enc.encodeToString(this.hash);
}
public static Record decode(String line) {
if (line == null) {
throw new IllegalArgumentException("no record");
}
String[] parts = line.trim().split("\\$");
if (parts.length != 4 || !"pbkdf2-sha256".equals(parts[0])) {
throw new IllegalArgumentException("unrecognised password record");
}
Base64.Decoder dec = Base64.getDecoder();
return new Record(
dec.decode(parts[2]),
Integer.parseInt(parts[1]),
dec.decode(parts[3]));
}
}
private Passwords() {
}
public static Record create(char[] password) {
return create(password, DEFAULT_ITERATIONS);
}
public static Record create(char[] password, int iterations) {
byte[] salt = Secure.bytes(SALT_BYTES);
return new Record(salt, iterations, stretch(password, salt, iterations));
}
/**
* Checks a password against a stored record.
*
* The work is always done, even when the record is missing, so the time
* taken does not say whether the account exists.
*/
public static boolean verify(char[] password, Record record) {
if (record == null) {
// burn roughly one verification so an absent account and a wrong
// password take the same time
stretch(password, new byte[SALT_BYTES], DEFAULT_ITERATIONS);
return false;
}
byte[] candidate = stretch(password, record.salt, record.iterations);
boolean ok = Secure.same(candidate, record.hash);
Arrays.fill(candidate, (byte) 0);
return ok;
}
private static byte[] stretch(char[] password, byte[] salt, int iterations) {
try {
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, HASH_BITS);
try {
return factory.generateSecret(spec).getEncoded();
} finally {
// a PBEKeySpec keeps a copy of the password in a char array that
// clearPassword is the only way to zero
spec.clearPassword();
}
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is unavailable", e);
}
}
/**
* True when a stored record was made with weaker work than we now want.
*
* Checked at login so records upgrade themselves the next time their owner
* types the password, instead of every account being re-hashed by hand.
*/
public static boolean needsRehash(Record record) {
return record == null || record.iterations < DEFAULT_ITERATIONS
|| record.hash.length * 8 != HASH_BITS;
}
/** A human-readable note about the policy, for a registration screen. */
public static String policy() {
return "at least 8 characters";
}
/** Rejects the passwords that make an account worthless to its owner. */
public static boolean acceptable(char[] password) {
return password != null && password.length >= 8;
}
/** Bytes that must not linger in memory longer than they are needed. */
public static void wipe(char[] password) {
if (password != null) {
Arrays.fill(password, '\0');
}
}
/** Convenience for tests and tools that only have a String. */
public static char[] toChars(String password) {
return password == null ? null : password.toCharArray();
}
static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
static byte[] sha256(byte[] input) {
try {
return MessageDigest.getInstance("SHA-256").digest(input);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
}
@@ -0,0 +1,52 @@
package net.rustnotmc.auth;
import java.security.SecureRandom;
/**
* The one source of unpredictable bytes in the project.
*
* {@link java.util.Random} is seeded from the clock, so anyone who can guess
* when a process started can predict every value it will ever produce. That is
* fine for a shuffle and fatal for a session token, a salt and a challenge.
*/
public final class Secure {
private static final SecureRandom RANDOM = new SecureRandom();
private Secure() {
}
public static byte[] bytes(int count) {
if (count < 0) {
throw new IllegalArgumentException("negative length: " + count);
}
byte[] value = new byte[count];
RANDOM.nextBytes(value);
return value;
}
public static int nextInt(int bound) {
if (bound <= 0) {
throw new IllegalArgumentException("bound must be positive: " + bound);
}
return RANDOM.nextInt(bound);
}
/**
* Compares two byte arrays without a shortcut for the first mismatch.
*
* An array comparison that returns as soon as it sees a difference reports
* how far it got, and an attacker can use that to learn a token one byte at
* a time by measuring how long the answer took.
*/
public static boolean same(byte[] a, byte[] b) {
if (a == null || b == null || a.length != b.length) {
return false;
}
int diff = 0;
for (int i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
@@ -0,0 +1,128 @@
package net.rustnotmc.auth;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Session tokens, and the rules about when one is worth something.
*
* A token is 32 random bytes, compared in constant time, and carries the
* account it belongs to and the moment it stops working. Logging out forgets
* it immediately rather than waiting for it to expire, because a player who
* clicked logout on a shared computer means it.
*
* Tokens live in memory only. A token that was written to disk would outlive
* the process that issued it, and a stolen server data directory would then be
* a stolen set of live sessions.
*/
public final class TokenService {
/** How long a fresh token lasts. */
public static final long DEFAULT_LIFETIME_MILLIS = 12L * 60L * 60L * 1000L;
/** One issued token. */
public static final class Token {
public final String value;
public final String account;
public final long issuedAt;
public final long expiresAt;
Token(String value, String account, long issuedAt, long expiresAt) {
this.value = value;
this.account = account;
this.issuedAt = issuedAt;
this.expiresAt = expiresAt;
}
public boolean expired(long now) {
return now >= this.expiresAt;
}
}
private final Map<String, Token> live = new ConcurrentHashMap<String, Token>();
private final long lifetimeMillis;
private final LoginThrottle.Clock clock;
public TokenService() {
this(DEFAULT_LIFETIME_MILLIS, new LoginThrottle.Clock() {
public long now() {
return System.currentTimeMillis();
}
});
}
public TokenService(long lifetimeMillis, LoginThrottle.Clock clock) {
this.lifetimeMillis = lifetimeMillis;
this.clock = clock;
}
/** Issues a token for an account, valid for the configured lifetime. */
public Token issue(String account) {
long now = this.clock.now();
Token token = new Token(LoginThrottle.encode(Secure.bytes(32)),
account, now, now + this.lifetimeMillis);
this.live.put(token.value, token);
return token;
}
/** The token behind a value, or null when it is unknown or expired. */
public Token check(String value) {
if (value == null || value.isEmpty()) {
return null;
}
Token token = this.live.get(value);
if (token == null) {
return null;
}
if (token.expired(this.clock.now())) {
this.live.remove(value);
return null;
}
return token;
}
public boolean valid(String value, String account) {
Token token = check(value);
return token != null && token.account.equalsIgnoreCase(account);
}
/** Forgets one token, so it stops being usable immediately. */
public void revoke(String value) {
if (value != null) {
this.live.remove(value);
}
}
/** Forgets every token belonging to an account, for a password change. */
public int revokeAll(String account) {
int gone = 0;
for (Map.Entry<String, Token> entry : this.live.entrySet()) {
if (entry.getValue().account.equalsIgnoreCase(account)) {
this.live.remove(entry.getKey());
gone++;
}
}
return gone;
}
/** Drops tokens that have run out, so the map does not grow forever. */
public int sweep() {
long now = this.clock.now();
int gone = 0;
for (Map.Entry<String, Token> entry : this.live.entrySet()) {
if (entry.getValue().expired(now)) {
this.live.remove(entry.getKey());
gone++;
}
}
return gone;
}
public int live() {
return this.live.size();
}
public long lifetimeMillis() {
return this.lifetimeMillis;
}
}