Skip to content
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repositories {

allprojects {
group = "pl.spcode.navauth"
version = "0.1.5-SNAPSHOT"
version = "0.1.6-SNAPSHOT"
}

tasks.register("formatAll") {
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/configuration/multification.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ Multification library was created by [**EternalCode**](https://github.com/Eterna

> Multification makes it simple to create customizable notifications and messages within large plugins that require handling a high volume of messages

## With Multification, you have granular control over the notifications and messages sent to players.
Here's a quick example of how easy it is:
![multification-login-example.png](/multification/multification-login-example.png)
Here's the edited property from example above (messages.yml):
```yml
loginPasswordOnlyInstruction:
chat: <#2784F5>ᴇᴋɪᴘᴀ.ɢɢ</#2784F5> <gray>×</gray> <white>Zaloguj się za pomocą
/login <hasło>
title: <#2784F5>ᴇᴋɪᴘᴀ.ɢɢ</#2784F5>
subtitle: <white>Zaloguj się
bossbar:
message: <#2784F5>ᴇᴋɪᴘᴀ.ɢɢ</#2784F5> <gray>×</gray> <white>Oczekiwanie na logowanie...
duration: 3s
color: BLUE
progress: '1.0'
```

### 📝 Example usage (copy of original lib docs)

::: tip
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
import java.lang.invoke.MethodHandles;
import java.lang.ref.WeakReference;
import java.lang.reflect.Method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class MethodInvocation implements Invocation, Comparable<MethodInvocation> {

private static final Logger logger = LoggerFactory.getLogger(MethodInvocation.class);

private final MethodHandle methodHandle;
private final WeakReference<Object> targetRef;
private final int priority;
Expand All @@ -41,7 +46,7 @@ public void invoke(Object event) {
try {
methodHandle.invoke(target, event);
} catch (Throwable t) {
t.printStackTrace(); // Production: Use logger/SLF4J
logger.error("Error occurred while trying to invoke MethodInvocation", t);
}
}

Expand All @@ -52,6 +57,6 @@ public Object getTarget() {

@Override
public int compareTo(MethodInvocation o) {
return Integer.compare(o.priority, priority); // Desc: higher first
return Integer.compare(o.priority, priority);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import pl.spcode.navauth.common.config.SessionsConfig
import pl.spcode.navauth.common.domain.common.IPAddress
import pl.spcode.navauth.common.domain.player.PlayerAdapter
import pl.spcode.navauth.common.domain.user.User
Expand All @@ -36,7 +37,10 @@ import pl.spcode.navauth.common.infra.persistence.Paginator
@Singleton
class UserActivitySessionService
@Inject
constructor(private val userActivitySessionRepository: UserActivitySessionRepository) {
constructor(
private val userActivitySessionRepository: UserActivitySessionRepository,
private val sessionsConfig: SessionsConfig,
) {

private val logger: Logger = LoggerFactory.getLogger(UserActivitySessionService::class.java)
private val playerJoinedAtMap = ConcurrentHashMap<UUID, PlayerSessionData>()
Expand All @@ -45,18 +49,31 @@ constructor(private val userActivitySessionRepository: UserActivitySessionReposi
private data class PlayerSessionData(val joinedAt: Date, val ip: IPAddress)

fun registerPlayerJoin(playerAdapter: PlayerAdapter) {
if (!sessionsConfig.userActivitySessionsEnabled) {
return
}
playerJoinedAtMap[playerAdapter.getUuid().value] =
PlayerSessionData(Date(), playerAdapter.getIPAddress())
}

fun storePlayerSessionOnLeave(playerAdapter: PlayerAdapter) {
val data = playerJoinedAtMap.get(playerAdapter.getUuid().value)
val data = playerJoinedAtMap[playerAdapter.getUuid().value]
if (data == null) {
logger.debug("player with UUID {} session was not registered", playerAdapter.getUuid())
if (sessionsConfig.userActivitySessionsEnabled) {
logger.debug("player with UUID {} session was not registered", playerAdapter.getUuid())
}
return
}
val leftAt = Date()

if ((leftAt.time - data.joinedAt.time) < sessionsConfig.sessionMinimumTimeMs) {
logger.debug(
"player with UUID {} session was not registered, session minimum time not fulfilled",
playerAdapter.getUuid(),
)
return
}

val session =
UserActivitySession.create(
playerAdapter.getUuid(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,7 @@ constructor(
userRepository.save(user)
}

fun deleteUserCredentials(user: User): User {
return txService.inTransaction {
return@inTransaction deleteUserCredentialsNoTx(user)
}
}

private fun deleteUserCredentialsNoTx(user: User): User {
private fun deleteUserCredentialsUpdateUserNoTx(user: User): User {
val user = user.withCredentialsRequired(false)
userRepository.save(user)
userCredentialsService.deleteUserCredentials(user)
Expand All @@ -111,12 +105,15 @@ constructor(
val requireCredentials = credentials.isTwoFactorEnabled
val premiumUser = User.premium(user.uuid, user.username, mojangId, requireCredentials)

userRepository.save(premiumUser)
val status = userRepository.save(premiumUser)
status.isCreated
status.isUpdated
status.numLinesChanged
if (requireCredentials) {
val newCredentials = credentials.withoutPassword()
userCredentialsService.storeUserCredentials(premiumUser, newCredentials)
} else {
deleteUserCredentialsNoTx(user)
userCredentialsService.deleteUserCredentials(premiumUser)
}

return@inTransaction premiumUser
Expand Down Expand Up @@ -257,7 +254,7 @@ constructor(
val newCredentials = credentials.withoutTotpSecret()
userCredentialsService.storeUserCredentials(user, newCredentials)
} else {
deleteUserCredentialsNoTx(user)
deleteUserCredentialsUpdateUserNoTx(user)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,42 @@ open class GeneralConfig : OkaeriConfig() {
@Comment("Maximum time of registration.")
var maxRegistrationDuration: Duration = Duration.ofSeconds(30)

@Comment("Interval between each register instruction multification.")
var intervalBetweenRegisterMultification: Duration = Duration.ofSeconds(3)

@Comment("Maximum time of login.") var maxLoginDuration: Duration = Duration.ofSeconds(20)

@Comment("Interval between each login instruction multification.")
var intervalBetweenLoginMultification: Duration = Duration.ofSeconds(3)

@Comment("Delay before sending multification on a successful premium authentication.")
var premiumAuthMultificationDelay: Duration = Duration.ofSeconds(1)

@Comment("Usernames config") var usernamesConfig: UsernamesConfig = UsernamesConfig()

@Comment("Two-factor (2FA) TOTP authentication config")
var twoFactorAuthConfig: TwoFactorAuthConfig = TwoFactorAuthConfig()

@Comment("Passwords config") var passwordsConfig: PasswordsConfig = PasswordsConfig()

@Comment(
"When a non-premium user tries to join with a premium nickname,",
"an 'invalid session' error occurs. This is default Minecraft behavior.",
"Should we cache premium connections and send a more descriptive message",
"when they try to join again after getting disconnected right after the preLogin event?",
"",
"We cache player uuid, IP and protocol version.",
)
var descriptiveInvalidSessionEnabled = true

@Comment(
"Max time (milliseconds) to consider a reconnect as an invalid session connection.",
"(You can set this a little higher than the reconnect throttle from other plugins.)",
)
var descriptiveInvalidSessionCacheTimeMs = 8000

var sessionsConfig = SessionsConfig()

@Variable("CONFIG_VERSION")
@Comment("Config version. DO NOT CHANGE this property!")
var configVersion: Int = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ open class MessagesConfig : OkaeriConfig() {
var yourAccountDataHasBeenMigrated =
TextComponent("<green>Your account data has been migrated to '%USERNAME%'.")

val possibleInvalidSessionReconnectError =
TextComponent(
"<red>You've tried to log in to a premium account '%USERNAME%' one or more times. " +
"<br>This might be an invalid session error, caused by one of the following:" +
"<br> 1. An outdated session. If you own the account, restart your client." +
"<br> 2. You don't own the premium account. In that case, use a non-premium username."
)

@Comment(
"Notifications which use multification library.",
"Here you can use chat messages, action bars, sounds etc. combined.",
Expand Down Expand Up @@ -191,6 +199,11 @@ open class MessagesConfig : OkaeriConfig() {
)
var adminCmdUserPremiumMigrationSuccess: Notice =
Notice.chat("<green>User '%USERNAME%' successfully migrated to premium mode.")

var adminCmdReloadingConfig: Notice = Notice.chat("<green>Reloading configuration...")
var adminCmdConfigReloadSuccess: Notice = Notice.chat("<green>Config %NAME% has been reloaded.")
var adminCmdConfigReloadError: Notice =
Notice.chat("<red>Failed to reload %NAME% configuration! Error: %CAUSE%")
}

@Variable("CONFIG_VERSION")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* NavAuth
* Copyright © 2026 Oliwier Fijas (Navio1430)
*
* NavAuth is free software; You can redistribute it and/or modify it under the terms of:
* the GNU Affero General Public License version 3 as published by the Free Software Foundation.
*
* NavAuth is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with NavAuth. If not, see <https://www.gnu.org/licenses/>
* and navigate to version 3 of the GNU Affero General Public License.
*
*/

package pl.spcode.navauth.common.config

import eu.okaeri.configs.OkaeriConfig
import eu.okaeri.configs.annotation.Comment

class SessionsConfig : OkaeriConfig() {

@Comment(
"Should we store player sessions?",
"https://navio1430.github.io/NavAuth/docs/general/user-lookup.html#lookup-user-sessions",
"Make sure you've included information about this function in your Privacy Policy.",
"We store join at, left at times and IP's.",
)
var userActivitySessionsEnabled = false

@Comment("Minimum time of milliseconds required to save a player activity session.")
var sessionMinimumTimeMs = 30000
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* NavAuth
* Copyright © 2026 Oliwier Fijas (Navio1430)
*
* NavAuth is free software; You can redistribute it and/or modify it under the terms of:
* the GNU Affero General Public License version 3 as published by the Free Software Foundation.
*
* NavAuth is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with NavAuth. If not, see <https://www.gnu.org/licenses/>
* and navigate to version 3 of the GNU Affero General Public License.
*
*/

package pl.spcode.navauth.velocity.application.auth

import com.google.common.cache.CacheBuilder
import com.google.inject.Inject
import com.google.inject.Singleton
import com.velocitypowered.api.proxy.InboundConnection
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration
import pl.spcode.navauth.common.config.GeneralConfig
import pl.spcode.navauth.common.domain.common.IPAddress

/**
* Service used to make the invalid session error more descriptive, which is a significant UX
* improvement.
*/
@Singleton
class InvalidSessionCacheService @Inject constructor(val generalConfig: GeneralConfig) {

private val cache =
CacheBuilder.newBuilder()
.expireAfterWrite(
generalConfig.descriptiveInvalidSessionCacheTimeMs.milliseconds.toJavaDuration()
)
.build<String, ConnectionCache>()

fun cachePremiumConnection(username: String, connection: InboundConnection) {
val ip = IPAddress.fromInetAddress(connection.remoteAddress.address)
val obj = ConnectionCache(username, ip, connection.protocolVersion.protocol)
cache.put(username, obj)
}

fun markAsEncryptedPremiumConnection(username: String) {
cache.getIfPresent(username)?.passedEncryption = true
}

fun isInvalidSessionReconnect(username: String, connection: InboundConnection): Boolean {
val cached = cache.getIfPresent(username) ?: return false
if (!cached.passedEncryption) {
val ip = IPAddress.fromInetAddress(connection.remoteAddress.address)
if (
cached.ip.compareTo(ip) == 0 &&
cached.protocolVersion == connection.protocolVersion.protocol
) {
return true
}
}
return false
}

fun invalidate(username: String) {
cache.invalidate(username)
}

/** @param passedEncryption DO NOT USE THIS FOR SECURITY!!! */
data class ConnectionCache(
val username: String,
val ip: IPAddress,
val protocolVersion: Int,
var passedEncryption: Boolean = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ constructor(
uniqueSessionId: VelocityUniqueSessionId,
): VelocityAutoLoginAuthSession {
val session =
VelocityAutoLoginAuthSession(player, scheduler, multification, messagesConfig, eventBus)
VelocityAutoLoginAuthSession(
player,
scheduler,
multification,
messagesConfig,
generalConfig,
eventBus,
)
return authSessionService.registerSession(uniqueSessionId, session)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import pl.spcode.navauth.velocity.command.admin.ForceCrackedAdminCommand
import pl.spcode.navauth.velocity.command.admin.ForcePremiumAdminCommand
import pl.spcode.navauth.velocity.command.admin.MigrateUserDataAdminCommand
import pl.spcode.navauth.velocity.command.admin.PlayerLookupAdminCommand
import pl.spcode.navauth.velocity.command.admin.ReloadConfigAdminCommand
import pl.spcode.navauth.velocity.command.root.MigrationRootCommand
import pl.spcode.navauth.velocity.command.user.ChangePasswordCommand
import pl.spcode.navauth.velocity.command.user.LoginCommand
Expand All @@ -50,6 +51,7 @@ class CommandsRegistry {
ForcePremiumAdminCommand::class,
MigrateUserDataAdminCommand::class,
PlayerLookupAdminCommand::class,
ReloadConfigAdminCommand::class,
// root (console only)
MigrationRootCommand::class,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ class Permissions {
const val ADMIN_FORCE_CRACKED = "$ADMIN_BASE.forcecracked"
const val ADMIN_FORCE_PREMIUM = "$ADMIN_BASE.forcepremium"
const val ADMIN_MIGRATE_USER_DATA = "$ADMIN_BASE.migrateuserdata"
const val ADMIN_RELOAD = "$ADMIN_BASE.reload"
}
}
Loading
Loading