feat: swap auId in JWT for individual token

note: has not been tested to ensure there are no collisions, todo
This commit is contained in:
Raymond
2025-07-27 03:06:33 -04:00
committed by Azalea
parent 82adf5c138
commit 39ed8af840
4 changed files with 155 additions and 76 deletions

View File

@@ -34,6 +34,7 @@ class UserRegistrar(
val cardService: CardService, val cardService: CardService,
val validator: AquaUserServices, val validator: AquaUserServices,
val emailProps: EmailProperties, val emailProps: EmailProperties,
val sessionRepo: SessionTokenRepo,
final val paths: PathProps final val paths: PathProps
) { ) {
val portraitPath = paths.aquaNetPortrait.path() val portraitPath = paths.aquaNetPortrait.path()
@@ -233,6 +234,12 @@ class UserRegistrar(
// Save the user // Save the user
userRepo.save(u) userRepo.save(u)
// Clear all tokens if changing password
if (key == "pwHash")
sessionRepo.deleteAll(
sessionRepo.findByAquaNetUserAuId(u.auId)
)
} }
SUCCESS SUCCESS

View File

@@ -1,77 +1,111 @@
package icu.samnyan.aqua.net.components package icu.samnyan.aqua.net.components
import ext.Str import ext.Str
import ext.minus import ext.minus
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo import icu.samnyan.aqua.net.db.AquaNetUserRepo
import io.jsonwebtoken.JwtParser import icu.samnyan.aqua.net.db.SessionToken
import io.jsonwebtoken.Jwts import icu.samnyan.aqua.net.db.SessionTokenRepo
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.JwtParser
import jakarta.annotation.PostConstruct import io.jsonwebtoken.Jwts
import org.slf4j.LoggerFactory import io.jsonwebtoken.security.Keys
import org.springframework.boot.context.properties.ConfigurationProperties import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration import jakarta.transaction.Transactional
import org.springframework.stereotype.Service import org.slf4j.LoggerFactory
import java.util.* import org.springframework.boot.context.properties.ConfigurationProperties
import javax.crypto.SecretKey import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Service
@Configuration import java.time.Instant
@ConfigurationProperties(prefix = "aqua-net.jwt") import java.util.*
class JWTProperties { import javax.crypto.SecretKey
var secret: Str = "Open Sesame!"
} @Configuration
@ConfigurationProperties(prefix = "aqua-net.jwt")
@Service class JWTProperties {
class JWT( var secret: Str = "Open Sesame!"
val props: JWTProperties, }
val userRepo: AquaNetUserRepo
) { @Service
val log = LoggerFactory.getLogger(JWT::class.java)!! class JWT(
lateinit var key: SecretKey val props: JWTProperties,
lateinit var parser: JwtParser val userRepo: AquaNetUserRepo,
val sessionRepo: SessionTokenRepo
@PostConstruct ) {
fun onLoad() { val log = LoggerFactory.getLogger(JWT::class.java)!!
// Check secret lateinit var key: SecretKey
if (props.secret == "Open Sesame!") { lateinit var parser: JwtParser
log.warn("USING DEFAULT JWT SECRET, PLEASE SET aqua-net.jwt IN CONFIGURATION")
} @PostConstruct
fun onLoad() {
// Pad byte array to 256 bits // Check secret
var ba = props.secret.toByteArray() if (props.secret == "Open Sesame!") {
if (ba.size < 32) { log.warn("USING DEFAULT JWT SECRET, PLEASE SET aqua-net.jwt IN CONFIGURATION")
log.warn("JWT Secret is less than 256 bits, padding with 0. PLEASE USE A STRONGER SECRET!") }
ba = ByteArray(32).also { ba.copyInto(it) }
} // Pad byte array to 256 bits
var ba = props.secret.toByteArray()
// Initialize key if (ba.size < 32) {
key = Keys.hmacShaKeyFor(ba) log.warn("JWT Secret is less than 256 bits, padding with 0. PLEASE USE A STRONGER SECRET!")
ba = ByteArray(32).also { ba.copyInto(it) }
// Create parser }
parser = Jwts.parser()
.verifyWith(key) // Initialize key
.build() key = Keys.hmacShaKeyFor(ba)
log.info("JWT Service Enabled") // Create parser
} parser = Jwts.parser()
.verifyWith(key)
.build()
fun gen(user: AquaNetUser): Str = Jwts.builder().header()
.keyId("aqua-net") log.info("JWT Service Enabled")
.and() }
.subject(user.auId.toString())
.issuedAt(Date()) @Transactional
.signWith(key) fun gen(user: AquaNetUser): Str {
.compact() val activeTokens = sessionRepo.findByAquaNetUserAuId(user.auId)
.sortedByDescending { it.expiry }.drop(4) // the cap is 5, but we append a new token after the fact
fun parse(token: Str): AquaNetUser? = try { if (activeTokens.isNotEmpty()) {
userRepo.findByAuId(parser.parseSignedClaims(token).payload.subject.toLong()) sessionRepo.deleteAll(activeTokens)
} catch (e: Exception) { }
log.debug("Failed to parse JWT", e) val token = SessionToken().apply {
null aquaNetUser = user
} }
sessionRepo.save(token)
fun auth(token: Str) = parse(token) ?: (400 - "Invalid token")
return Jwts.builder().header()
final inline fun <T> auth(token: Str, block: (AquaNetUser) -> T) = block(auth(token)) .keyId("aqua-net")
.and()
.subject(token.token)
.issuedAt(Date())
.signWith(key)
.compact()
}
@Transactional
fun parse(token: Str): AquaNetUser? {
try {
val uuid = parser.parseSignedClaims(token).payload.subject.toString()
val token = sessionRepo.findByToken(uuid)
if (token != null) {
val toBeRemoved = sessionRepo.findByAquaNetUserAuId(token.aquaNetUser.auId)
.filter { it.expiry < Instant.now() }
if (toBeRemoved.isNotEmpty())
sessionRepo.deleteAll(toBeRemoved)
if (token.expiry < Instant.now()) {
sessionRepo.delete(token)
return null
}
}
return token?.aquaNetUser
} catch (e: Exception) {
log.debug("Failed to parse JWT", e)
return null
}
}
fun auth(token: Str) = parse(token) ?: (400 - "Invalid token")
final inline fun <T> auth(token: Str, block: (AquaNetUser) -> T) = block(auth(token))
} }

View File

@@ -0,0 +1,31 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "aqua_net_session")
class SessionToken(
@Id
@Column(nullable = false)
var token: String = UUID.randomUUID().toString(),
// Token creation time
@Column(nullable = false)
var expiry: Instant = Instant.now().plusSeconds(3 * 86400),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@Repository
interface SessionTokenRepo : JpaRepository<SessionToken, String> {
fun findByToken(token: String): SessionToken?
fun findByAquaNetUserAuId(auId: Long): List<SessionToken>
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE aqua_net_session
(
token VARCHAR(36) NOT NULL,
expiry datetime NOT NULL,
au_id BIGINT NULL,
CONSTRAINT pk_session PRIMARY KEY (token)
);