Серверная база со своим логином
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user