Серверная база со своим логином
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user