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

View File

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