Skip to content

Commit

Permalink
RC 4.2.5 ( #162)
Browse files Browse the repository at this point in the history
RC 4.2.5
  • Loading branch information
NovaFox161 committed Jan 21, 2024
2 parents bd8cda5 + 3ef0fc0 commit 770f00d
Show file tree
Hide file tree
Showing 83 changed files with 854 additions and 559 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/gradle.yml
Expand Up @@ -83,7 +83,7 @@ jobs:
SCW_SECRET: ${{ secrets.SCW_SECRET }}
with:
command: ./gradlew jib -Djib.to.auth.username=${SCW_USER} -Djib.to.auth.password=${SCW_SECRET}
attempt_limit: 5
attempt_limit: 25
# 1 minute in ms
attempt_delay: 60000
deploy-dev:
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Expand Up @@ -27,7 +27,7 @@ buildscript {
allprojects {
//Project props
group = "org.dreamexposure.discal"
version = "4.2.4"
version = "4.2.5"
description = "DisCal"

//Plugins
Expand Down
8 changes: 7 additions & 1 deletion cam/build.gradle.kts
Expand Up @@ -26,8 +26,14 @@ kotlin {

jib {
to {
val buildVersion = if (System.getenv("GITHUB_RUN_NUMBER") != null) {
"$version.b${System.getenv("GITHUB_RUN_NUMBER")}"
} else {
"$version.d${System.currentTimeMillis().div(1000)}" //Seconds since epoch
}

image = "rg.nl-ams.scw.cloud/dreamexposure/discal-cam"
tags = mutableSetOf("latest", version.toString())
tags = mutableSetOf("latest", version.toString(), buildVersion)
}

val baseImage: String by properties
Expand Down
@@ -0,0 +1,100 @@
package org.dreamexposure.discal.cam.business

import org.dreamexposure.discal.core.business.ApiKeyService
import org.dreamexposure.discal.core.business.SessionService
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.extensions.isExpiredTtl
import org.dreamexposure.discal.core.`object`.new.security.Scope
import org.dreamexposure.discal.core.`object`.new.security.TokenType
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component

@Component
class SecurityService(
private val sessionService: SessionService,
private val apiKeyService: ApiKeyService,
) {
suspend fun authenticateAndAuthorizeToken(token: String, schemas: List<TokenType>, scopes: List<Scope>): Pair<HttpStatus, String> {
if (!authenticateToken(token)) return Pair(HttpStatus.UNAUTHORIZED, "Unauthenticated")

if (!validateTokenSchema(token, schemas)) return Pair(HttpStatus.UNAUTHORIZED, "Unsupported schema")

if (!authorizeToken(token, scopes)) return Pair(HttpStatus.FORBIDDEN, "Access denied")

return Pair(HttpStatus.OK, "Authorized")
}

suspend fun authenticateToken(token: String): Boolean {
val schema = getSchema(token)
val tokenStr = token.removePrefix(schema.schema)

return when (schema) {
TokenType.BEARER -> authenticateUserToken(tokenStr)
TokenType.APP -> authenticateAppToken(tokenStr)
TokenType.INTERNAL -> authenticateInternalToken(tokenStr)
else -> false
}
}

suspend fun validateTokenSchema(token: String, allowedSchemas: List<TokenType>): Boolean {
if (allowedSchemas.isEmpty()) return true // No schemas required
val schema = getSchema(token)

return allowedSchemas.contains(schema)
}

suspend fun authorizeToken(token: String, requiredScopes: List<Scope>): Boolean {
if (requiredScopes.isEmpty()) return true // No scopes required

val schema = getSchema(token)
val tokenStr = token.removePrefix(schema.schema)

val scopes = when (schema) {
TokenType.BEARER -> getScopesForUserToken(tokenStr)
TokenType.APP -> getScopesForAppToken(tokenStr)
TokenType.INTERNAL -> getScopesForInternalToken()
else -> return false
}

return scopes.containsAll(requiredScopes)
}


// Authentication based on token type
private suspend fun authenticateUserToken(token: String): Boolean {
val session = sessionService.getSession(token) ?: return false

return !session.expiresAt.isExpiredTtl()
}

private suspend fun authenticateAppToken(token: String): Boolean {
val key = apiKeyService.getKey(token) ?: return false

return !key.blocked
}

private fun authenticateInternalToken(token: String): Boolean {
return Config.SECRET_DISCAL_API_KEY.getString() == token
}

// Fetching scopes for tokens
private suspend fun getScopesForUserToken(token: String): List<Scope> {
return sessionService.getSession(token)?.scopes ?: emptyList()
}

private suspend fun getScopesForAppToken(token: String): List<Scope> {
return apiKeyService.getKey(token)?.scopes ?: emptyList()
}

private fun getScopesForInternalToken(): List<Scope> = Scope.entries.toList()

// Various other stuff
private fun getSchema(token: String): TokenType {
return when {
token.startsWith(TokenType.BEARER.schema) -> TokenType.BEARER
token.startsWith(TokenType.APP.schema) -> TokenType.APP
token.startsWith(TokenType.INTERNAL.schema) -> TokenType.INTERNAL
else -> TokenType.NONE
}
}
}
Expand Up @@ -2,6 +2,7 @@ package org.dreamexposure.discal.cam.business.cronjob

import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactor.mono
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
Expand All @@ -12,7 +13,6 @@ import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest
import org.dreamexposure.discal.core.`object`.rest.HeartbeatType
import org.dreamexposure.discal.core.utils.GlobalVal
import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT
import org.dreamexposure.discal.core.utils.GlobalVal.JSON
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
Expand All @@ -23,6 +23,7 @@ import reactor.core.scheduler.Schedulers

@Component
class HeartbeatCronJob(
private val httpClient: OkHttpClient,
private val objectMapper: ObjectMapper,
): ApplicationRunner {
private final val apiUrl = Config.URL_API.getString()
Expand All @@ -39,13 +40,13 @@ class HeartbeatCronJob(
val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = InstanceData())

val request = Request.Builder()
.url("$apiUrl/v2/status/heartbeat")
.url("$apiUrl/v3/status/heartbeat")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(JSON))
.header("Authorization", Config.SECRET_DISCAL_API_KEY.getString())
.header("Authorization", "Int ${Config.SECRET_DISCAL_API_KEY.getString()}")
.header("Content-Type", "application/json")
.build()

Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute)
Mono.fromCallable(httpClient.newCall(request)::execute)
.map(Response::close)
.subscribeOn(Schedulers.boundedElastic())
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
Expand Down
@@ -0,0 +1,31 @@
package org.dreamexposure.discal.cam.controllers.v1

import org.dreamexposure.discal.cam.business.SecurityService
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.`object`.new.security.Scope.INTERNAL_CAM_VALIDATE_TOKEN
import org.dreamexposure.discal.core.`object`.new.security.TokenType.INTERNAL
import org.dreamexposure.discal.core.`object`.rest.v1.security.ValidateRequest
import org.dreamexposure.discal.core.`object`.rest.v1.security.ValidateResponse
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/v1/security")
class SecurityController(
private val securityService: SecurityService,
) {
@SecurityRequirement(schemas = [INTERNAL], scopes = [INTERNAL_CAM_VALIDATE_TOKEN])
@PostMapping("/validate", produces = ["application/json"])
suspend fun validate(@RequestBody request: ValidateRequest): ValidateResponse {
val result = securityService.authenticateAndAuthorizeToken(
request.token,
request.schemas,
request.scopes,
)

return ValidateResponse(result.first == HttpStatus.OK, result.first, result.second)
}
}
Expand Up @@ -2,9 +2,11 @@ package org.dreamexposure.discal.cam.controllers.v1

import discord4j.common.util.Snowflake
import org.dreamexposure.discal.cam.managers.CalendarAuthManager
import org.dreamexposure.discal.core.annotations.Authentication
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.`object`.network.discal.CredentialData
import org.dreamexposure.discal.core.`object`.new.security.Scope.CALENDAR_TOKEN_READ
import org.dreamexposure.discal.core.`object`.new.security.TokenType.INTERNAL
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
Expand All @@ -15,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController
class TokenController(
private val calendarAuthManager: CalendarAuthManager,
) {
@Authentication(access = Authentication.AccessLevel.ADMIN)
@SecurityRequirement(schemas = [INTERNAL], scopes = [CALENDAR_TOKEN_READ])
@GetMapping(produces = ["application/json"])
suspend fun getToken(@RequestParam host: CalendarHost, @RequestParam id: Int, @RequestParam guild: Snowflake?): CredentialData? {
return calendarAuthManager.getCredentialData(host, id, guild)
Expand Down
Expand Up @@ -4,7 +4,9 @@ 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.managers.DiscordOauthManager
import org.dreamexposure.discal.core.annotations.Authentication
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.`object`.new.security.Scope.OAUTH2_DISCORD
import org.dreamexposure.discal.core.`object`.new.security.TokenType.BEARER
import org.springframework.web.bind.annotation.*

@RestController
Expand All @@ -14,20 +16,20 @@ class DiscordOauthController(
) {

@GetMapping("login")
@Authentication(access = Authentication.AccessLevel.PUBLIC)
@SecurityRequirement(disableSecurity = true, scopes = [])
suspend fun login(): LoginResponse {
val link = discordOauthManager.getOauthLinkForLogin()
return LoginResponse(link)
}

@GetMapping("logout")
@Authentication(access = Authentication.AccessLevel.WRITE)
@SecurityRequirement(schemas = [BEARER], scopes = [OAUTH2_DISCORD])
suspend fun logout(@RequestHeader("Authorization") token: String) {
discordOauthManager.handleLogout(token)
}

@PostMapping("code")
@Authentication(access = Authentication.AccessLevel.PUBLIC)
@SecurityRequirement(disableSecurity = true, scopes = [])
suspend fun token(@RequestBody body: TokenRequest): TokenResponse {
return discordOauthManager.handleCodeExchange(body.state, body.code)
}
Expand Down
Expand Up @@ -29,7 +29,7 @@ class CalendarAuthManager(
}
} catch (ex: Exception) {
LOGGER.error("Get CredentialData Exception | guildId:$guild | credentialId:$id | calendarHost:${host.name}", ex)
null
throw ex // rethrow
}
}
}
Expand Up @@ -7,6 +7,7 @@ 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.`object`.WebSession
import org.dreamexposure.discal.core.`object`.new.security.Scope
import org.dreamexposure.discal.core.utils.GlobalVal.discordApiUrl
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
Expand Down Expand Up @@ -51,7 +52,8 @@ class DiscordOauthManager(
apiToken,
authInfo.user!!.id,
accessToken = dTokens.accessToken,
refreshToken = dTokens.refreshToken
refreshToken = dTokens.refreshToken,
scopes = Scope.defaultWebsiteLoginScopes(),
)

sessionService.removeAndInsertSession(session)
Expand Down
@@ -0,0 +1,79 @@
package org.dreamexposure.discal.cam.security

import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.mono
import org.dreamexposure.discal.cam.business.SecurityService
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.extensions.spring.writeJsonString
import org.dreamexposure.discal.core.`object`.rest.ErrorResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono

@Component
class SecurityWebFilter(
private val securityService: SecurityService,
private val handlerMapping: RequestMappingHandlerMapping,
private val objectMapper: ObjectMapper,
) : WebFilter {

override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
return mono {
doSecurityFilter(exchange, chain)
}.then(chain.filter(exchange))
}

suspend fun doSecurityFilter(exchange: ServerWebExchange, chain: WebFilterChain) {
val handlerMethod = handlerMapping.getHandler(exchange)
.cast(HandlerMethod::class.java)
.onErrorResume { Mono.empty() }
.awaitFirstOrNull() ?: return

if (!handlerMethod.hasMethodAnnotation(SecurityRequirement::class.java)) {
throw IllegalStateException("No SecurityRequirement annotation!")
}

val authAnnotation = handlerMethod.getMethodAnnotation(SecurityRequirement::class.java)!!
val authHeader = exchange.request.headers.getOrEmpty("Authorization").firstOrNull()


if (authAnnotation.disableSecurity) return

if (authHeader == null) {
exchange.response.statusCode = HttpStatus.UNAUTHORIZED
exchange.response.writeJsonString(
objectMapper.writeValueAsString(ErrorResponse("Missing Authorization header"))
).awaitFirstOrNull()
return
}

if (authHeader.equals("teapot", ignoreCase = true)) {
exchange.response.statusCode = HttpStatus.I_AM_A_TEAPOT
exchange.response.writeJsonString(
objectMapper.writeValueAsString(ErrorResponse("I'm a teapot"))
).awaitFirstOrNull()
return
}

val result = securityService.authenticateAndAuthorizeToken(
authHeader,
authAnnotation.schemas.toList(),
authAnnotation.scopes.toList()
)
if (result.first != HttpStatus.OK) {
exchange.response.statusCode = result.first
exchange.response.writeJsonString(
objectMapper.writeValueAsString(ErrorResponse(result.second))
).awaitFirstOrNull()
return
}

// If we made it to the end, everything is good to go.
}
}
8 changes: 7 additions & 1 deletion client/build.gradle.kts
Expand Up @@ -25,8 +25,14 @@ kotlin {

jib {
to {
val buildVersion = if (System.getenv("GITHUB_RUN_NUMBER") != null) {
"$version.b${System.getenv("GITHUB_RUN_NUMBER")}"
} else {
"$version.d${System.currentTimeMillis().div(1000)}" //Seconds since epoch
}

image = "rg.nl-ams.scw.cloud/dreamexposure/discal-client"
tags = mutableSetOf("latest", version.toString())
tags = mutableSetOf("latest", version.toString(), buildVersion)
}

val baseImage: String by properties
Expand Down

0 comments on commit 770f00d

Please sign in to comment.