From 2d292bf235666d52cbb494b30e6cc678c8061ce1 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 28 Aug 2023 18:29:17 -0500 Subject: [PATCH] [WIP] Working on removing the DatabaseManager usage within the security API --- .../org/dreamexposure/discal/cam/Cam.kt | 7 - .../cam/business/cronjob/SessionCronJob.kt | 32 +++ .../discal/cam/endpoints/v1/GetEndpoint.kt | 10 +- .../v1/oauth2/DiscordOauthEndpoint.kt | 43 ++-- .../discal/cam/google/GoogleAuth.kt | 24 ++- .../cam/google/GoogleInternalAuthHandler.kt | 120 ----------- .../discal/cam/service/SessionService.kt | 25 --- .../discal/cam/service/StateService.kt | 5 +- .../discal/core/business/CredentialService.kt | 31 +++ .../discal/core/business/SessionService.kt | 72 +++++++ .../discal/core/config/CacheConfig.kt | 19 ++ .../discal/core/config/Config.kt | 1 + .../discal/core/database/CredentialData.kt | 11 + .../core/database/CredentialsRepository.kt | 11 + .../discal/core/database/DatabaseManager.kt | 197 ------------------ .../discal/core/database/SessionData.kt | 13 ++ .../discal/core/database/SessionRepository.kt | 18 ++ .../entities/google/DisCalGoogleCredential.kt | 14 +- .../discal/core/object/WebSession.kt | 11 +- .../discal/core/object/new/Credential.kt | 18 ++ .../discal/core/spring/SecurityWebFilter.kt | 11 +- .../org/dreamexposure/discal/typealiases.kt | 4 + 22 files changed, 297 insertions(+), 400 deletions(-) create mode 100644 cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt delete mode 100644 cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt delete mode 100644 cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt index 5791e6a3a..b969ec3a3 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt @@ -2,7 +2,6 @@ package org.dreamexposure.discal.cam import jakarta.annotation.PreDestroy import org.dreamexposure.discal.Application -import org.dreamexposure.discal.cam.google.GoogleInternalAuthHandler import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.logger.LOGGER @@ -25,12 +24,6 @@ class Cam { fun main(args: Array) { Config.init() - //Handle generating new google auth credentials for discal accounts - if (args.size > 1 && args[0].equals("-forceNewGoogleAuth", true)) { - //This will automatically kill this instance once finished - GoogleInternalAuthHandler.requestCode(args[1].toInt()).subscribe() - } - //Start up spring try { SpringApplicationBuilder(Application::class.java) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt new file mode 100644 index 000000000..5ab8a82ca --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt @@ -0,0 +1,32 @@ +package org.dreamexposure.discal.cam.business.cronjob + +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.core.business.SessionService +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Duration + +@Component +class SessionCronJob( + val sessionService: SessionService, +) : ApplicationRunner { + override fun run(args: ApplicationArguments?) { + Flux.interval(Duration.ofHours(1)) + .flatMap { justDoIt() }.doOnError { + LOGGER.error(GlobalVal.DEFAULT, "Session cronjob error", it) + }.onErrorResume { + Mono.empty() + }.subscribe() + } + + private fun justDoIt() = mono { + LOGGER.debug("Running expired session purge job") + + sessionService.deleteExpiredSessions() + } +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt index f36298dba..badf3cb41 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt @@ -2,10 +2,10 @@ package org.dreamexposure.discal.cam.endpoints.v1 import discord4j.common.util.Snowflake import org.dreamexposure.discal.cam.google.GoogleAuth -import org.dreamexposure.discal.core.`object`.network.discal.CredentialData import org.dreamexposure.discal.core.annotations.Authentication import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.enums.calendar.CalendarHost +import org.dreamexposure.discal.core.`object`.network.discal.CredentialData import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -14,7 +14,9 @@ import reactor.core.publisher.Mono @RestController @RequestMapping("/v1/") -class GetEndpoint { +class GetEndpoint( + private val googleAuth: GoogleAuth, +) { @Authentication(access = Authentication.AccessLevel.ADMIN) @GetMapping("token", produces = ["application/json"]) @@ -24,10 +26,10 @@ class GetEndpoint { CalendarHost.GOOGLE -> { if (guild == null) { // Internal (owned by DisCal, should never go bad) - GoogleAuth.requestNewAccessToken(id) + googleAuth.requestNewAccessToken(id) } else { // External (owned by user) - DatabaseManager.getCalendar(guild, id).flatMap(GoogleAuth::requestNewAccessToken) + DatabaseManager.getCalendar(guild, id).flatMap(googleAuth::requestNewAccessToken) //TODO: Replace this } } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt index d49ee92a4..2ddd428f5 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt @@ -1,20 +1,20 @@ package org.dreamexposure.discal.cam.endpoints.v1.oauth2 +import kotlinx.coroutines.reactor.awaitSingle import org.dreamexposure.discal.cam.discord.DiscordOauthHandler import org.dreamexposure.discal.cam.json.discal.LoginResponse import org.dreamexposure.discal.cam.json.discal.TokenRequest import org.dreamexposure.discal.cam.json.discal.TokenResponse import org.dreamexposure.discal.cam.service.StateService import org.dreamexposure.discal.core.annotations.Authentication +import org.dreamexposure.discal.core.business.SessionService import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.crypto.KeyGenerator -import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.`object`.WebSession import org.dreamexposure.discal.core.utils.GlobalVal.discordApiUrl import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException -import reactor.core.publisher.Mono import java.net.URLEncoder import java.nio.charset.Charset.defaultCharset @@ -22,6 +22,7 @@ import java.nio.charset.Charset.defaultCharset @RequestMapping("/oauth2/discord/") class DiscordOauthEndpoint( private val stateService: StateService, + private val sessionService: SessionService, private val discordOauthHandler: DiscordOauthHandler, ) { private val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() @@ -33,45 +34,41 @@ class DiscordOauthEndpoint( @GetMapping("login") @Authentication(access = Authentication.AccessLevel.PUBLIC) - fun login(): Mono { + fun login(): LoginResponse { val state = stateService.generateState() val link = "$oauthLinkWithoutState&state=$state" - return Mono.just(LoginResponse(link)) + return LoginResponse(link) } @GetMapping("logout") @Authentication(access = Authentication.AccessLevel.WRITE) - fun logout(@RequestHeader("Authorization") token: String): Mono { - return DatabaseManager.deleteSession(token).then() + suspend fun logout(@RequestHeader("Authorization") token: String) { + sessionService.deleteSession(token) } @PostMapping("code") @Authentication(access = Authentication.AccessLevel.PUBLIC) - fun token(@RequestBody body: TokenRequest): Mono { + suspend fun token(@RequestBody body: TokenRequest): TokenResponse { // Validate state if (!stateService.validateState(body.state)) { // State invalid - 400 - return Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid state")) + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid state") } - return discordOauthHandler.doTokenExchange(body.code).flatMap { dTokens -> - // request current user info - discordOauthHandler.getOauthInfo(dTokens.accessToken).flatMap { authInfo -> - val apiToken = KeyGenerator.csRandomAlphaNumericString(64) + val dTokens = discordOauthHandler.doTokenExchange(body.code).awaitSingle() + val authInfo = discordOauthHandler.getOauthInfo(dTokens.accessToken).awaitSingle() + val apiToken = KeyGenerator.csRandomAlphaNumericString(64) + val session = WebSession( + apiToken, + authInfo.user!!.id, + accessToken = dTokens.accessToken, + refreshToken = dTokens.refreshToken + ) - val session = WebSession( - apiToken, - authInfo.user!!.id, - accessToken = dTokens.accessToken, - refreshToken = dTokens.refreshToken - ) + sessionService.removeAndInsertSession(session) - // Save session data then return response - DatabaseManager.removeAndInsertSessionData(session) - .thenReturn(TokenResponse(session.token, session.expiresAt, authInfo.user)) - } - } + return TokenResponse(session.token, session.expiresAt, authInfo.user) } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt index 0f70a1914..3d7b575de 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt @@ -2,10 +2,12 @@ package org.dreamexposure.discal.cam.google import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_BAD_REQUEST import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_OK +import kotlinx.coroutines.reactor.mono import okhttp3.FormBody import okhttp3.Request import org.dreamexposure.discal.cam.json.google.ErrorData import org.dreamexposure.discal.cam.json.google.RefreshData +import org.dreamexposure.discal.core.business.CredentialService import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.crypto.AESEncryption import org.dreamexposure.discal.core.database.DatabaseManager @@ -19,20 +21,24 @@ import org.dreamexposure.discal.core.`object`.network.discal.CredentialData import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT +import org.springframework.stereotype.Component import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers import java.time.Instant import kotlin.system.exitProcess -@Suppress("BlockingMethodInNonBlockingContext") -object GoogleAuth { - private val CREDENTIALS: Flux +@Component +class GoogleAuth( + private val credentialService: CredentialService, +) { + private final val CREDENTIALS: Flux init { val credCount = Config.SECRET_GOOGLE_CREDENTIAL_COUNT.getInt() + CREDENTIALS = Flux.range(0, credCount) - .flatMap(DatabaseManager::getCredentialData) + .flatMap { mono { credentialService.getCredential(it) } } .map(::DisCalGoogleCredential) .doOnError { exitProcess(1) } .cache() @@ -53,27 +59,27 @@ object GoogleAuth { aes.encrypt(data.accessToken) .doOnNext { calendarData.encryptedAccessToken = it } - .then(DatabaseManager.updateCalendar(calendarData).thenReturn(data)) + .then(DatabaseManager.updateCalendar(calendarData).thenReturn(data))//TODO: Replace this } } } fun requestNewAccessToken(credentialId: Int): Mono { return CREDENTIALS - .filter { it.credentialData.credentialNumber == credentialId } + .filter { it.credential.credentialNumber == credentialId } .next() .switchIfEmpty(Mono.error(NotFoundException())) .flatMap { credential -> if (!credential.expired()) { return@flatMap credential.getAccessToken() - .map { CredentialData(it, credential.credentialData.expiresAt) } + .map { CredentialData(it, credential.credential.expiresAt) } } credential.getRefreshToken() .flatMap(this::doAccessTokenRequest) .flatMap { credential.setAccessToken(it.accessToken).thenReturn(it) } - .doOnNext { credential.credentialData.expiresAt = it.validUntil } - .flatMap { DatabaseManager.updateCredentialData(credential.credentialData).thenReturn(it) } + .doOnNext { credential.credential.expiresAt = it.validUntil } + .flatMap { DatabaseManager.updateCredentialData(credential.credentialData).thenReturn(it) }//TODO: Replace this }.switchIfEmpty(Mono.error(EmptyNotAllowedException())) } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt deleted file mode 100644 index 02fd0e293..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.dreamexposure.discal.cam.google - -import org.dreamexposure.discal.core.config.Config -import org.dreamexposure.discal.core.crypto.AESEncryption -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.exceptions.EmptyNotAllowedException -import org.dreamexposure.discal.core.exceptions.google.GoogleAuthCancelException -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.google.GoogleCredentialData -import org.dreamexposure.discal.core.`object`.google.InternalGoogleAuthPoll -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.core.wrapper.google.GoogleAuthWrapper -import org.json.JSONObject -import reactor.core.publisher.Mono -import reactor.function.TupleUtils -import java.time.Instant - -@Suppress("BlockingMethodInNonBlockingContext") -object GoogleInternalAuthHandler { - fun requestCode(credNumber: Int): Mono { - return GoogleAuthWrapper.requestDeviceCode().flatMap { response -> - val responseBody = response.body!!.string() - response.body?.close() - response.close() - - if (response.code == GlobalVal.STATUS_SUCCESS) { - val codeResponse = JSONObject(responseBody) - - val url = codeResponse.getString("verification_url") - val code = codeResponse.getString("user_code") - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] DisCal Google Cred Auth $credNumber", "$url | $code") - - val poll = InternalGoogleAuthPoll( - credNumber, - interval = codeResponse.getInt("interval"), - expiresIn = codeResponse.getInt("expires_in"), - remainingSeconds = codeResponse.getInt("expires_in"), - deviceCode = codeResponse.getString("device_code"), - ) { this.pollForAuth(it as InternalGoogleAuthPoll) } - - GoogleAuthWrapper.scheduleOAuthPoll(poll) - } else { - LOGGER.debug(GlobalVal.DEFAULT, "Error request access token Status code: ${response.code} | ${response.message}" + - " | $responseBody") - - Mono.empty() - } - } - } - - private fun pollForAuth(poll: InternalGoogleAuthPoll): Mono { - return GoogleAuthWrapper.requestPollResponse(poll).flatMap { response -> - val responseBody = response.body!!.string() - response.body?.close() - response.close() - - when (response.code) { - GlobalVal.STATUS_FORBIDDEN -> { - //Handle access denied - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Access denied for credential: ${poll.credNumber}") - - Mono.error(GoogleAuthCancelException()) - } - GlobalVal.STATUS_BAD_REQUEST, GlobalVal.STATUS_PRECONDITION_REQUIRED -> { - //See if auth is pending, if so, just reschedule. - - val aprError = JSONObject(responseBody) - when { - aprError.optString("error").equals("authorization_pending", true) -> { - //Response pending - Mono.empty() - } - aprError.optString("error").equals("expired_token", true) -> { - //Token expired, auth is cancelled - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] token expired.") - - Mono.error(GoogleAuthCancelException()) - } - else -> { - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Poll Failure! Status code: ${response.code}" + - " | ${response.message} | $responseBody") - - Mono.error(GoogleAuthCancelException()) - } - } - } - GlobalVal.STATUS_RATE_LIMITED -> { - //We got rate limited... oops. Let's just poll half as often... - poll.interval = poll.interval * 2 - - Mono.empty() - } - GlobalVal.STATUS_SUCCESS -> { - //Access granted, save credentials... - val aprGrant = JSONObject(responseBody) - val aes = AESEncryption(Config.SECRET_GOOGLE_CREDENTIAL_KEY.getString()) - - val refreshMono = aes.encrypt(aprGrant.getString("refresh_token")) - val accessMono = aes.encrypt(aprGrant.getString("access_token")) - - Mono.zip(refreshMono, accessMono).flatMap(TupleUtils.function { refresh, access -> - val expiresAt = Instant.now().plusSeconds(aprGrant.getLong("expires_in")) - - val creds = GoogleCredentialData(poll.credNumber, refresh, access, expiresAt) - - DatabaseManager.updateCredentialData(creds) - .then(Mono.error(GoogleAuthCancelException())) - }).onErrorResume(EmptyNotAllowedException::class.java) { Mono.error(GoogleAuthCancelException()) } - } - else -> { - //Unknown network error... - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Network error; poll failure Status code: ${response.code} " + - "| ${response.message} | $responseBody") - - Mono.error(GoogleAuthCancelException()) - } - } - }.then() - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt deleted file mode 100644 index c4e7f9fe0..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.dreamexposure.discal.cam.service - -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.time.Duration - -@Component -class SessionService : ApplicationRunner { - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofHours(24)) - .flatMap { - DatabaseManager.deleteExpiredSessions() - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "Session Service runner error", it) - }.onErrorResume { - Mono.empty() - }.subscribe() - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt index 80c645dd9..ed5ffebb9 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt @@ -39,9 +39,6 @@ class StateService { val expiresAt = states[state] states.remove(state) // Remove state immediately to prevent replay attacks - if (expiresAt != null && expiresAt.isAfter(Instant.now())) return true - - // If state is not valid or has expired, we return false - return false + return expiresAt != null && expiresAt.isAfter(Instant.now()) } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt new file mode 100644 index 000000000..e7b421a4d --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt @@ -0,0 +1,31 @@ +package org.dreamexposure.discal.core.business + +import kotlinx.coroutines.reactor.awaitSingle +import org.dreamexposure.discal.CredentialsCache +import org.dreamexposure.discal.core.database.CredentialsRepository +import org.dreamexposure.discal.core.`object`.new.Credential +import org.springframework.stereotype.Component + + +@Component +class DefaultCredentialService( + private val credentialsRepository: CredentialsRepository, + private val credentialsCache: CredentialsCache, +) : CredentialService { + override suspend fun getCredential(number: Int): Credential? { + var credential = credentialsCache.get(number) + if (credential != null) return credential + + credential = credentialsRepository.findByCredentialNumber(number) + .map(::Credential) + .awaitSingle() + + if (credential != null) credentialsCache.put(number, credential) + return credential + } + +} + +interface CredentialService { + suspend fun getCredential(number: Int): Credential? +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt new file mode 100644 index 000000000..2bfc9e4d5 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt @@ -0,0 +1,72 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.database.SessionData +import org.dreamexposure.discal.core.database.SessionRepository +import org.dreamexposure.discal.core.`object`.WebSession +import org.springframework.stereotype.Component +import java.time.Instant + + +@Component +class DefaultSessionService( + private val sessionRepository: SessionRepository, +) : SessionService { + // TODO: I do want to add caching, but need to figure out how I want to do that + + override suspend fun createSession(session: WebSession): WebSession { + return sessionRepository.save(SessionData( + token = session.token, + userId = session.user.asLong(), + expiresAt = session.expiresAt, + accessToken = session.accessToken, + refreshToken = session.refreshToken, + )).map(::WebSession).awaitSingle() + } + + override suspend fun getSession(token: String): WebSession? { + return sessionRepository.findByToken(token) + .map(::WebSession) + .awaitSingleOrNull() + } + + override suspend fun getSessions(userId: Snowflake): List { + return sessionRepository.findAllByUserId(userId.asLong()) + .map(::WebSession) + .collectList() + .awaitSingle() + } + + override suspend fun deleteSession(token: String) { + sessionRepository.deleteByToken(token).awaitSingleOrNull() + } + + override suspend fun deleteAllSessions(userId: Snowflake) { + sessionRepository.deleteAllByUserId(userId.asLong()).awaitSingleOrNull() + } + + override suspend fun deleteExpiredSessions() { + sessionRepository.deleteAllByExpiresAtIsLessThan(Instant.now()).awaitSingleOrNull() + } +} + +interface SessionService { + suspend fun createSession(session: WebSession): WebSession + + suspend fun getSession(token: String): WebSession? + + suspend fun getSessions(userId: Snowflake): List + suspend fun deleteSession(token: String) + + suspend fun deleteAllSessions(userId: Snowflake) + + suspend fun deleteExpiredSessions() + + suspend fun removeAndInsertSession(session: WebSession): WebSession { + deleteAllSessions(session.user) + + return createSession(session) + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index 7cd1708c4..462386304 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -1,9 +1,14 @@ package org.dreamexposure.discal.core.config +import com.fasterxml.jackson.databind.ObjectMapper +import org.dreamexposure.discal.CredentialsCache +import org.dreamexposure.discal.core.cache.JdkCacheRepository +import org.dreamexposure.discal.core.cache.RedisCacheRepository import org.dreamexposure.discal.core.extensions.asMinutes import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary import org.springframework.data.redis.cache.RedisCacheConfiguration import org.springframework.data.redis.cache.RedisCacheManager import org.springframework.data.redis.connection.RedisConnectionFactory @@ -13,8 +18,10 @@ class CacheConfig { // Cache name constants private val prefix = Config.CACHE_PREFIX.getString() private val settingsCacheName = "$prefix.settingsCache" + private val credentialsCacheName = "$prefix.credentialsCache" private val settingsTtl = Config.CACHE_TTL_SETTINGS_MINUTES.getLong().asMinutes() + private val credentialsTll = Config.CACHE_TTL_CREDENTIALS_MINUTES.getLong().asMinutes() // Redis caching @@ -25,8 +32,20 @@ class CacheConfig { .withCacheConfiguration(settingsCacheName, RedisCacheConfiguration.defaultCacheConfig().entryTtl(settingsTtl) ) + .withCacheConfiguration(credentialsCacheName, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(credentialsTll) + ) .build() } + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun credentialsRedisCache(cacheManager: RedisCacheManager, objectMapper: ObjectMapper): CredentialsCache = + RedisCacheRepository(cacheManager, objectMapper, credentialsCacheName) + + // In-memory fallback caching + @Bean + fun credentialsFallbackCache(): CredentialsCache = JdkCacheRepository(settingsTtl) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index 4224a47cf..a9c2e4803 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -16,6 +16,7 @@ enum class Config(private val key: String, private var value: Any? = null) { CACHE_PREFIX("bot.cache.prefix", "discal"), CACHE_TTL_SETTINGS_MINUTES("bot.cache.ttl-minutes.settings", 60), + CACHE_TTL_CREDENTIALS_MINUTES("bot.cache.ttl-minutes.credentials", 120), CACHE_TTL_ACCOUNTS_MINUTES("bot.cache.ttl-minutes.accounts", 60), // Security configuration diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt new file mode 100644 index 000000000..e567b911e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt @@ -0,0 +1,11 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("credentials") +data class CredentialData( + val credentialNumber: Int, + val refreshToken: String, + val accessToken: String, + val expiresAt: Long, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt new file mode 100644 index 000000000..b4a4682b6 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt @@ -0,0 +1,11 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Mono + +interface CredentialsRepository : R2dbcRepository { + + fun findByCredentialNumber(credentialNumber: Int): Mono + + // TODO: Finish impl??? +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index b19c801b5..b4b4eb67a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -22,7 +22,6 @@ import org.dreamexposure.discal.core.extensions.setFromString import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.StaticMessage -import org.dreamexposure.discal.core.`object`.WebSession import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.event.EventData @@ -1099,29 +1098,6 @@ object DatabaseManager { }.defaultIfEmpty(-1) } - fun getCredentialData(credNumber: Int): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_CREDENTIAL_DATA) - .bind(0, credNumber) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val refresh = row["REFRESH_TOKEN", String::class.java]!! - val access = row["ACCESS_TOKEN", String::class.java]!! - val expires = Instant.ofEpochMilli(row["EXPIRES_AT", Long::class.java]!!) - - GoogleCredentialData(credNumber, refresh, access, expires) - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get enabled announcements by type", it) - }.onErrorResume { Mono.empty() } - } - } - fun deleteAnnouncement(announcementId: String): Mono { return connect { c -> Mono.from( @@ -1514,144 +1490,6 @@ object DatabaseManager { }.collectList() } } - - /* Session Data */ - fun insertSessionData(session: WebSession): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.INSERT_SESSION_DATA) - .bind(0, session.token) - .bind(1, session.user.asLong()) - .bind(2, session.expiresAt) - .bind(3, session.accessToken) - .bind(4, session.refreshToken) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to insert session data", it) - }.onErrorResume { Mono.just(false) } - } - - fun removeAndInsertSessionData(session: WebSession): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.REMOVE_AND_INSERT_SESSION_DATA) - // Remove all existing sessions for user bindings - .bind(0, session.user.asLong()) - // Insert new session bindings - .bind(1, session.token) - .bind(2, session.user.asLong()) - .bind(3, session.expiresAt) - .bind(4, session.accessToken) - .bind(5, session.refreshToken) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to insert session data", it) - }.onErrorResume { Mono.just(false) } - } - - fun getSessionData(token: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_SESSION_TOKEN) - .bind(0, token) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val userId = Snowflake.of(row["user_id", Long::class.java]!!) - val expiresAt = row["expires_at", Instant::class.java]!! - val accessToken = row["access_token", String::class.java]!! - val refreshToken = row["refresh_token", String::class.java]!! - - WebSession(token, userId, expiresAt, accessToken, refreshToken) - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get session data by token", it) - }.onErrorResume { - Mono.empty() - } - } - } - - fun getAllSessionsForUser(userId: Snowflake): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_SESSIONS_USER) - .bind(0, userId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val token = row["token", String::class.java]!! - val expiresAt = row["expires_at", Instant::class.java]!! - val accessToken = row["access_token", String::class.java]!! - val refreshToken = row["refresh_token", String::class.java]!! - - WebSession(token, userId, expiresAt, accessToken, refreshToken) - } - }.retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get sessions for user", it) - }.onErrorResume { - Mono.empty() - }.collectList() - } - } - - fun deleteSession(token: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_SESSION) - .bind(0, token) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "session delete failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } - - fun deleteAllSessionsForUser(userId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_SESSIONS_FOR_USER) - .bind(0, userId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "delete all sessions for user failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } - - fun deleteExpiredSessions(): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_EXPIRED_SESSIONS) - .bind(0, Instant.now()) // Delete everything that expired before now - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - // Technically because we have handling for expired tokens we don't need to panic if this breaks - LOGGER.error(DEFAULT, "Expired session delete failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } } private object Queries { @@ -1872,41 +1710,6 @@ private object Queries { WHERE MOD(guild_id >> 22, ?) = ? """.trimMargin() - /* Session Data */ - - @Language("MySQL") - val INSERT_SESSION_DATA = """INSERT INTO ${Tables.SESSIONS} - (token, user_id, expires_at, access_token, refresh_token) - VALUES(?, ?, ?, ?, ?) - """.trimMargin() - - @Language("MySQL") - val SELECT_SESSION_TOKEN = """SELECT * FROM ${Tables.SESSIONS} - WHERE token = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_SESSIONS_USER = """SELECT * FROM ${Tables.SESSIONS} - WHERE user_id = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_SESSION = """DELETE FROM ${Tables.SESSIONS} - WHERE token = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_SESSIONS_FOR_USER = """DELETE FROM ${Tables.SESSIONS} - WHERE user_id = ? - """.trimMargin() - - val REMOVE_AND_INSERT_SESSION_DATA = "$DELETE_SESSIONS_FOR_USER;$INSERT_SESSION_DATA" - - @Language("MySQL") - val DELETE_EXPIRED_SESSIONS = """DELETE FROM ${Tables.SESSIONS} - where expires_at < ? - """.trimMargin() - /* Delete everything */ @Language("MySQL") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt new file mode 100644 index 000000000..17983b91e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt @@ -0,0 +1,13 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant + +@Table("sessions") +data class SessionData( + val token: String, + val userId: Long, + val expiresAt: Instant, + val accessToken: String, + val refreshToken: String, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt new file mode 100644 index 000000000..eda0794bf --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant + +interface SessionRepository : R2dbcRepository { + fun findByToken(token: String): Mono + + fun findAllByUserId(userId: Long): Flux + + fun deleteByToken(token: String): Mono + + fun deleteAllByUserId(userId: Long): Mono + + fun deleteAllByExpiresAtIsLessThan(expiresAt: Instant): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt index 2b2f05987..7464a98f1 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt @@ -2,12 +2,12 @@ package org.dreamexposure.discal.core.entities.google import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.crypto.AESEncryption -import org.dreamexposure.discal.core.`object`.google.GoogleCredentialData +import org.dreamexposure.discal.core.`object`.new.Credential import reactor.core.publisher.Mono import java.time.Instant data class DisCalGoogleCredential( - val credentialData: GoogleCredentialData, + val credential: Credential, ) { private val aes: AESEncryption = AESEncryption(Config.SECRET_GOOGLE_CREDENTIAL_KEY.getString()) private var access: String? = null @@ -15,13 +15,13 @@ data class DisCalGoogleCredential( fun getRefreshToken(): Mono { if (refresh != null) return Mono.justOrEmpty(refresh) - return aes.decrypt(credentialData.encryptedRefreshToken) + return aes.decrypt(credential.encryptedRefreshToken) .doOnNext { refresh = it } } fun getAccessToken(): Mono { if (access != null) return Mono.justOrEmpty(access) - return aes.decrypt(credentialData.encryptedAccessToken) + return aes.decrypt(credential.encryptedAccessToken) .doOnNext { access = it } } @@ -29,7 +29,7 @@ data class DisCalGoogleCredential( refresh = token //credentialData.encryptedRefreshToken = aes.encrypt(token) return aes.encrypt(token) - .doOnNext { credentialData.encryptedRefreshToken = it } + .doOnNext { credential.encryptedRefreshToken = it } .then() } @@ -37,9 +37,9 @@ data class DisCalGoogleCredential( access = token //credentialData.encryptedAccessToken = aes.encrypt(token) return aes.encrypt(token) - .doOnNext { credentialData.encryptedAccessToken = it } + .doOnNext { credential.encryptedAccessToken = it } .then() } - fun expired() = Instant.now().isAfter(credentialData.expiresAt) + fun expired() = Instant.now().isAfter(credential.expiresAt) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt index 75adcea0d..1ee3cb387 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt @@ -1,6 +1,7 @@ package org.dreamexposure.discal.core.`object` import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.SessionData import java.time.Instant import java.time.temporal.ChronoUnit @@ -14,4 +15,12 @@ data class WebSession( val accessToken: String, val refreshToken: String, -) +) { + constructor(data: SessionData) : this( + token = data.token, + user = Snowflake.of(data.userId), + expiresAt = data.expiresAt, + accessToken = data.accessToken, + refreshToken = data.refreshToken, + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt new file mode 100644 index 000000000..a0dd9e234 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.core.`object`.new + +import org.dreamexposure.discal.core.database.CredentialData +import java.time.Instant + +data class Credential( + val credentialNumber: Int, + var encryptedRefreshToken: String, + var encryptedAccessToken: String, + var expiresAt: Instant, +) { + constructor(data: CredentialData) : this( + credentialNumber = data.credentialNumber, + encryptedRefreshToken = data.refreshToken, + encryptedAccessToken = data.accessToken, + expiresAt = Instant.ofEpochMilli(data.expiresAt), + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt index defbf8113..2d269e5bb 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt @@ -1,6 +1,8 @@ package org.dreamexposure.discal.core.spring +import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.core.annotations.Authentication +import org.dreamexposure.discal.core.business.SessionService import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.exceptions.AuthenticationException @@ -22,7 +24,10 @@ import java.util.concurrent.ConcurrentMap @Component @ConditionalOnProperty(name = ["discal.security.enabled"], havingValue = "true") -class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebFilter { +class SecurityWebFilter( + private val sessionService: SessionService, + private val handlerMapping: RequestMappingHandlerMapping, +) : WebFilter { private val readOnlyKeys: ConcurrentMap = ConcurrentHashMap() init { @@ -87,7 +92,7 @@ class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebF Mono.just(Authentication.AccessLevel.READ) } authHeader.startsWith("Bearer ") -> { - DatabaseManager.getSessionData(authHeader.substringAfter("Bearer ")).flatMap { session -> + mono { sessionService.getSession(authHeader.substringAfter("Bearer ")) }.flatMap { session -> if (session.expiresAt.isAfter(Instant.now())) { Mono.just(Authentication.AccessLevel.WRITE) } else { @@ -97,7 +102,7 @@ class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebF } else -> { // Check if this is an API key - DatabaseManager.getAPIAccount(authHeader).flatMap { acc -> + DatabaseManager.getAPIAccount(authHeader).flatMap { acc -> //TODO: Replace this if (!acc.blocked) { Mono.just(Authentication.AccessLevel.WRITE) } else { diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 9ec8c396b..07546054f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -1,4 +1,8 @@ package org.dreamexposure.discal +import org.dreamexposure.discal.core.cache.CacheRepository +import org.dreamexposure.discal.core.`object`.new.Credential + // Cache //typealias GuildSettingsCache = CacheRepository +typealias CredentialsCache = CacheRepository