From 39ed8af8409bb9d1d364c6272fef6b509023f073 Mon Sep 17 00:00:00 2001 From: Raymond <101374892+raymonable@users.noreply.github.com> Date: Sun, 27 Jul 2025 03:06:33 -0400 Subject: [PATCH] feat: :sparkles: swap auId in JWT for individual token note: has not been tested to ensure there are no collisions, todo --- .../icu/samnyan/aqua/net/UserRegistrar.kt | 7 + .../icu/samnyan/aqua/net/components/JWT.kt | 186 +++++++++++------- .../icu/samnyan/aqua/net/db/AquaNetSession.kt | 31 +++ .../resources/db/80/V1000_53__net_session.sql | 7 + 4 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt create mode 100644 src/main/resources/db/80/V1000_53__net_session.sql diff --git a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt index 3e887c61..a8619727 100644 --- a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt +++ b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt @@ -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 diff --git a/src/main/java/icu/samnyan/aqua/net/components/JWT.kt b/src/main/java/icu/samnyan/aqua/net/components/JWT.kt index 4023e751..41d9a275 100644 --- a/src/main/java/icu/samnyan/aqua/net/components/JWT.kt +++ b/src/main/java/icu/samnyan/aqua/net/components/JWT.kt @@ -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 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 auth(token: Str, block: (AquaNetUser) -> T) = block(auth(token)) } \ No newline at end of file diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt new file mode 100644 index 00000000..86416f13 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt @@ -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 { + fun findByToken(token: String): SessionToken? + fun findByAquaNetUserAuId(auId: Long): List +} \ No newline at end of file diff --git a/src/main/resources/db/80/V1000_53__net_session.sql b/src/main/resources/db/80/V1000_53__net_session.sql new file mode 100644 index 00000000..bdc4c5f2 --- /dev/null +++ b/src/main/resources/db/80/V1000_53__net_session.sql @@ -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) +); \ No newline at end of file