620f136361
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект.
128 lines
3.9 KiB
Java
128 lines
3.9 KiB
Java
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;
|
|
}
|
|
} |