Rework login, get account from PG

This commit is contained in:
P0nk
2024-09-27 22:52:12 +02:00
parent 082e0c0486
commit 5abae50be5
8 changed files with 197 additions and 61 deletions

View File

@@ -22,6 +22,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package client; package client;
import config.YamlConfig; import config.YamlConfig;
import database.account.Account;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent; import io.netty.handler.timeout.IdleStateEvent;
@@ -63,6 +64,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.Arrays; import java.util.Arrays;
import java.util.Calendar; import java.util.Calendar;
import java.util.Collections; import java.util.Collections;
@@ -72,6 +74,7 @@ import java.util.Iterator;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
@@ -84,6 +87,8 @@ public class Client extends ChannelInboundHandlerAdapter {
private static final int MAX_FAILED_LOGIN_ATTEMPTS = 5; private static final int MAX_FAILED_LOGIN_ATTEMPTS = 5;
private static final int MAX_CHR_SLOTS = 15; private static final int MAX_CHR_SLOTS = 15;
public static final byte NO_GENDER = 10;
public static final int LOGIN_NOTLOGGEDIN = 0; public static final int LOGIN_NOTLOGGEDIN = 0;
public static final int LOGIN_SERVER_TRANSITION = 1; public static final int LOGIN_SERVER_TRANSITION = 1;
public static final int LOGIN_LOGGEDIN = 2; public static final int LOGIN_LOGGEDIN = 2;
@@ -97,12 +102,13 @@ public class Client extends ChannelInboundHandlerAdapter {
private volatile boolean inTransition; private volatile boolean inTransition;
private io.netty.channel.Channel ioChannel; private io.netty.channel.Channel ioChannel;
private Account account;
private Character player; private Character player;
private int channel = 1; private int channel = 1;
private int accId = -4; private int accId = -4;
private boolean loggedIn = false; private boolean loggedIn = false;
private boolean serverTransition = false; private boolean serverTransition = false;
private Calendar birthday = null; private Calendar birthday = null; // TODO: convert to LocalDate
private String accountName = null; private String accountName = null;
private int world; private int world;
private volatile long lastPong; private volatile long lastPong;
@@ -281,6 +287,25 @@ public class Client extends ChannelInboundHandlerAdapter {
return getChannelServer().getEventSM().getEventManager(event); return getChannelServer().getEventSM().getEventManager(event);
} }
public Account getAccount() {
return account;
}
public void setAccount(Account account) {
Objects.requireNonNull(account);
this.account = account;
this.accId = account.id();
this.accountName = account.name();
this.characterSlots = account.chrSlots();
this.pin = account.pin();
this.pic = account.pic();
this.gender = Objects.requireNonNullElse(account.gender(), NO_GENDER);
Calendar calendar = Calendar.getInstance();
LocalDate birthdate = account.birthdate();
calendar.set(birthdate.getYear(), birthdate.getMonthValue() - 1, birthdate.getDayOfMonth());
this.birthday = calendar;
}
public Character getPlayer() { public Character getPlayer() {
return player; return player;
} }
@@ -301,6 +326,8 @@ public class Client extends ChannelInboundHandlerAdapter {
return serverTransition; return serverTransition;
} }
// TODO: load ipbans on server start and query it on demand. This query should not be run on every login!
@Deprecated
public boolean hasBannedIP() { public boolean hasBannedIP() {
boolean ret = false; boolean ret = false;
try (Connection con = DatabaseConnection.getConnection(); try (Connection con = DatabaseConnection.getConnection();
@@ -342,6 +369,8 @@ public class Client extends ChannelInboundHandlerAdapter {
return ret; return ret;
} }
// TODO: load macbans on server start and query it on demand. This query should not be run on every login!
@Deprecated
public boolean hasBannedMac() { public boolean hasBannedMac() {
if (macs.isEmpty()) { if (macs.isEmpty()) {
return false; return false;
@@ -377,6 +406,7 @@ public class Client extends ChannelInboundHandlerAdapter {
} }
// TODO: Recode to close statements... // TODO: Recode to close statements...
// Only used from ban command.
private void loadMacsIfNescessary() throws SQLException { private void loadMacsIfNescessary() throws SQLException {
if (macs.isEmpty()) { if (macs.isEmpty()) {
try (Connection con = DatabaseConnection.getConnection(); try (Connection con = DatabaseConnection.getConnection();
@@ -493,15 +523,21 @@ public class Client extends ChannelInboundHandlerAdapter {
return false; return false;
} }
public int login(String login, String pwd, Hwid hwid) { public boolean tryLogin() {
int loginok = 5;
if (++loginattempt >= MAX_FAILED_LOGIN_ATTEMPTS) { if (++loginattempt >= MAX_FAILED_LOGIN_ATTEMPTS) {
loggedIn = false; loggedIn = false;
SessionCoordinator.getInstance().closeSession(this, false); SessionCoordinator.getInstance().closeSession(this, false);
return 6; // thanks Survival_Project for finding out an issue with AUTOMATIC_REGISTER here return false;
} }
return true;
}
// TODO: load account outside in LoginPasswordHandler (from service).
//
public int login(String login, String pwd, Hwid hwid) {
int loginok = 5;
try (Connection con = DatabaseConnection.getConnection(); try (Connection con = DatabaseConnection.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT id, password, gender, banned, pin, pic, characterslots, tos FROM accounts WHERE name = ?")) { PreparedStatement ps = con.prepareStatement("SELECT id, password, gender, banned, pin, pic, characterslots, tos FROM accounts WHERE name = ?")) {
ps.setString(1, login); ps.setString(1, login);
@@ -576,6 +612,8 @@ public class Client extends ChannelInboundHandlerAdapter {
} }
} }
// TODO: check tempban directly on loaded account
@Deprecated
public Calendar getTempBanCalendarFromDB() { public Calendar getTempBanCalendarFromDB() {
final Calendar lTempban = Calendar.getInstance(); final Calendar lTempban = Calendar.getInstance();
@@ -675,6 +713,7 @@ public class Client extends ChannelInboundHandlerAdapter {
} }
} }
// TODO: move to LoginPasswordHandler
public int getLoginState() { // 0 = LOGIN_NOTLOGGEDIN, 1= LOGIN_SERVER_TRANSITION, 2 = LOGIN_LOGGEDIN public int getLoginState() { // 0 = LOGIN_NOTLOGGEDIN, 1= LOGIN_SERVER_TRANSITION, 2 = LOGIN_LOGGEDIN
try (Connection con = DatabaseConnection.getConnection()) { try (Connection con = DatabaseConnection.getConnection()) {
int state; int state;
@@ -952,6 +991,7 @@ public class Client extends ChannelInboundHandlerAdapter {
public void setGender(byte m) { public void setGender(byte m) {
this.gender = m; this.gender = m;
// TODO: move to AccountService
try (Connection con = DatabaseConnection.getConnection(); try (Connection con = DatabaseConnection.getConnection();
PreparedStatement ps = con.prepareStatement("UPDATE accounts SET gender = ? WHERE id = ?")) { PreparedStatement ps = con.prepareStatement("UPDATE accounts SET gender = ? WHERE id = ?")) {
ps.setByte(1, gender); ps.setByte(1, gender);

View File

@@ -4,11 +4,18 @@ import lombok.Builder;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Objects;
/** /**
* @author Ponk * @author Ponk
*/ */
@Builder @Builder
public record Account(int id, String name, String password, boolean acceptedTos, byte gender, LocalDate birthdate, public record Account(int id, String name, String password, boolean acceptedTos, Byte gender, LocalDate birthdate,
String pin, String pic, int chrSlots, int loggedIn, LocalDateTime lastLogin, boolean banned) { String pin, String pic, byte chrSlots, byte loginState, LocalDateTime lastLogin, boolean banned,
LocalDateTime tempBanTimestamp) {
public Account {
Objects.requireNonNull(name);
Objects.requireNonNull(password);
Objects.requireNonNull(birthdate);
}
} }

View File

@@ -15,9 +15,24 @@ public class AccountRepository {
this.connection = connection; this.connection = connection;
} }
public Optional<Account> findByNameIgnoreCase(String name) {
String sql = """
SELECT id, name, password, pin, pic, birthdate, gender, tos_accepted, chr_slots, login_state,
last_login, banned, temp_ban_timestamp
FROM account
WHERE lower(name) = lower(:name)""";
try (Handle handle = connection.getHandle()) {
return handle.createQuery(sql)
.bind("name", name)
.mapTo(Account.class)
.findOne();
}
}
public Optional<Account> findById(int accountId) { public Optional<Account> findById(int accountId) {
String sql = """ String sql = """
SELECT id, name, password, pin, pic, logged_in, last_login, birthdate, banned, gender, tos_accepted SELECT id, name, password, pin, pic, birthdate, gender, tos_accepted, chr_slots, login_state,
last_login, banned, temp_ban_timestamp
FROM account FROM account
WHERE id = :id"""; WHERE id = :id""";
try (Handle handle = connection.getHandle()) { try (Handle handle = connection.getHandle()) {
@@ -30,13 +45,15 @@ public class AccountRepository {
public Integer insert(Account account) { public Integer insert(Account account) {
String sql = """ String sql = """
INSERT INTO account (name, password, birthdate) INSERT INTO account (name, password, birthdate, chr_slots, login_state)
VALUES (:name, :password, :birthdate)"""; VALUES (:name, :password, :birthdate, :chrSlots, :loginState)""";
try (Handle handle = connection.getHandle()) { try (Handle handle = connection.getHandle()) {
return handle.createUpdate(sql) return handle.createUpdate(sql)
.bind("name", account.name()) .bind("name", account.name())
.bind("password", account.password()) .bind("password", account.password())
.bind("birthdate", account.birthdate()) .bind("birthdate", account.birthdate())
.bind("chrSlots", account.chrSlots())
.bind("loginState", account.loginState())
.executeAndReturnGeneratedKeys("id") .executeAndReturnGeneratedKeys("id")
.mapTo(Integer.class) .mapTo(Integer.class)
.one(); .one();

View File

@@ -21,14 +21,20 @@ public class AccountRowMapper implements RowMapper<Account> {
.password(rs.getString("password")) .password(rs.getString("password"))
.pin(rs.getString("pin")) .pin(rs.getString("pin"))
.pic(rs.getString("pic")) .pic(rs.getString("pic"))
.loggedIn(rs.getInt("logged_in")) .birthdate(rs.getDate("birthdate").toLocalDate())
.gender(Optional.ofNullable(rs.getObject("gender", Short.class))
.map(Short::byteValue)
.orElse(null))
.acceptedTos(rs.getBoolean("tos_accepted"))
.chrSlots(rs.getByte("chr_slots"))
.loginState(rs.getByte("login_state"))
.lastLogin(Optional.ofNullable(rs.getTimestamp("last_login")) .lastLogin(Optional.ofNullable(rs.getTimestamp("last_login"))
.map(Timestamp::toLocalDateTime) .map(Timestamp::toLocalDateTime)
.orElse(null)) .orElse(null))
.birthdate(rs.getDate("birthdate").toLocalDate())
.banned(rs.getBoolean("banned")) .banned(rs.getBoolean("banned"))
.gender(rs.getByte("gender")) .tempBanTimestamp(Optional.ofNullable(rs.getTimestamp("temp_ban_timestamp"))
.acceptedTos(rs.getBoolean("tos_accepted")) .map(Timestamp::toLocalDateTime)
.orElse(null))
.build(); .build();
} }
} }

View File

@@ -26,10 +26,12 @@ import client.Client;
import client.DefaultDates; import client.DefaultDates;
import config.YamlConfig; import config.YamlConfig;
import constants.game.GameConstants; import constants.game.GameConstants;
import database.account.Account;
import net.PacketHandler; import net.PacketHandler;
import net.packet.InPacket; import net.packet.InPacket;
import net.server.Server; import net.server.Server;
import net.server.coordinator.session.Hwid; import net.server.coordinator.session.Hwid;
import net.server.coordinator.session.SessionCoordinator;
import net.server.world.World; import net.server.world.World;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -46,6 +48,7 @@ import java.sql.PreparedStatement;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.util.Calendar; import java.util.Calendar;
import java.util.Optional;
public final class LoginPasswordHandler implements PacketHandler { public final class LoginPasswordHandler implements PacketHandler {
private static final Logger log = LoggerFactory.getLogger(LoginPasswordHandler.class); private static final Logger log = LoggerFactory.getLogger(LoginPasswordHandler.class);
@@ -64,7 +67,7 @@ public final class LoginPasswordHandler implements PacketHandler {
} }
@Override @Override
public final void handlePacket(InPacket p, Client c) { public void handlePacket(InPacket p, Client c) {
String remoteHost = c.getRemoteAddress(); String remoteHost = c.getRemoteAddress();
if (remoteHost.contentEquals("null")) { if (remoteHost.contentEquals("null")) {
c.sendPacket(PacketCreator.getLoginFailed(14)); // thanks Alchemist for noting remoteHost could be null c.sendPacket(PacketCreator.getLoginFailed(14)); // thanks Alchemist for noting remoteHost could be null
@@ -73,51 +76,94 @@ public final class LoginPasswordHandler implements PacketHandler {
String login = p.readString(); String login = p.readString();
String pwd = p.readString(); String pwd = p.readString();
c.setAccountName(login);
p.skip(6); // localhost masked the initial part with zeroes... p.skip(6); // localhost masked the initial part with zeroes...
byte[] hwidNibbles = p.readBytes(4); byte[] hwidNibbles = p.readBytes(4);
Hwid hwid = new Hwid(HexTool.toCompactHexString(hwidNibbles)); c.setHwid(new Hwid(HexTool.toCompactHexString(hwidNibbles)));
int loginok = c.login(login, pwd, hwid);
if (!c.tryLogin()) {
if (YamlConfig.config.server.AUTOMATIC_REGISTER && loginok == 5) { return;
try { }
int accountId = createAccountPostgres(login, pwd); Optional<Account> foundAccount = accountService.getAccount(login);
createAccountMysql(accountId, login, pwd); if (foundAccount.isEmpty()) {
c.setAccID(accountId); if (YamlConfig.config.server.AUTOMATIC_REGISTER) {
} finally { Account newAccount = createAccount(login, pwd);
loginok = c.login(login, pwd, hwid); foundAccount = Optional.of(newAccount);
} else {
c.sendPacket(PacketCreator.getLoginFailed(5));
return;
} }
} }
if (c.hasBannedIP() || c.hasBannedMac()) { Account account = foundAccount.get();
if (account.banned()) {
c.sendPacket(PacketCreator.getLoginFailed(3));
// TODO: send ban reason instead of login failed, something like this:
// c.sendPacket(PacketCreator.getPermBan(c.getGReason()));
return;
}
if (!correctPassword(pwd, account)) {
c.sendPacket(PacketCreator.getLoginFailed(4));
return;
}
c.setAccount(account);
if (c.getLoginState() > Client.LOGIN_NOTLOGGEDIN) {
c.sendPacket(PacketCreator.getLoginFailed(7));
return;
}
if (!account.acceptedTos()) {
c.sendPacket(PacketCreator.getLoginFailed(23));
return;
}
boolean banCheckDisabled = false;
if (!banCheckDisabled && (c.hasBannedIP() || c.hasBannedMac())) {
c.sendPacket(PacketCreator.getLoginFailed(3)); c.sendPacket(PacketCreator.getLoginFailed(3));
return; return;
} }
Calendar tempban = c.getTempBanCalendarFromDB();
if (tempban != null) { /* TODO: check temp ban from account, something like this:
LocalDateTime tempBan = account.tempBanTimestamp();
if (tempBan != null && tempBan.isAfter(LocalDateTime.now())) {
Duration remainingTempBan = Duration.between(LocalDateTime.now(), tempBan);
c.sendPacket(PacketCreator.getTempBan());
}
*/
boolean tempBanDisabled = false;
Calendar tempban = null;
if (!tempBanDisabled && (tempban = c.getTempBanCalendarFromDB()) != null) {
if (tempban.getTimeInMillis() > Calendar.getInstance().getTimeInMillis()) { if (tempban.getTimeInMillis() > Calendar.getInstance().getTimeInMillis()) {
c.sendPacket(PacketCreator.getTempBan(tempban.getTimeInMillis(), c.getGReason())); c.sendPacket(PacketCreator.getTempBan(tempban.getTimeInMillis(), c.getGReason()));
return; return;
} }
} }
if (loginok == 3) {
c.sendPacket(PacketCreator.getPermBan(c.getGReason()));//crashes but idc :D Integer failureCode = checkMultiClient(c);
return; if (failureCode != null) {
} else if (loginok != 0) { c.sendPacket(PacketCreator.getLoginFailed(failureCode));
c.sendPacket(PacketCreator.getLoginFailed(loginok));
return; return;
} }
if (c.finishLogin()) {
checkChar(c); if (!c.finishLogin()) {
login(c);
} else {
c.sendPacket(PacketCreator.getLoginFailed(7)); c.sendPacket(PacketCreator.getLoginFailed(7));
} }
checkChar(c);
c.sendPacket(PacketCreator.getAuthSuccess(c));
Server.getInstance().registerLoginState(c);
} }
private int createAccountPostgres(String name, String password) { private Account createAccount(String name, String password) {
Account account = createAccountPostgres(name, password);
createAccountMysql(account.id(), name, password);
return account;
}
private Account createAccountPostgres(String name, String password) {
return accountService.createNew(name, password); return accountService.createNew(name, password);
} }
@@ -126,7 +172,7 @@ public final class LoginPasswordHandler implements PacketHandler {
PreparedStatement ps = con.prepareStatement("INSERT INTO accounts (id, name, password, birthday, tempban) VALUES (?, ?, ?, ?, ?);")) { PreparedStatement ps = con.prepareStatement("INSERT INTO accounts (id, name, password, birthday, tempban) VALUES (?, ?, ?, ?, ?);")) {
ps.setInt(1, id); ps.setInt(1, id);
ps.setString(2, name); ps.setString(2, name);
ps.setString(3, BCrypt.hashpw(password, BCrypt.gensalt(12))); ps.setString(3, BCrypt.hashpw(password, BCrypt.gensalt()));
ps.setDate(4, Date.valueOf(DefaultDates.getBirthday())); ps.setDate(4, Date.valueOf(DefaultDates.getBirthday()));
ps.setTimestamp(5, Timestamp.valueOf(DefaultDates.getTempban())); ps.setTimestamp(5, Timestamp.valueOf(DefaultDates.getTempban()));
ps.executeUpdate(); ps.executeUpdate();
@@ -135,6 +181,24 @@ public final class LoginPasswordHandler implements PacketHandler {
} }
} }
private boolean correctPassword(String input, Account account) {
return BCrypt.checkpw(input, account.password());
}
private Integer checkMultiClient(Client c) {
SessionCoordinator.AntiMulticlientResult res = SessionCoordinator.getInstance()
.attemptLoginSession(c, c.getHwid(), c.getAccID(), false);
return switch (res) {
case SUCCESS -> null;
case REMOTE_LOGGEDIN -> 17;
case REMOTE_REACHED_LIMIT -> 13;
case REMOTE_PROCESSING -> 10;
case MANY_ACCOUNT_ATTEMPTS -> 16;
default -> 8;
};
}
private void checkChar(Client c) { // issue with multiple chars from same account login found by shavit, resinate private void checkChar(Client c) { // issue with multiple chars from same account login found by shavit, resinate
if (!YamlConfig.config.server.USE_CHARACTER_ACCOUNT_CHECK) { if (!YamlConfig.config.server.USE_CHARACTER_ACCOUNT_CHECK) {
return; return;
@@ -151,9 +215,4 @@ public final class LoginPasswordHandler implements PacketHandler {
} }
} }
} }
private static void login(Client c) {
c.sendPacket(PacketCreator.getAuthSuccess(c));//why the fk did I do c.getAccountName()?
Server.getInstance().registerLoginState(c);
}
} }

View File

@@ -35,7 +35,7 @@ import tools.PacketCreator;
public class SetGenderHandler extends AbstractPacketHandler { public class SetGenderHandler extends AbstractPacketHandler {
@Override @Override
public void handlePacket(InPacket p, Client c) { public void handlePacket(InPacket p, Client c) {
if (c.getGender() == 10) { //Packet shouldn't come if Gender isn't 10. if (c.getGender() == Client.NO_GENDER) { //Packet shouldn't come if Gender isn't 10.
byte confirmed = p.readByte(); byte confirmed = p.readByte();
if (confirmed == 0x01) { if (confirmed == 0x01) {
c.setGender(p.readByte()); c.setGender(p.readByte());

View File

@@ -19,8 +19,8 @@ import java.util.Optional;
*/ */
@Slf4j @Slf4j
public class AccountService { public class AccountService {
private static final int PASSWORD_HASH_SALT_LOG_ROUNDS = 12;
private static final LocalDate GMS_RELEASE = LocalDate.of(2005, 5, 11); private static final LocalDate GMS_RELEASE = LocalDate.of(2005, 5, 11);
private static final byte INITIAL_CHR_SLOTS = 3;
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
@@ -28,27 +28,34 @@ public class AccountService {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
} }
public int createNew(String name, String password) { public Account createNew(String name, String password) {
Account newAccount = Account.builder() Account newAccount = Account.builder()
.name(name) .name(name)
.password(hashPassword(password)) .password(hashPassword(password))
.birthdate(GMS_RELEASE) .birthdate(GMS_RELEASE)
.chrSlots(INITIAL_CHR_SLOTS)
.loginState((byte) Client.LOGIN_NOTLOGGEDIN)
.gender(null)
.build(); .build();
Integer accountId; Integer accountId;
try { try {
accountId = accountRepository.insert(newAccount); accountId = accountRepository.insert(newAccount);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to create new account", e); log.error("Failed to insert new account", e);
throw new RuntimeException("Failed to create new account"); throw new RuntimeException("Failed to insert new account");
} }
return Optional.ofNullable(accountId) return getAccount(accountId)
.orElseThrow(() -> new RuntimeException("Failed to create new account - missing id")); .orElseThrow(() -> new RuntimeException("Failed to get account after insert, id: " + accountId));
} }
private String hashPassword(String password) { private String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt(PASSWORD_HASH_SALT_LOG_ROUNDS)); return BCrypt.hashpw(password, BCrypt.gensalt());
}
public Optional<Account> getAccount(String name) {
return accountRepository.findByNameIgnoreCase(name);
} }
public Optional<Account> getAccount(int accountId) { public Optional<Account> getAccount(int accountId) {

View File

@@ -5,23 +5,23 @@ CREATE TABLE account
password varchar(200) NOT NULL, password varchar(200) NOT NULL,
pin varchar(4), pin varchar(4),
pic varchar(26), pic varchar(26),
logged_in smallint DEFAULT 0 NOT NULL,
created_at timestamp DEFAULT now() NOT NULL, created_at timestamp DEFAULT now() NOT NULL,
last_login timestamp,
birthdate date NOT NULL, birthdate date NOT NULL,
banned boolean DEFAULT false NOT NULL, tos_accepted boolean DEFAULT false NOT NULL,
banreason text, gender smallint,
macs text, chr_slots smallint NOT NULL,
nx_credit integer DEFAULT 0 NOT NULL, nx_credit integer DEFAULT 0 NOT NULL,
maple_point integer DEFAULT 0 NOT NULL, maple_point integer DEFAULT 0 NOT NULL,
nx_prepaid integer DEFAULT 0 NOT NULL, nx_prepaid integer DEFAULT 0 NOT NULL,
chr_slots smallint DEFAULT 3 NOT NULL, login_state smallint NOT NULL,
gender smallint DEFAULT 10 NOT NULL, last_login timestamp,
banned boolean DEFAULT false NOT NULL,
banreason text,
temp_ban_timestamp timestamp, temp_ban_timestamp timestamp,
greason smallint, greason smallint,
tos_accepted boolean DEFAULT false NOT NULL,
ip text, ip text,
hwid text, hwid text,
macs text,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE (name) UNIQUE (name)
); );