diff --git a/.gitignore b/.gitignore index 2165d593..5f98beae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ out/ /node_modules/ /src/main/web/generated/ /stats.html +/tomcat/ diff --git a/build.gradle.kts b/build.gradle.kts index 7e8bd200..1941b614 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,9 +15,9 @@ plugins { id("org.graalvm.buildtools.native") version "0.9.21" id("io.gitlab.arturbosch.detekt") version "1.23.1" id("com.google.devtools.ksp") version "1.8.21-1.0.11" - id("org.springframework.boot") version "3.1.2" + id("org.springframework.boot") version "3.1.3" kotlin("plugin.spring") version "1.8.21" - id("org.openapi.generator") version "6.6.0" + id("org.openapi.generator") version "7.0.1" // id("org.jetbrains.kotlin.plugin.serialization") version "1.8.10" } @@ -47,8 +47,8 @@ tasks.withType { kotlinOptions { freeCompilerArgs += "-Xjsr305=strict" } - dependsOn("openApiGenerateServer") - mustRunAfter("openApiGenerateServer") + dependsOn("openApiGenerateMastodonCompatibleApi") + mustRunAfter("openApiGenerateMastodonCompatibleApi") } tasks.withType { @@ -63,30 +63,45 @@ tasks.clean { delete += listOf("$rootDir/src/main/resources/static") } -tasks.create("openApiGenerateServer", GenerateTask::class) { +//tasks.create("openApiGenerateServer", GenerateTask::class) { +// generatorName.set("kotlin-spring") +// inputSpec.set("$rootDir/src/main/resources/openapi/api.yaml") +// outputDir.set("$buildDir/generated/sources/openapi") +// apiPackage.set("dev.usbharu.hideout.controller.generated") +// modelPackage.set("dev.usbharu.hideout.domain.model.generated") +// configOptions.put("interfaceOnly", "true") +// configOptions.put("useSpringBoot3", "true") +// additionalProperties.put("useTags", "true") +// schemaMappings.putAll( +// mapOf( +// "ReactionResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse", +// "Account" to "dev.usbharu.hideout.domain.model.hideout.dto.Account", +// "JwtToken" to "dev.usbharu.hideout.domain.model.hideout.dto.JwtToken", +// "PostRequest" to "dev.usbharu.hideout.domain.model.hideout.form.Post", +// "PostResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.PostResponse", +// "Reaction" to "dev.usbharu.hideout.domain.model.hideout.form.Reaction", +// "RefreshToken" to "dev.usbharu.hideout.domain.model.hideout.form.RefreshToken", +// "UserLogin" to "dev.usbharu.hideout.domain.model.hideout.form.UserLogin", +// "UserResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.UserResponse", +// "UserCreate" to "dev.usbharu.hideout.domain.model.hideout.form.UserCreate", +// "Visibility" to "dev.usbharu.hideout.domain.model.hideout.entity.Visibility", +// ) +// ) +// +//// importMappings.putAll(mapOf("ReactionResponse" to "ReactionResponse")) +//// typeMappings.putAll(mapOf("ReactionResponse" to "ReactionResponse")) +//} + +tasks.create("openApiGenerateMastodonCompatibleApi", GenerateTask::class) { generatorName.set("kotlin-spring") - inputSpec.set("$rootDir/src/main/resources/openapi/api.yaml") - outputDir.set("$buildDir/generated/sources/openapi") - apiPackage.set("dev.usbharu.hideout.controller.generated") - modelPackage.set("dev.usbharu.hideout.domain.model.generated") + inputSpec.set("$rootDir/src/main/resources/openapi/mastodon.yaml") + outputDir.set("$buildDir/generated/sources/mastodon") + apiPackage.set("dev.usbharu.hideout.controller.mastodon.generated") + modelPackage.set("dev.usbharu.hideout.domain.mastodon.model.generated") configOptions.put("interfaceOnly", "true") configOptions.put("useSpringBoot3", "true") + configOptions.put("reactive", "true") additionalProperties.put("useTags", "true") - schemaMappings.putAll( - mapOf( - "ReactionResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse", - "Account" to "dev.usbharu.hideout.domain.model.hideout.dto.Account", - "JwtToken" to "dev.usbharu.hideout.domain.model.hideout.dto.JwtToken", - "PostRequest" to "dev.usbharu.hideout.domain.model.hideout.form.Post", - "PostResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.PostResponse", - "Reaction" to "dev.usbharu.hideout.domain.model.hideout.form.Reaction", - "RefreshToken" to "dev.usbharu.hideout.domain.model.hideout.form.RefreshToken", - "UserLogin" to "dev.usbharu.hideout.domain.model.hideout.form.UserLogin", - "UserResponse" to "dev.usbharu.hideout.domain.model.hideout.dto.UserResponse", - "UserCreate" to "dev.usbharu.hideout.domain.model.hideout.form.UserCreate", - "Visibility" to "dev.usbharu.hideout.domain.model.hideout.entity.Visibility", - ) - ) // importMappings.putAll(mapOf("ReactionResponse" to "ReactionResponse")) // typeMappings.putAll(mapOf("ReactionResponse" to "ReactionResponse")) @@ -105,7 +120,11 @@ kotlin { } sourceSets.main { - kotlin.srcDirs("$buildDir/generated/ksp/main", "$buildDir/generated/sources/openapi/src/main/kotlin") + kotlin.srcDirs( + "$buildDir/generated/ksp/main", + "$buildDir/generated/sources/openapi/src/main/kotlin", + "$buildDir/generated/sources/mastodon/src/main/kotlin" + ) } dependencies { @@ -134,6 +153,7 @@ dependencies { implementation("io.insert-koin:koin-logger-slf4j:$koin_version") implementation("io.insert-koin:koin-annotations:1.2.0") implementation("io.ktor:ktor-server-compression-jvm:2.3.0") + implementation("org.springframework.boot:spring-boot-starter-actuator") ksp("io.insert-koin:koin-ksp-compiler:1.2.0") implementation("org.springframework.boot:spring-boot-starter-web") @@ -153,6 +173,9 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") testImplementation("org.springframework.boot:spring-boot-test-autoconfigure") testImplementation("org.springframework.boot:spring-boot-starter-test") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.security:spring-security-oauth2-jose") implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version") diff --git a/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt b/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt index 75505da3..8dc456bc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt @@ -5,7 +5,9 @@ import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.source.ImmutableJWKSet import com.nimbusds.jose.jwk.source.JWKSource import com.nimbusds.jose.proc.SecurityContext +import dev.usbharu.hideout.domain.model.UserDetailsImpl import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.security.servlet.PathRequest import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -14,26 +16,35 @@ import org.springframework.http.MediaType import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.core.Authentication import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher +import org.springframework.web.servlet.handler.HandlerMappingIntrospector import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey import java.util.* -@EnableWebSecurity + +@EnableWebSecurity(debug = true) @Configuration class SecurityConfig { @Bean @Order(1) - fun oauth2SecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + fun oauth2SecurityFilterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { + val builder = MvcRequestMatcher.Builder(introspector) + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) http .exceptionHandling { @@ -45,31 +56,46 @@ class SecurityConfig { .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } - .csrf { - it.disable() - } return http.build() } @Bean @Order(2) - fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + fun defaultSecurityFilterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { + val builder = MvcRequestMatcher.Builder(introspector) + + http.authorizeHttpRequests { + it.requestMatchers(builder.pattern("/api/v1/**")).hasAnyAuthority("SCOPE_read", "SCOPE_read:accounts") + } http .authorizeHttpRequests { it.requestMatchers( - "/inbox", - "/users/*/inbox", - "/outbox", - "/users/*/outbox" - ) - .permitAll() + builder.pattern("/inbox"), + builder.pattern("/api/v1/apps"), + builder.pattern("/api/v1/instance/**") + ).permitAll() } + http + .authorizeHttpRequests { + it.requestMatchers(PathRequest.toH2Console()).permitAll() + } + http .authorizeHttpRequests { it.anyRequest().authenticated() } + http + .oauth2ResourceServer { + it.jwt(Customizer.withDefaults()) + } .formLogin(Customizer.withDefaults()) .csrf { - it.disable() + it.ignoringRequestMatchers(builder.pattern("/api/**")) + it.ignoringRequestMatchers(PathRequest.toH2Console()) + } + .headers { + it.frameOptions { + it.sameOrigin() + } } return http.build() } @@ -119,6 +145,18 @@ class SecurityConfig { .tokenRevocationEndpoint("/oauth/revoke") .build() } + + @Bean + fun jwtTokenCustomizer(): OAuth2TokenCustomizer { + return OAuth2TokenCustomizer { context: JwtEncodingContext -> + if (OAuth2TokenType.ACCESS_TOKEN == context.tokenType) { + val userDetailsImpl = context.getPrincipal().principal as UserDetailsImpl + context.claims.claim("uid", userDetailsImpl.id.toString()) + + + } + } + } } @ConfigurationProperties("hideout.security.jwt") diff --git a/src/main/kotlin/dev/usbharu/hideout/config/DatabaseConfig.kt b/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt similarity index 82% rename from src/main/kotlin/dev/usbharu/hideout/config/DatabaseConfig.kt rename to src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt index 03064946..58898d02 100644 --- a/src/main/kotlin/dev/usbharu/hideout/config/DatabaseConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt @@ -7,11 +7,14 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class DatabaseConfig { +class SpringConfig { @Autowired lateinit var dbConfig: DatabaseConnectConfig + @Autowired + lateinit var config: ApplicationConfig + @Bean fun database(): Database { return Database.connect( @@ -23,6 +26,11 @@ class DatabaseConfig { } } +@ConfigurationProperties("hideout") +data class ApplicationConfig( + val url: String +) + @ConfigurationProperties("hideout.database") data class DatabaseConnectConfig( val url: String, diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/DefaultApiImpl.kt b/src/main/kotlin/dev/usbharu/hideout/controller/DefaultApiImpl.kt deleted file mode 100644 index b6253251..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/controller/DefaultApiImpl.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.usbharu.hideout.controller - -import dev.usbharu.hideout.controller.generated.DefaultApi -import dev.usbharu.hideout.domain.model.hideout.dto.JwtToken -import dev.usbharu.hideout.service.api.UserAuthApiService -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Controller - -@Controller -class DefaultApiImpl(private val userAuthApiService: UserAuthApiService) : DefaultApi { - override fun refreshTokenPost(): ResponseEntity { - return ResponseEntity(HttpStatus.OK) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAccountApiController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAccountApiController.kt new file mode 100644 index 00000000..c9285d01 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAccountApiController.kt @@ -0,0 +1,22 @@ +package dev.usbharu.hideout.controller.mastodon + +import dev.usbharu.hideout.controller.mastodon.generated.AccountApi +import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount +import dev.usbharu.hideout.service.api.mastodon.AccountApiService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.stereotype.Controller + +@Controller +class MastodonAccountApiController(private val accountApiService: AccountApiService) : AccountApi { + override suspend fun apiV1AccountsVerifyCredentialsGet(): ResponseEntity { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + return ResponseEntity( + accountApiService.verifyCredentials(principal.getClaim("uid").toLong()), + HttpStatus.OK + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAppsApiController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAppsApiController.kt new file mode 100644 index 00000000..9d538ce6 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAppsApiController.kt @@ -0,0 +1,39 @@ +package dev.usbharu.hideout.controller.mastodon + +import dev.usbharu.hideout.controller.mastodon.generated.AppApi +import dev.usbharu.hideout.domain.mastodon.model.generated.Application +import dev.usbharu.hideout.domain.mastodon.model.generated.AppsRequest +import dev.usbharu.hideout.service.api.mastodon.AppApiService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RequestParam + +@Controller +class MastodonAppsApiController(private val appApiService: AppApiService) : AppApi { + override suspend fun apiV1AppsPost(appsRequest: AppsRequest): ResponseEntity { + println(appsRequest) + return ResponseEntity( + appApiService.createApp(appsRequest), + HttpStatus.OK + ) + } + + @RequestMapping( + method = [RequestMethod.POST], + value = ["/api/v1/apps"], + produces = ["application/json"], + consumes = ["application/x-www-form-urlencoded"] + ) + suspend fun apiV1AppsPost(@RequestParam map: Map): ResponseEntity { + val appsRequest = + AppsRequest(map.getValue("client_name"), map.getValue("redirect_uris"), map["scopes"], map["website"]) + return ResponseEntity( + appApiService.createApp(appsRequest), + HttpStatus.OK + ) + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonInstanceApiController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonInstanceApiController.kt new file mode 100644 index 00000000..207d809c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonInstanceApiController.kt @@ -0,0 +1,15 @@ +package dev.usbharu.hideout.controller.mastodon + +import dev.usbharu.hideout.controller.mastodon.generated.InstanceApi +import dev.usbharu.hideout.domain.mastodon.model.generated.V1Instance +import dev.usbharu.hideout.service.api.mastodon.InstanceApiService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller + +@Controller +class MastodonInstanceApiController(private val instanceApiService: InstanceApiService) : InstanceApi { + override suspend fun apiV1InstanceGet(): ResponseEntity { + return ResponseEntity(instanceApiService.v1Instance(), HttpStatus.OK) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonStatusesApiContoller.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonStatusesApiContoller.kt new file mode 100644 index 00000000..0e81d218 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonStatusesApiContoller.kt @@ -0,0 +1,20 @@ +package dev.usbharu.hideout.controller.mastodon + +import dev.usbharu.hideout.controller.mastodon.generated.StatusApi +import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest +import dev.usbharu.hideout.domain.model.UserDetailsImpl +import dev.usbharu.hideout.service.api.mastodon.StatusesApiService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Controller + +@Controller +class MastodonStatusesApiContoller(private val statusesApiService: StatusesApiService) : StatusApi { + override suspend fun apiV1StatusesPost(statusesRequest: StatusesRequest): ResponseEntity { + val principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal() + require(principal is UserDetailsImpl) + return ResponseEntity(statusesApiService.postStatus(statusesRequest, principal), HttpStatus.OK) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt new file mode 100644 index 00000000..b666c5e8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt @@ -0,0 +1,83 @@ +package dev.usbharu.hideout.domain.model + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import java.io.Serial + +class UserDetailsImpl( + val id: Long, + username: String?, + password: String?, + enabled: Boolean, + accountNonExpired: Boolean, + credentialsNonExpired: Boolean, + accountNonLocked: Boolean, + authorities: MutableCollection? +) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) { + companion object { + @Serial + private const val serialVersionUID: Long = -899168205656607781L + } + + +} + +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonDeserialize(using = UserDetailsDeserializer::class) +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE +) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSubTypes +abstract class UserDetailsMixin + + +class UserDetailsDeserializer : JsonDeserializer() { + val SIMPLE_GRANTED_AUTHORITY_SET = object : TypeReference>() {} + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): UserDetailsImpl { + + val mapper = p.codec as ObjectMapper + val jsonNode: JsonNode = mapper.readTree(p) + println(jsonNode) + val authorities: Set = mapper.convertValue( + jsonNode["authorities"], + SIMPLE_GRANTED_AUTHORITY_SET + ) + + val password = jsonNode.readText("password") + return UserDetailsImpl( + jsonNode["id"].longValue(), + jsonNode.readText("username"), + password, + true, + true, + true, + true, + authorities.toMutableList(), + ) + + } + + fun JsonNode.readText(field: String, defaultValue: String = ""): String { + return when { + has(field) -> get(field).asText(defaultValue) + else -> defaultValue + } + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/RegisteredClientRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/RegisteredClientRepositoryImpl.kt index 7199a949..c451639b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/RegisteredClientRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/RegisteredClientRepositoryImpl.kt @@ -1,16 +1,22 @@ package dev.usbharu.hideout.repository +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.readValue import dev.usbharu.hideout.repository.RegisteredClient.clientId import dev.usbharu.hideout.repository.RegisteredClient.clientSettings import dev.usbharu.hideout.repository.RegisteredClient.tokenSettings -import dev.usbharu.hideout.util.JsonUtil +import dev.usbharu.hideout.service.auth.ExposedOAuth2AuthorizationService import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.javatime.CurrentTimestamp import org.jetbrains.exposed.sql.javatime.timestamp import org.jetbrains.exposed.sql.transactions.transaction +import org.slf4j.LoggerFactory +import org.springframework.security.jackson2.SecurityJackson2Modules import org.springframework.security.oauth2.core.AuthorizationGrantType import org.springframework.security.oauth2.core.ClientAuthenticationMethod import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository +import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module import org.springframework.security.oauth2.server.authorization.settings.ClientSettings import org.springframework.security.oauth2.server.authorization.settings.ConfigurationSettingNames import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat @@ -41,13 +47,15 @@ class RegisteredClientRepositoryImpl(private val database: Database) : Registere it[clientSecret] = registeredClient.clientSecret it[clientSecretExpiresAt] = registeredClient.clientSecretExpiresAt it[clientName] = registeredClient.clientName - it[clientAuthenticationMethods] = registeredClient.clientAuthenticationMethods.joinToString(",") - it[authorizationGrantTypes] = registeredClient.authorizationGrantTypes.joinToString(",") + it[clientAuthenticationMethods] = + registeredClient.clientAuthenticationMethods.map { it.value }.joinToString(",") + it[authorizationGrantTypes] = + registeredClient.authorizationGrantTypes.map { it.value }.joinToString(",") it[redirectUris] = registeredClient.redirectUris.joinToString(",") it[postLogoutRedirectUris] = registeredClient.postLogoutRedirectUris.joinToString(",") it[scopes] = registeredClient.scopes.joinToString(",") - it[clientSettings] = JsonUtil.mapToJson(registeredClient.clientSettings.settings) - it[tokenSettings] = JsonUtil.mapToJson(registeredClient.tokenSettings.settings) + it[clientSettings] = mapToJson(registeredClient.clientSettings.settings) + it[tokenSettings] = mapToJson(registeredClient.tokenSettings.settings) } } else { RegisteredClient.update({ RegisteredClient.id eq registeredClient.id }) { @@ -61,8 +69,8 @@ class RegisteredClientRepositoryImpl(private val database: Database) : Registere it[redirectUris] = registeredClient.redirectUris.joinToString(",") it[postLogoutRedirectUris] = registeredClient.postLogoutRedirectUris.joinToString(",") it[scopes] = registeredClient.scopes.joinToString(",") - it[clientSettings] = JsonUtil.mapToJson(registeredClient.clientSettings.settings) - it[tokenSettings] = JsonUtil.mapToJson(registeredClient.tokenSettings.settings) + it[clientSettings] = mapToJson(registeredClient.clientSettings.settings) + it[tokenSettings] = mapToJson(registeredClient.tokenSettings.settings) } } } @@ -81,10 +89,93 @@ class RegisteredClientRepositoryImpl(private val database: Database) : Registere if (clientId == null) { return null } - return RegisteredClient.select { + val toRegisteredClient = RegisteredClient.select { RegisteredClient.clientId eq clientId }.singleOrNull()?.toRegisteredClient() + LOGGER.trace("findByClientId: $toRegisteredClient") + return toRegisteredClient } + + private fun mapToJson(map: Map<*, *>): String = objectMapper.writeValueAsString(map) + + private fun jsonToMap(json: String): Map = objectMapper.readValue(json) + + companion object { + val objectMapper: ObjectMapper = ObjectMapper() + val LOGGER = LoggerFactory.getLogger(RegisteredClientRepositoryImpl::class.java) + + init { + + val classLoader = ExposedOAuth2AuthorizationService::class.java.classLoader + val modules = SecurityJackson2Modules.getModules(classLoader) + this.objectMapper.registerModules(JavaTimeModule()) + this.objectMapper.registerModules(modules) + this.objectMapper.registerModules(OAuth2AuthorizationServerJackson2Module()) + } + } + + fun ResultRow.toRegisteredClient(): SpringRegisteredClient { + fun resolveClientAuthenticationMethods(string: String): ClientAuthenticationMethod { + return when (string) { + ClientAuthenticationMethod.CLIENT_SECRET_BASIC.value -> ClientAuthenticationMethod.CLIENT_SECRET_BASIC + ClientAuthenticationMethod.CLIENT_SECRET_JWT.value -> ClientAuthenticationMethod.CLIENT_SECRET_JWT + ClientAuthenticationMethod.CLIENT_SECRET_POST.value -> ClientAuthenticationMethod.CLIENT_SECRET_POST + ClientAuthenticationMethod.NONE.value -> ClientAuthenticationMethod.NONE + else -> { + ClientAuthenticationMethod(string) + } + } + } + + fun resolveAuthorizationGrantType(string: String): AuthorizationGrantType { + return when (string) { + AuthorizationGrantType.AUTHORIZATION_CODE.value -> AuthorizationGrantType.AUTHORIZATION_CODE + AuthorizationGrantType.CLIENT_CREDENTIALS.value -> AuthorizationGrantType.CLIENT_CREDENTIALS + AuthorizationGrantType.REFRESH_TOKEN.value -> AuthorizationGrantType.REFRESH_TOKEN + else -> { + AuthorizationGrantType(string) + } + } + } + + val clientAuthenticationMethods = this[RegisteredClient.clientAuthenticationMethods].split(",").toSet() + val authorizationGrantTypes = this[RegisteredClient.authorizationGrantTypes].split(",").toSet() + val redirectUris = this[RegisteredClient.redirectUris]?.split(",").orEmpty().toSet() + val postLogoutRedirectUris = this[RegisteredClient.postLogoutRedirectUris]?.split(",").orEmpty().toSet() + val clientScopes = this[RegisteredClient.scopes].split(",").toSet() + + val builder = SpringRegisteredClient + .withId(this[RegisteredClient.id]) + .clientId(this[clientId]) + .clientIdIssuedAt(this[RegisteredClient.clientIdIssuedAt]) + .clientSecret(this[RegisteredClient.clientSecret]) + .clientSecretExpiresAt(this[RegisteredClient.clientSecretExpiresAt]) + .clientName(this[RegisteredClient.clientName]) + .clientAuthenticationMethods { + clientAuthenticationMethods.forEach { s -> + it.add(resolveClientAuthenticationMethods(s)) + } + } + .authorizationGrantTypes { + authorizationGrantTypes.forEach { s -> + it.add(resolveAuthorizationGrantType(s)) + } + } + .redirectUris { it.addAll(redirectUris) } + .postLogoutRedirectUris { it.addAll(postLogoutRedirectUris) } + .scopes { it.addAll(clientScopes) } + .clientSettings(ClientSettings.withSettings(jsonToMap(this[clientSettings])).build()) + + val tokenSettingsMap = jsonToMap(this[tokenSettings]) + val withSettings = TokenSettings.withSettings(tokenSettingsMap) + if (tokenSettingsMap.containsKey(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT)) { + withSettings.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) + } + builder.tokenSettings(withSettings.build()) + + return builder.build() + } + } // org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql @@ -105,65 +196,3 @@ object RegisteredClient : Table("registered_client") { override val primaryKey = PrimaryKey(id) } - -fun ResultRow.toRegisteredClient(): SpringRegisteredClient { - fun resolveClientAuthenticationMethods(string: String): ClientAuthenticationMethod { - return when (string) { - ClientAuthenticationMethod.CLIENT_SECRET_BASIC.value -> ClientAuthenticationMethod.CLIENT_SECRET_BASIC - ClientAuthenticationMethod.CLIENT_SECRET_JWT.value -> ClientAuthenticationMethod.CLIENT_SECRET_JWT - ClientAuthenticationMethod.CLIENT_SECRET_POST.value -> ClientAuthenticationMethod.CLIENT_SECRET_POST - ClientAuthenticationMethod.NONE.value -> ClientAuthenticationMethod.NONE - else -> { - ClientAuthenticationMethod(string) - } - } - } - - fun resolveAuthorizationGrantType(string: String): AuthorizationGrantType { - return when (string) { - AuthorizationGrantType.AUTHORIZATION_CODE.value -> AuthorizationGrantType.AUTHORIZATION_CODE - AuthorizationGrantType.CLIENT_CREDENTIALS.value -> AuthorizationGrantType.CLIENT_CREDENTIALS - AuthorizationGrantType.REFRESH_TOKEN.value -> AuthorizationGrantType.REFRESH_TOKEN - else -> { - AuthorizationGrantType(string) - } - } - } - - val clientAuthenticationMethods = this[RegisteredClient.clientAuthenticationMethods].split(",").toSet() - val authorizationGrantTypes = this[RegisteredClient.authorizationGrantTypes].split(",").toSet() - val redirectUris = this[RegisteredClient.redirectUris]?.split(",").orEmpty().toSet() - val postLogoutRedirectUris = this[RegisteredClient.postLogoutRedirectUris]?.split(",").orEmpty().toSet() - val clientScopes = this[RegisteredClient.scopes].split(",").toSet() - - val builder = SpringRegisteredClient - .withId(this[RegisteredClient.id]) - .clientId(this[clientId]) - .clientIdIssuedAt(this[RegisteredClient.clientIdIssuedAt]) - .clientSecret(this[RegisteredClient.clientSecret]) - .clientSecretExpiresAt(this[RegisteredClient.clientSecretExpiresAt]) - .clientName(this[RegisteredClient.clientName]) - .clientAuthenticationMethods { - clientAuthenticationMethods.forEach { s -> - it.add(resolveClientAuthenticationMethods(s)) - } - } - .authorizationGrantTypes { - authorizationGrantTypes.forEach { s -> - it.add(resolveAuthorizationGrantType(s)) - } - } - .redirectUris { it.addAll(redirectUris) } - .postLogoutRedirectUris { it.addAll(postLogoutRedirectUris) } - .scopes { it.addAll(clientScopes) } - .clientSettings(ClientSettings.withSettings(JsonUtil.jsonToMap(this[clientSettings])).build()) - - val tokenSettingsMap = JsonUtil.jsonToMap(this[tokenSettings]) - val withSettings = TokenSettings.withSettings(tokenSettingsMap) - if (tokenSettingsMap.containsKey(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT)) { - withSettings.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) - } - builder.tokenSettings(withSettings.build()) - - return builder.build() -} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AccountApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AccountApiService.kt new file mode 100644 index 00000000..1e5cab0b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AccountApiService.kt @@ -0,0 +1,63 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.Account +import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount +import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccountSource +import dev.usbharu.hideout.domain.mastodon.model.generated.Role +import dev.usbharu.hideout.service.core.Transaction +import dev.usbharu.hideout.service.mastodon.AccountService +import org.springframework.stereotype.Service + +@Service +interface AccountApiService { + suspend fun verifyCredentials(userid: Long): CredentialAccount +} + + +@Service +class AccountApiServiceImpl(private val accountService: AccountService, private val transaction: Transaction) : + AccountApiService { + override suspend fun verifyCredentials(userid: Long): CredentialAccount = transaction.transaction { + val account = accountService.findById(userid) + of(account) + } + + private fun of(account: Account): CredentialAccount { + return CredentialAccount( + id = account.id, + username = account.username, + acct = account.acct, + url = account.url, + displayName = account.displayName, + note = account.note, + avatar = account.avatar, + avatarStatic = account.avatarStatic, + header = account.header, + headerStatic = account.headerStatic, + locked = account.locked, + fields = account.fields, + emojis = account.emojis, + bot = account.bot, + group = account.group, + discoverable = account.discoverable, + createdAt = account.createdAt, + lastStatusAt = account.lastStatusAt, + statusesCount = account.statusesCount, + followersCount = account.followersCount, + noindex = account.noindex, + moved = account.moved, + suspendex = account.suspendex, + limited = account.limited, + followingCount = account.followingCount, + source = CredentialAccountSource( + account.note, + account.fields, + CredentialAccountSource.Privacy.public, + false, + 0 + ), + role = Role(0, "Admin", "", 32) + ) + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AppApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AppApiService.kt new file mode 100644 index 00000000..85dd1be1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AppApiService.kt @@ -0,0 +1,61 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.Application +import dev.usbharu.hideout.domain.mastodon.model.generated.AppsRequest +import dev.usbharu.hideout.service.auth.SecureTokenGenerator +import dev.usbharu.hideout.service.core.Transaction +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.security.oauth2.core.ClientAuthenticationMethod +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings +import org.springframework.stereotype.Service +import java.util.* + +@Service +interface AppApiService { + suspend fun createApp(appsRequest: AppsRequest): Application +} + +@Service +class AppApiServiceImpl( + private val registeredClientRepository: RegisteredClientRepository, + private val secureTokenGenerator: SecureTokenGenerator, + private val passwordEncoder: PasswordEncoder, + private val transaction: Transaction +) : AppApiService { + override suspend fun createApp(appsRequest: AppsRequest): Application { + return transaction.transaction { + val id = UUID.randomUUID().toString() + val clientSecret = secureTokenGenerator.generate() + val registeredClient = RegisteredClient.withId(id) + .clientId(id) + .clientSecret(passwordEncoder.encode(clientSecret)) + .clientName(appsRequest.clientName) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(appsRequest.redirectUris) + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .scopes { it.addAll(parseScope(appsRequest.scopes.orEmpty())) } + .build() + registeredClientRepository.save(registeredClient) + + Application( + appsRequest.clientName, + "invalid-vapid-key", + appsRequest.website, + id, + clientSecret + ) + } + } + + private fun parseScope(string: String): Set { + return string.split(" ").toSet() + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/InstanceApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/InstanceApiService.kt new file mode 100644 index 00000000..92bff65b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/InstanceApiService.kt @@ -0,0 +1,83 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.config.ApplicationConfig +import dev.usbharu.hideout.domain.mastodon.model.generated.* +import org.springframework.stereotype.Service +import java.net.URL + +@Service +interface InstanceApiService { + suspend fun v1Instance(): V1Instance +} + +@Service +class InstanceApiServiceImpl(private val applicationConfig: ApplicationConfig) : InstanceApiService { + override suspend fun v1Instance(): V1Instance { + val url = applicationConfig.url + val url1 = URL(url) + return V1Instance( + uri = url1.host, + title = "Hideout Server", + shortDescription = "Hideout test server", + description = "This server is operated for testing of Hideout. We are not responsible for any events that occur when associating with this server", + email = "i@usbharu.dev", + version = "0.0.1", + urls = V1InstanceUrls("wss://${url1.host}"), + stats = V1InstanceStats(1, 0, 0), + thumbnail = null, + languages = listOf("ja-JP"), + registrations = false, + approvalRequired = false, + invitesEnabled = false, + configuration = V1InstanceConfiguration( + accounts = V1InstanceConfigurationAccounts(1), + statuses = V1InstanceConfigurationStatuses( + 300, + 4, + 23 + ), + mediaAttachments = V1InstanceConfigurationMediaAttachments( + listOf(), + 0, + 0, + 0, + 0 + ), + polls = V1InstanceConfigurationPolls( + 0, + 0, + 0, + 0 + ) + ), + contactAccount = Account( + id = "0", + username = "", + acct = "", + url = "", + displayName = "", + note = "", + avatar = "", + avatarStatic = "", + header = "", + headerStatic = "", + locked = false, + fields = emptyList(), + emojis = emptyList(), + bot = false, + group = false, + discoverable = false, + createdAt = "0", + lastStatusAt = "0", + statusesCount = 1, + followersCount = 0, + noindex = false, + moved = false, + suspendex = false, + limited = false, + followingCount = 0 + ), + rules = emptyList() + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt new file mode 100644 index 00000000..17c8c794 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt @@ -0,0 +1,105 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest +import dev.usbharu.hideout.domain.model.UserDetailsImpl +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility +import dev.usbharu.hideout.exception.FailedToGetResourcesException +import dev.usbharu.hideout.query.PostQueryService +import dev.usbharu.hideout.query.UserQueryService +import dev.usbharu.hideout.service.mastodon.AccountService +import dev.usbharu.hideout.service.post.PostService +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +interface StatusesApiService { + suspend fun postStatus(statusesRequest: StatusesRequest, user: UserDetailsImpl): Status +} + + +@Service +class StatsesApiServiceImpl( + private val postService: PostService, + private val accountService: AccountService, + private val postQueryService: PostQueryService, + private val userQueryService: UserQueryService +) : + StatusesApiService { + override suspend fun postStatus(statusesRequest: StatusesRequest, user: UserDetailsImpl): Status { + + val visibility = when (statusesRequest.visibility) { + StatusesRequest.Visibility.public -> Visibility.PUBLIC + StatusesRequest.Visibility.unlisted -> Visibility.UNLISTED + StatusesRequest.Visibility.private -> Visibility.FOLLOWERS + StatusesRequest.Visibility.direct -> Visibility.DIRECT + null -> Visibility.PUBLIC + } + + val post = postService.createLocal( + PostCreateDto( + statusesRequest.status.orEmpty(), + statusesRequest.spoilerText, + visibility, + null, + statusesRequest.inReplyToId?.toLongOrNull(), + user.id + ) + ) + val account = accountService.findById(user.id) + + val postVisibility = when (statusesRequest.visibility) { + StatusesRequest.Visibility.public -> Status.Visibility.public + StatusesRequest.Visibility.unlisted -> Status.Visibility.unlisted + StatusesRequest.Visibility.private -> Status.Visibility.private + StatusesRequest.Visibility.direct -> Status.Visibility.direct + null -> Status.Visibility.public + } + + val replyUser = if (post.replyId != null) { + try { + userQueryService.findById(postQueryService.findById(post.replyId).userId).id + } catch (e: FailedToGetResourcesException) { + null + } + } else { + null + } + + + return Status( + id = post.id.toString(), + uri = post.apId, + createdAt = Instant.ofEpochMilli(post.createdAt).toString(), + account = account, + content = post.text, + visibility = postVisibility, + sensitive = post.sensitive, + spoilerText = post.overview.orEmpty(), + mediaAttachments = emptyList(), + mentions = emptyList(), + tags = emptyList(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + repliesCount = 0, + url = post.url, + post.replyId?.toString(), + inReplyToAccountId = replyUser?.toString(), + reblog = null, + language = null, + text = post.text, + editedAt = null, + application = null, + poll = null, + card = null, + favourited = null, + reblogged = null, + muted = null, + bookmarked = null, + pinned = null, + filtered = null + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationConsentService.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationConsentService.kt index aff981c3..9fb968e0 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationConsentService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationConsentService.kt @@ -1,7 +1,10 @@ package dev.usbharu.hideout.service.auth +import dev.usbharu.hideout.service.core.Transaction +import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.transaction import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository @@ -9,22 +12,38 @@ import org.springframework.stereotype.Service import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent as AuthorizationConsent @Service -class ExposedOAuth2AuthorizationConsentService(private val registeredClientRepository: RegisteredClientRepository) : +class ExposedOAuth2AuthorizationConsentService( + private val registeredClientRepository: RegisteredClientRepository, + private val transaction: Transaction, + private val database: Database +) : OAuth2AuthorizationConsentService { - override fun save(authorizationConsent: AuthorizationConsent?) { + + init { + transaction(database) { + SchemaUtils.create(OAuth2AuthorizationConsent) + SchemaUtils.createMissingTablesAndColumns(OAuth2AuthorizationConsent) + } + } + + + override fun save(authorizationConsent: AuthorizationConsent?) = runBlocking { requireNotNull(authorizationConsent) - val singleOrNull = - OAuth2AuthorizationConsent.select { - OAuth2AuthorizationConsent.registeredClientId - .eq(authorizationConsent.registeredClientId) - .and(OAuth2AuthorizationConsent.principalName.eq(authorizationConsent.principalName)) - } - .singleOrNull() - if (singleOrNull == null) { - OAuth2AuthorizationConsent.insert { - it[registeredClientId] = authorizationConsent.registeredClientId - it[principalName] = authorizationConsent.principalName - it[authorities] = authorizationConsent.authorities.joinToString(",") + transaction.transaction { + + val singleOrNull = + OAuth2AuthorizationConsent.select { + OAuth2AuthorizationConsent.registeredClientId + .eq(authorizationConsent.registeredClientId) + .and(OAuth2AuthorizationConsent.principalName.eq(authorizationConsent.principalName)) + } + .singleOrNull() + if (singleOrNull == null) { + OAuth2AuthorizationConsent.insert { + it[registeredClientId] = authorizationConsent.registeredClientId + it[principalName] = authorizationConsent.principalName + it[authorities] = authorizationConsent.authorities.joinToString(",") + } } } } @@ -38,15 +57,17 @@ class ExposedOAuth2AuthorizationConsentService(private val registeredClientRepos } } - override fun findById(registeredClientId: String?, principalName: String?): AuthorizationConsent? { + override fun findById(registeredClientId: String?, principalName: String?): AuthorizationConsent? = runBlocking { requireNotNull(registeredClientId) requireNotNull(principalName) + transaction.transaction { - return OAuth2AuthorizationConsent.select { - (OAuth2AuthorizationConsent.registeredClientId eq registeredClientId) - .and(OAuth2AuthorizationConsent.principalName eq principalName) + OAuth2AuthorizationConsent.select { + (OAuth2AuthorizationConsent.registeredClientId eq registeredClientId) + .and(OAuth2AuthorizationConsent.principalName eq principalName) + } + .singleOrNull()?.toAuthorizationConsent() } - .singleOrNull()?.toAuthorizationConsent() } fun ResultRow.toAuthorizationConsent(): AuthorizationConsent { diff --git a/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt index 9e25e117..c1d4b287 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt @@ -1,9 +1,18 @@ package dev.usbharu.hideout.service.auth -import dev.usbharu.hideout.util.JsonUtil +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.domain.model.UserDetailsImpl +import dev.usbharu.hideout.domain.model.UserDetailsMixin +import dev.usbharu.hideout.service.core.Transaction +import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.javatime.timestamp +import org.jetbrains.exposed.sql.transactions.transaction +import org.springframework.security.jackson2.CoreJackson2Module +import org.springframework.security.jackson2.SecurityJackson2Modules import org.springframework.security.oauth2.core.* import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames import org.springframework.security.oauth2.core.oidc.OidcIdToken @@ -13,96 +22,114 @@ import org.springframework.security.oauth2.server.authorization.OAuth2Authorizat import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService import org.springframework.security.oauth2.server.authorization.OAuth2TokenType import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository +import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module import org.springframework.stereotype.Service @Service -class ExposedOAuth2AuthorizationService(private val registeredClientRepository: RegisteredClientRepository) : +class ExposedOAuth2AuthorizationService( + private val registeredClientRepository: RegisteredClientRepository, + private val transaction: Transaction, + private val database: Database +) : OAuth2AuthorizationService { - override fun save(authorization: OAuth2Authorization?) { + + init { + transaction(database) { + SchemaUtils.create(Authorization) + SchemaUtils.createMissingTablesAndColumns(Authorization) + } + } + + override fun save(authorization: OAuth2Authorization?): Unit = runBlocking { requireNotNull(authorization) - val singleOrNull = Authorization.select { Authorization.id eq authorization.id }.singleOrNull() - if (singleOrNull == null) { - val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java) - val accessToken = authorization.getToken(OAuth2AccessToken::class.java) - val refreshToken = authorization.getToken(OAuth2RefreshToken::class.java) - val oidcIdToken = authorization.getToken(OidcIdToken::class.java) - val userCode = authorization.getToken(OAuth2UserCode::class.java) - val deviceCode = authorization.getToken(OAuth2DeviceCode::class.java) - Authorization.insert { - it[id] = authorization.id - it[registeredClientId] = authorization.registeredClientId - it[principalName] = authorization.principalName - it[authorizationGrantType] = authorization.authorizationGrantType.value - it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isEmpty() } - it[attributes] = JsonUtil.mapToJson(authorization.attributes) - it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) - it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue - it[authorizationCodeIssuedAt] = authorizationCodeToken?.token?.issuedAt - it[authorizationCodeExpiresAt] = authorizationCodeToken?.token?.expiresAt - it[authorizationCodeMetadata] = authorizationCodeToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[accessTokenValue] = accessToken?.token?.tokenValue - it[accessTokenIssuedAt] = accessToken?.token?.issuedAt - it[accessTokenExpiresAt] = accessToken?.token?.expiresAt - it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[accessTokenType] = accessToken?.token?.tokenType?.value - it[accessTokenScopes] = accessToken?.run { token.scopes.joinToString(",").takeIf { it.isEmpty() } } - it[refreshTokenValue] = refreshToken?.token?.tokenValue - it[refreshTokenIssuedAt] = refreshToken?.token?.issuedAt - it[refreshTokenExpiresAt] = refreshToken?.token?.expiresAt - it[refreshTokenMetadata] = refreshToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[oidcIdTokenValue] = oidcIdToken?.token?.tokenValue - it[oidcIdTokenIssuedAt] = oidcIdToken?.token?.issuedAt - it[oidcIdTokenExpiresAt] = oidcIdToken?.token?.expiresAt - it[oidcIdTokenMetadata] = oidcIdToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[userCodeValue] = userCode?.token?.tokenValue - it[userCodeIssuedAt] = userCode?.token?.issuedAt - it[userCodeExpiresAt] = userCode?.token?.expiresAt - it[userCodeMetadata] = userCode?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[deviceCodeValue] = deviceCode?.token?.tokenValue - it[deviceCodeIssuedAt] = deviceCode?.token?.issuedAt - it[deviceCodeExpiresAt] = deviceCode?.token?.expiresAt - it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - } - } else { - val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java) - val accessToken = authorization.getToken(OAuth2AccessToken::class.java) - val refreshToken = authorization.getToken(OAuth2RefreshToken::class.java) - val oidcIdToken = authorization.getToken(OidcIdToken::class.java) - val userCode = authorization.getToken(OAuth2UserCode::class.java) - val deviceCode = authorization.getToken(OAuth2DeviceCode::class.java) - Authorization.update({ Authorization.id eq authorization.id }) { - it[registeredClientId] = authorization.registeredClientId - it[principalName] = authorization.principalName - it[authorizationGrantType] = authorization.authorizationGrantType.value - it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isEmpty() } - it[attributes] = JsonUtil.mapToJson(authorization.attributes) - it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) - it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue - it[authorizationCodeIssuedAt] = authorizationCodeToken?.token?.issuedAt - it[authorizationCodeExpiresAt] = authorizationCodeToken?.token?.expiresAt - it[authorizationCodeMetadata] = authorizationCodeToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[accessTokenValue] = accessToken?.token?.tokenValue - it[accessTokenIssuedAt] = accessToken?.token?.issuedAt - it[accessTokenExpiresAt] = accessToken?.token?.expiresAt - it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[accessTokenType] = accessToken?.token?.tokenType?.value - it[accessTokenScopes] = accessToken?.token?.scopes?.joinToString(",")?.takeIf { it.isEmpty() } - it[refreshTokenValue] = refreshToken?.token?.tokenValue - it[refreshTokenIssuedAt] = refreshToken?.token?.issuedAt - it[refreshTokenExpiresAt] = refreshToken?.token?.expiresAt - it[refreshTokenMetadata] = refreshToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[oidcIdTokenValue] = oidcIdToken?.token?.tokenValue - it[oidcIdTokenIssuedAt] = oidcIdToken?.token?.issuedAt - it[oidcIdTokenExpiresAt] = oidcIdToken?.token?.expiresAt - it[oidcIdTokenMetadata] = oidcIdToken?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[userCodeValue] = userCode?.token?.tokenValue - it[userCodeIssuedAt] = userCode?.token?.issuedAt - it[userCodeExpiresAt] = userCode?.token?.expiresAt - it[userCodeMetadata] = userCode?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } - it[deviceCodeValue] = deviceCode?.token?.tokenValue - it[deviceCodeIssuedAt] = deviceCode?.token?.issuedAt - it[deviceCodeExpiresAt] = deviceCode?.token?.expiresAt - it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> JsonUtil.mapToJson(it1) } + transaction.transaction { + val singleOrNull = Authorization.select { Authorization.id eq authorization.id }.singleOrNull() + if (singleOrNull == null) { + val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java) + val accessToken = authorization.getToken(OAuth2AccessToken::class.java) + val refreshToken = authorization.getToken(OAuth2RefreshToken::class.java) + val oidcIdToken = authorization.getToken(OidcIdToken::class.java) + val userCode = authorization.getToken(OAuth2UserCode::class.java) + val deviceCode = authorization.getToken(OAuth2DeviceCode::class.java) + Authorization.insert { + it[id] = authorization.id + it[registeredClientId] = authorization.registeredClientId + it[principalName] = authorization.principalName + it[authorizationGrantType] = authorization.authorizationGrantType.value + it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isNotEmpty() } + it[attributes] = mapToJson(authorization.attributes) + it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) + it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue + it[authorizationCodeIssuedAt] = authorizationCodeToken?.token?.issuedAt + it[authorizationCodeExpiresAt] = authorizationCodeToken?.token?.expiresAt + it[authorizationCodeMetadata] = + authorizationCodeToken?.metadata?.let { it1 -> mapToJson(it1) } + it[accessTokenValue] = accessToken?.token?.tokenValue + it[accessTokenIssuedAt] = accessToken?.token?.issuedAt + it[accessTokenExpiresAt] = accessToken?.token?.expiresAt + it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> mapToJson(it1) } + it[accessTokenType] = accessToken?.token?.tokenType?.value + it[accessTokenScopes] = + accessToken?.run { token.scopes.joinToString(",").takeIf { it.isNotEmpty() } } + it[refreshTokenValue] = refreshToken?.token?.tokenValue + it[refreshTokenIssuedAt] = refreshToken?.token?.issuedAt + it[refreshTokenExpiresAt] = refreshToken?.token?.expiresAt + it[refreshTokenMetadata] = refreshToken?.metadata?.let { it1 -> mapToJson(it1) } + it[oidcIdTokenValue] = oidcIdToken?.token?.tokenValue + it[oidcIdTokenIssuedAt] = oidcIdToken?.token?.issuedAt + it[oidcIdTokenExpiresAt] = oidcIdToken?.token?.expiresAt + it[oidcIdTokenMetadata] = oidcIdToken?.metadata?.let { it1 -> mapToJson(it1) } + it[userCodeValue] = userCode?.token?.tokenValue + it[userCodeIssuedAt] = userCode?.token?.issuedAt + it[userCodeExpiresAt] = userCode?.token?.expiresAt + it[userCodeMetadata] = userCode?.metadata?.let { it1 -> mapToJson(it1) } + it[deviceCodeValue] = deviceCode?.token?.tokenValue + it[deviceCodeIssuedAt] = deviceCode?.token?.issuedAt + it[deviceCodeExpiresAt] = deviceCode?.token?.expiresAt + it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> mapToJson(it1) } + } + } else { + val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java) + val accessToken = authorization.getToken(OAuth2AccessToken::class.java) + val refreshToken = authorization.getToken(OAuth2RefreshToken::class.java) + val oidcIdToken = authorization.getToken(OidcIdToken::class.java) + val userCode = authorization.getToken(OAuth2UserCode::class.java) + val deviceCode = authorization.getToken(OAuth2DeviceCode::class.java) + Authorization.update({ Authorization.id eq authorization.id }) { + it[registeredClientId] = authorization.registeredClientId + it[principalName] = authorization.principalName + it[authorizationGrantType] = authorization.authorizationGrantType.value + it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isNotEmpty() } + it[attributes] = mapToJson(authorization.attributes) + it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) + it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue + it[authorizationCodeIssuedAt] = authorizationCodeToken?.token?.issuedAt + it[authorizationCodeExpiresAt] = authorizationCodeToken?.token?.expiresAt + it[authorizationCodeMetadata] = + authorizationCodeToken?.metadata?.let { it1 -> mapToJson(it1) } + it[accessTokenValue] = accessToken?.token?.tokenValue + it[accessTokenIssuedAt] = accessToken?.token?.issuedAt + it[accessTokenExpiresAt] = accessToken?.token?.expiresAt + it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> mapToJson(it1) } + it[accessTokenType] = accessToken?.token?.tokenType?.value + it[accessTokenScopes] = accessToken?.token?.scopes?.joinToString(",")?.takeIf { it.isNotEmpty() } + it[refreshTokenValue] = refreshToken?.token?.tokenValue + it[refreshTokenIssuedAt] = refreshToken?.token?.issuedAt + it[refreshTokenExpiresAt] = refreshToken?.token?.expiresAt + it[refreshTokenMetadata] = refreshToken?.metadata?.let { it1 -> mapToJson(it1) } + it[oidcIdTokenValue] = oidcIdToken?.token?.tokenValue + it[oidcIdTokenIssuedAt] = oidcIdToken?.token?.issuedAt + it[oidcIdTokenExpiresAt] = oidcIdToken?.token?.expiresAt + it[oidcIdTokenMetadata] = oidcIdToken?.metadata?.let { it1 -> mapToJson(it1) } + it[userCodeValue] = userCode?.token?.tokenValue + it[userCodeIssuedAt] = userCode?.token?.issuedAt + it[userCodeExpiresAt] = userCode?.token?.expiresAt + it[userCodeMetadata] = userCode?.metadata?.let { it1 -> mapToJson(it1) } + it[deviceCodeValue] = deviceCode?.token?.tokenValue + it[deviceCodeIssuedAt] = deviceCode?.token?.issuedAt + it[deviceCodeExpiresAt] = deviceCode?.token?.expiresAt + it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> mapToJson(it1) } + } } } } @@ -121,57 +148,61 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: return Authorization.select { Authorization.id eq id }.singleOrNull()?.toAuthorization() } - override fun findByToken(token: String?, tokenType: OAuth2TokenType?): OAuth2Authorization? { + override fun findByToken(token: String?, tokenType: OAuth2TokenType?): OAuth2Authorization? = runBlocking { requireNotNull(token) - return when (tokenType?.value) { - null -> { - Authorization.select { - Authorization.authorizationCodeValue eq token - }.orWhere { - Authorization.accessTokenValue eq token - }.orWhere { - Authorization.oidcIdTokenValue eq token - }.orWhere { - Authorization.refreshTokenValue eq token - }.orWhere { - Authorization.userCodeValue eq token - }.orWhere { - Authorization.deviceCodeValue eq token + transaction.transaction { + + + when (tokenType?.value) { + null -> { + Authorization.select { + Authorization.authorizationCodeValue eq token + }.orWhere { + Authorization.accessTokenValue eq token + }.orWhere { + Authorization.oidcIdTokenValue eq token + }.orWhere { + Authorization.refreshTokenValue eq token + }.orWhere { + Authorization.userCodeValue eq token + }.orWhere { + Authorization.deviceCodeValue eq token + } } - } - OAuth2ParameterNames.STATE -> { - Authorization.select { Authorization.state eq token } - } + OAuth2ParameterNames.STATE -> { + Authorization.select { Authorization.state eq token } + } - OAuth2ParameterNames.CODE -> { - Authorization.select { Authorization.authorizationCodeValue eq token } - } + OAuth2ParameterNames.CODE -> { + Authorization.select { Authorization.authorizationCodeValue eq token } + } - OAuth2ParameterNames.ACCESS_TOKEN -> { - Authorization.select { Authorization.accessTokenValue eq token } - } + OAuth2ParameterNames.ACCESS_TOKEN -> { + Authorization.select { Authorization.accessTokenValue eq token } + } - OidcParameterNames.ID_TOKEN -> { - Authorization.select { Authorization.oidcIdTokenValue eq token } - } + OidcParameterNames.ID_TOKEN -> { + Authorization.select { Authorization.oidcIdTokenValue eq token } + } - OAuth2ParameterNames.REFRESH_TOKEN -> { - Authorization.select { Authorization.refreshTokenValue eq token } - } + OAuth2ParameterNames.REFRESH_TOKEN -> { + Authorization.select { Authorization.refreshTokenValue eq token } + } - OAuth2ParameterNames.USER_CODE -> { - Authorization.select { Authorization.userCodeValue eq token } - } + OAuth2ParameterNames.USER_CODE -> { + Authorization.select { Authorization.userCodeValue eq token } + } - OAuth2ParameterNames.DEVICE_CODE -> { - Authorization.select { Authorization.deviceCodeValue eq token } - } + OAuth2ParameterNames.DEVICE_CODE -> { + Authorization.select { Authorization.deviceCodeValue eq token } + } - else -> { - null - } - }?.singleOrNull()?.toAuthorization() + else -> { + null + } + }?.singleOrNull()?.toAuthorization() + } } fun ResultRow.toAuthorization(): OAuth2Authorization { @@ -184,7 +215,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val principalName = this[Authorization.principalName] val authorizationGrantType = this[Authorization.authorizationGrantType] val authorizedScopes = this[Authorization.authorizedScopes]?.split(",").orEmpty().toSet() - val attributes = this[Authorization.attributes]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + val attributes = this[Authorization.attributes]?.let { jsonToMap(it) }.orEmpty() builder.id(id).principalName(principalName) .authorizationGrantType(AuthorizationGrantType(authorizationGrantType)).authorizedScopes(authorizedScopes) @@ -195,12 +226,12 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: builder.attribute(OAuth2ParameterNames.STATE, state) } - val authorizationCodeValue = this[Authorization.authorizationCodeValue] - if (authorizationCodeValue.isNullOrBlank()) { + val authorizationCodeValue = this[Authorization.authorizationCodeValue].orEmpty() + if (authorizationCodeValue.isNotBlank()) { val authorizationCodeIssuedAt = this[Authorization.authorizationCodeIssuedAt] val authorizationCodeExpiresAt = this[Authorization.authorizationCodeExpiresAt] val authorizationCodeMetadata = this[Authorization.authorizationCodeMetadata]?.let { - JsonUtil.jsonToMap( + jsonToMap( it ) }.orEmpty() @@ -216,7 +247,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val accessTokenIssuedAt = this[Authorization.accessTokenIssuedAt] val accessTokenExpiresAt = this[Authorization.accessTokenExpiresAt] val accessTokenMetadata = - this[Authorization.accessTokenMetadata]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + this[Authorization.accessTokenMetadata]?.let { jsonToMap(it) }.orEmpty() val accessTokenType = if (this[Authorization.accessTokenType].equals(OAuth2AccessToken.TokenType.BEARER.value, true)) { OAuth2AccessToken.TokenType.BEARER @@ -242,7 +273,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val oidcTokenIssuedAt = this[Authorization.oidcIdTokenIssuedAt] val oidcTokenExpiresAt = this[Authorization.oidcIdTokenExpiresAt] val oidcTokenMetadata = - this[Authorization.oidcIdTokenMetadata]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + this[Authorization.oidcIdTokenMetadata]?.let { jsonToMap(it) }.orEmpty() val oidcIdToken = OidcIdToken( oidcIdTokenValue, @@ -259,7 +290,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val refreshTokenIssuedAt = this[Authorization.refreshTokenIssuedAt] val refreshTokenExpiresAt = this[Authorization.refreshTokenExpiresAt] val refreshTokenMetadata = - this[Authorization.refreshTokenMetadata]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + this[Authorization.refreshTokenMetadata]?.let { jsonToMap(it) }.orEmpty() val oAuth2RefreshToken = OAuth2RefreshToken(refreshTokenValue, refreshTokenIssuedAt, refreshTokenExpiresAt) @@ -271,7 +302,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val userCodeIssuedAt = this[Authorization.userCodeIssuedAt] val userCodeExpiresAt = this[Authorization.userCodeExpiresAt] val userCodeMetadata = - this[Authorization.userCodeMetadata]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + this[Authorization.userCodeMetadata]?.let { jsonToMap(it) }.orEmpty() val oAuth2UserCode = OAuth2UserCode(userCodeValue, userCodeIssuedAt, userCodeExpiresAt) builder.token(oAuth2UserCode) { it.putAll(userCodeMetadata) } } @@ -281,7 +312,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: val deviceCodeIssuedAt = this[Authorization.deviceCodeIssuedAt] val deviceCodeExpiresAt = this[Authorization.deviceCodeExpiresAt] val deviceCodeMetadata = - this[Authorization.deviceCodeMetadata]?.let { JsonUtil.jsonToMap(it) }.orEmpty() + this[Authorization.deviceCodeMetadata]?.let { jsonToMap(it) }.orEmpty() val oAuth2DeviceCode = OAuth2DeviceCode(deviceCodeValue, deviceCodeIssuedAt, deviceCodeExpiresAt) builder.token(oAuth2DeviceCode) { it.putAll(deviceCodeMetadata) } @@ -289,9 +320,28 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository: return builder.build() } + + private fun mapToJson(map: Map<*, *>): String = objectMapper.writeValueAsString(map) + + private fun jsonToMap(json: String): Map = objectMapper.readValue(json) + + companion object { + val objectMapper: ObjectMapper = ObjectMapper() + + init { + + val classLoader = ExposedOAuth2AuthorizationService::class.java.classLoader + val modules = SecurityJackson2Modules.getModules(classLoader) + this.objectMapper.registerModules(JavaTimeModule()) + this.objectMapper.registerModules(modules) + this.objectMapper.registerModules(OAuth2AuthorizationServerJackson2Module()) + this.objectMapper.registerModules(CoreJackson2Module()) + this.objectMapper.addMixIn(UserDetailsImpl::class.java, UserDetailsMixin::class.java) + } + } } -object Authorization : Table("authorization") { +object Authorization : Table("application_authorization") { val id = varchar("id", 255) val registeredClientId = varchar("registered_client_id", 255) val principalName = varchar("principal_name", 255) diff --git a/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGenerator.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGenerator.kt new file mode 100644 index 00000000..81b7a793 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGenerator.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.service.auth + +import org.springframework.stereotype.Component + +@Component +interface SecureTokenGenerator { + fun generate(): String +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGeneratorImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGeneratorImpl.kt new file mode 100644 index 00000000..9a36f5e7 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/SecureTokenGeneratorImpl.kt @@ -0,0 +1,18 @@ +package dev.usbharu.hideout.service.auth + +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.* + +@Component +class SecureTokenGeneratorImpl : SecureTokenGenerator { + override fun generate(): String { + + val byteArray = ByteArray(16) + val secureRandom = SecureRandom() + secureRandom.nextBytes(byteArray) + + + return Base64.getUrlEncoder().encodeToString(byteArray) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/auth/UserDetailsServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/UserDetailsServiceImpl.kt index 34bd9b60..adb9c0ab 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/auth/UserDetailsServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/UserDetailsServiceImpl.kt @@ -1,27 +1,38 @@ package dev.usbharu.hideout.service.auth +import dev.usbharu.hideout.config.ApplicationConfig +import dev.usbharu.hideout.domain.model.UserDetailsImpl import dev.usbharu.hideout.query.UserQueryService import dev.usbharu.hideout.service.core.Transaction import kotlinx.coroutines.runBlocking -import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.stereotype.Service +import java.net.URL @Service -class UserDetailsServiceImpl(private val userQueryService: UserQueryService, private val transaction: Transaction) : +class UserDetailsServiceImpl( + private val userQueryService: UserQueryService, + private val applicationConfig: ApplicationConfig, + private val transaction: Transaction +) : UserDetailsService { override fun loadUserByUsername(username: String?): UserDetails = runBlocking { if (username == null) { throw UsernameNotFoundException("$username not found") } transaction.transaction { - val findById = userQueryService.findByNameAndDomain(username, "") - User( + val findById = userQueryService.findByNameAndDomain(username, URL(applicationConfig.url).host) + UserDetailsImpl( + findById.id, findById.name, findById.password, - emptyList() + true, + true, + true, + true, + mutableListOf() ) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt b/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt new file mode 100644 index 00000000..d3347aca --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt @@ -0,0 +1,39 @@ +package dev.usbharu.hideout.service.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.Account +import dev.usbharu.hideout.query.UserQueryService +import org.springframework.stereotype.Service + +@Service +interface AccountService { + suspend fun findById(id: Long): Account +} + +@Service +class AccountServiceImpl(private val userQueryService: UserQueryService) : AccountService { + override suspend fun findById(id: Long): Account { + val findById = userQueryService.findById(id) + return Account( + id = findById.id.toString(), + username = findById.name, + acct = "${findById.name}@${findById.domain}", + url = findById.url, + displayName = findById.screenName, + note = findById.description, + avatar = findById.url + "/icon.jpg", + avatarStatic = findById.url + "/icon.jpg", + header = findById.url + "/header.jpg", + headerStatic = findById.url + "/header.jpg", + locked = false, + emptyList(), + emptyList(), + false, + false, + false, + findById.createdAt.toString(), + findById.createdAt.toString(), + 0, + 0, + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/user/UserAuthServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/user/UserAuthServiceImpl.kt index a4e0bba6..547aa367 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/user/UserAuthServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/user/UserAuthServiceImpl.kt @@ -2,8 +2,8 @@ package dev.usbharu.hideout.service.user import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.query.UserQueryService -import io.ktor.util.* import org.koin.core.annotation.Single +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.stereotype.Service import java.security.* import java.util.* @@ -15,8 +15,7 @@ class UserAuthServiceImpl( ) : UserAuthService { override fun hash(password: String): String { - val digest = sha256.digest(password.toByteArray(Charsets.UTF_8)) - return hex(digest) + return BCryptPasswordEncoder().encode(password) } override suspend fun usernameAlreadyUse(username: String): Boolean { diff --git a/src/main/kotlin/dev/usbharu/hideout/util/JsonUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/JsonUtil.kt index 25d282bc..958d0a90 100644 --- a/src/main/kotlin/dev/usbharu/hideout/util/JsonUtil.kt +++ b/src/main/kotlin/dev/usbharu/hideout/util/JsonUtil.kt @@ -1,11 +1,12 @@ package dev.usbharu.hideout.util import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue object JsonUtil { - val objectMapper = jacksonObjectMapper() + val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) fun mapToJson(map: Map<*, *>, objectMapper: ObjectMapper = this.objectMapper): String = objectMapper.writeValueAsString(map) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5683266b..37a09f1e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,12 +1,28 @@ hideout: + url: "https://test-hideout.usbharu.dev" database: - url: "jdbc:h2:./test;MODE=POSTGRESQL" + url: "jdbc:h2:./test-dev2;MODE=POSTGRESQL" driver: "org.h2.Driver" user: "" password: "" spring: + jackson: + serialization: + WRITE_DATES_AS_TIMESTAMPS: false datasource: driver-class-name: org.h2.Driver - url: "jdbc:h2:./test;MODE=POSTGRESQL" + url: "jdbc:h2:./test-dev2;MODE=POSTGRESQL" username: "" password: "" + + h2: + console: + enabled: true +server: + + tomcat: + basedir: tomcat + accesslog: + enabled: true + + port: 8081 diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 4593b633..25579496 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -4,7 +4,7 @@ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + @@ -12,4 +12,5 @@ + diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index dbcf6195..9b10b266 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -5,7 +5,182 @@ info: version: 1.0.0 servers: - url: 'https://test-hideout.usbharu.dev' + +tags: + - name: status + description: status + - name: account + description: account + - name: app + description: app + - name: instance + description: instance + paths: + /api/v2/instance: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + + /api/v1/instance/peers: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + type: string + + /api/v1/instance/activity: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/V1InstanceActivity" + + /api/v1/instance/rules: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Rule" + + /api/v1/instance/domain_blocks: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainBlock" + + /api/v1/instance/extended_description: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendedDescription" + + /api/v1/instance: + get: + tags: + - instance + security: + - { } + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/V1Instance" + + /api/v1/statuses: + post: + tags: + - status + security: + - OAuth2: + - "write:statuses" + requestBody: + description: 投稿する内容 + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusesRequest" + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + + /api/v1/apps: + post: + tags: + - app + security: + - { } + requestBody: + description: 作成するApp + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppsRequest" + + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Application" + + /api/v1/accounts/verify_credentials: + get: + tags: + - account + security: + - OAuth2: + - "read:accounts" + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/CredentialAccount" components: schemas: @@ -20,3 +195,1005 @@ components: type: string url: type: string + display_name: + type: string + note: + type: string + avatar: + type: string + avatar_static: + type: string + header: + type: string + header_static: + type: string + locked: + type: boolean + fields: + type: array + items: + $ref: "#/components/schemas/Field" + emojis: + type: array + items: + $ref: "#/components/schemas/CustomEmoji" + bot: + type: boolean + group: + type: boolean + discoverable: + type: boolean + nullable: true + noindex: + type: boolean + moved: + type: boolean + suspendex: + type: boolean + limited: + type: boolean + created_at: + type: string + last_status_at: + type: string + nullable: true + statuses_count: + type: integer + followers_count: + type: integer + following_count: + type: integer + required: + - id + - username + - acct + - url + - display_name + - note + - avatar + - avatar_static + - header + - header_static + - locked + - fields + - emojis + - bot + - group + - discoverable + - created_at + - last_status_at + - statuses_count + - followers_count + - followers_count + + CredentialAccount: + type: object + properties: + id: + type: string + username: + type: string + acct: + type: string + url: + type: string + display_name: + type: string + note: + type: string + avatar: + type: string + avatar_static: + type: string + header: + type: string + header_static: + type: string + locked: + type: boolean + fields: + type: array + items: + $ref: "#/components/schemas/Field" + emojis: + type: array + items: + $ref: "#/components/schemas/CustomEmoji" + bot: + type: boolean + group: + type: boolean + discoverable: + type: boolean + nullable: true + noindex: + type: boolean + moved: + type: boolean + suspendex: + type: boolean + limited: + type: boolean + created_at: + type: string + last_status_at: + type: string + nullable: true + statuses_count: + type: integer + followers_count: + type: integer + following_count: + type: integer + source: + $ref: "#/components/schemas/CredentialAccountSource" + role: + $ref: "#/components/schemas/Role" + required: + - id + - username + - acct + - url + - display_name + - note + - avatar + - avatar_static + - header + - header_static + - locked + - fields + - emojis + - bot + - group + - discoverable + - created_at + - last_status_at + - statuses_count + - followers_count + - followers_count + - source + + CredentialAccountSource: + type: object + properties: + note: + type: string + fields: + type: array + items: + $ref: "#/components/schemas/Field" + privacy: + type: string + enum: + - public + - unlisted + - private + - direct + sensitive: + type: boolean + follow_requests_count: + type: integer + + Role: + type: object + properties: + id: + type: integer + name: + type: string + color: + type: string + permissions: + type: integer + highlighted: + type: boolean + + Field: + type: object + properties: + name: + type: string + value: + type: string + verified_at: + type: string + nullable: true + required: + - name + - value + - verified_at + + CustomEmoji: + type: object + properties: + shortcode: + type: string + url: + type: string + static_url: + type: string + visible_in_picker: + type: boolean + category: + type: string + required: + - shortcode + - url + - static_url + - visible_in_picker + - category + + Status: + type: object + properties: + id: + type: string + uri: + type: string + created_at: + type: string + account: + $ref: "#/components/schemas/Account" + content: + type: string + visibility: + type: string + enum: + - public + - unlisted + - private + - direct + sensitive: + type: boolean + spoiler_text: + type: string + media_attachments: + type: array + items: + $ref: "#/components/schemas/MediaAttachment" + application: + $ref: "#/components/schemas/StatusApplication" + mentions: + type: array + items: + $ref: "#/components/schemas/StatusMention" + tags: + type: array + items: + $ref: "#/components/schemas/StatusTag" + emojis: + type: array + items: + $ref: "#/components/schemas/CustomEmoji" + reblogs_count: + type: integer + favourites_count: + type: integer + replies_count: + type: integer + url: + type: string + nullable: true + in_reply_to_id: + type: string + nullable: true + in_reply_to_account_id: + type: string + nullable: true + reblog: + $ref: "#/components/schemas/Status" + poll: + $ref: "#/components/schemas/Poll" + card: + $ref: "#/components/schemas/PreviewCard" + language: + type: string + nullable: true + text: + type: string + nullable: true + edited_at: + type: string + nullable: true + favourited: + type: boolean + reblogged: + type: boolean + muted: + type: boolean + bookmarked: + type: boolean + pinned: + type: boolean + filtered: + type: array + items: + $ref: "#/components/schemas/FilterResult" + required: + - id + - uri + - created_at + - account + - content + - visibility + - sensitive + - spoiler_text + - media_attachments + - mentions + - tags + - emojis + - reblogs_count + - favourites_count + - replies_count + - url + - in_reply_to_id + - in_reply_to_account_id + - language + - text + - edited_at + + MediaAttachment: + type: object + properties: + id: + type: string + type: + type: string + enum: + - unknown + - image + - gifv + - video + - audio + url: + type: string + preview_url: + type: string + remote_url: + type: string + nullable: true + description: + type: string + blurhash: + type: string + text_url: + type: string + + StatusApplication: + type: object + properties: + name: + type: string + website: + type: string + nullable: true + + StatusMention: + type: object + properties: + id: + type: string + username: + type: string + url: + type: string + acct: + type: string + + StatusTag: + type: object + properties: + name: + type: string + url: + type: string + Poll: + type: object + properties: + id: + type: string + expires_at: + type: string + nullable: true + expired: + type: boolean + multiple: + type: boolean + votes_count: + type: integer + voters_count: + type: integer + nullable: true + options: + type: array + items: + $ref: "#/components/schemas/PollOption" + emojis: + type: array + items: + $ref: "#/components/schemas/CustomEmoji" + voted: + type: boolean + own_votes: + type: array + items: + type: integer + + PollOption: + type: object + properties: + title: + type: string + votes_count: + type: integer + nullable: true + + PreviewCard: + type: object + properties: + url: + type: string + title: + type: string + description: + type: string + type: + type: string + enum: + - link + - photo + - video + - rich + author_name: + type: string + author_url: + type: string + provider_name: + type: string + provider_url: + type: string + html: + type: string + width: + type: integer + height: + type: integer + image: + type: string + nullable: true + embed_url: + type: string + blurhash: + type: string + nullable: true + + FilterResult: + type: object + properties: + filter: + $ref: "#/components/schemas/FilterResult" + keyword_matches: + type: array + items: + type: string + nullable: true + status_matches: + type: string + nullable: true + + Filter: + type: object + properties: + id: + type: string + title: + type: string + context: + type: string + enum: + - home + - notifications + - public + - thread + - account + expires_at: + type: string + nullable: true + filter_action: + type: string + enum: + - warn + - hide + keywords: + type: array + items: + $ref: "#/components/schemas/FilterKeyword" + statuses: + type: array + items: + $ref: "#/components/schemas/FilterStatus" + + FilterKeyword: + type: object + properties: + id: + type: string + keyword: + type: string + whole_word: + type: boolean + + FilterStatus: + type: object + properties: + id: + type: string + status_id: + type: string + + Instance: + type: object + properties: + domain: + type: string + title: + type: string + version: + type: string + source_url: + type: string + description: + type: string + usage: + $ref: "#/components/schemas/InstanceUsage" + thumbnail: + $ref: "#/components/schemas/InstanceThumbnail" + languages: + type: array + items: + type: string + configuration: + $ref: "#/components/schemas/InstanceConfiguration" + + + InstanceUsage: + type: object + properties: + users: + $ref: "#/components/schemas/InstanceUsageUsers" + + + InstanceUsageUsers: + type: object + properties: + active_month: + type: integer + + InstanceThumbnail: + type: object + properties: + blurhash: + type: string + versions: + $ref: "#/components/schemas/InstanceThumbnailVersions" + + InstanceThumbnailVersions: + type: object + properties: + "@1x": + type: string + "@2x": + type: string + + InstanceConfiguration: + type: object + properties: + urls: + $ref: "#/components/schemas/InstanceConfigurationUrls" + accounts: + $ref: "#/components/schemas/InstanceConfigurationAccounts" + statuses: + $ref: "#/components/schemas/InstanceConfigurationStatuses" + media_attachments: + $ref: "#/components/schemas/InstanceConfigurationMediaAttachments" + polls: + $ref: "#/components/schemas/InstanceConfigurationPolls" + translation: + $ref: "#/components/schemas/InstanceConfigurationTranslation" + registrations: + $ref: "#/components/schemas/InstanceConfigurationRegistrations" + contact: + $ref: "#/components/schemas/InstanceConfigurationContact" + rules: + type: array + items: + $ref: "#/components/schemas/Rule" + + InstanceConfigurationUrls: + type: object + properties: + streaming_api: + type: string + + InstanceConfigurationAccounts: + type: object + properties: + max_featured_tags: + type: integer + + InstanceConfigurationStatuses: + type: object + properties: + max_characters: + type: integer + max_media_attachments: + type: integer + characters_reserved_per_url: + type: integer + + InstanceConfigurationMediaAttachments: + type: object + properties: + supported_mime_types: + type: array + items: + type: string + image_size_limit: + type: integer + image_matrix_limit: + type: integer + video_size_limit: + type: integer + video_frame_rate_limit: + type: integer + video_matrix_limit: + type: integer + + InstanceConfigurationPolls: + type: object + properties: + max_options: + type: integer + max_characters_per_option: + type: integer + min_expiration: + type: integer + max_expiration: + type: integer + + InstanceConfigurationTranslation: + type: object + properties: + enabled: + type: boolean + + InstanceConfigurationRegistrations: + type: object + properties: + enabled: + type: boolean + approval_required: + type: boolean + message: + type: string + nullable: true + + InstanceConfigurationContact: + type: object + properties: + email: + type: string + account: + $ref: "#/components/schemas/Account" + + Rule: + type: object + properties: + id: + type: string + text: + type: string + + V1Instance: + type: object + properties: + uri: + type: string + title: + type: string + short_description: + type: string + description: + type: string + email: + type: string + version: + type: string + urls: + $ref: "#/components/schemas/V1InstanceUrls" + stats: + $ref: "#/components/schemas/V1InstanceStats" + thumbnail: + type: string + nullable: true + languages: + type: array + items: + type: string + registrations: + type: boolean + approval_required: + type: boolean + invites_enabled: + type: boolean + configuration: + $ref: "#/components/schemas/V1InstanceConfiguration" + contact_account: + $ref: "#/components/schemas/Account" + rules: + type: array + items: + $ref: "#/components/schemas/Rule" + required: + - uri + - title + - short_description + - description + - email + - version + - urls + - stats + - thumbnail + - languages + - registrations + - approval_required + - invites_enabled + - configuration + - contact_account + - rules + + V1InstanceUrls: + type: object + properties: + streaming_api: + type: string + required: + - streaming_api + + V1InstanceStats: + type: object + properties: + user_count: + type: integer + status_count: + type: integer + domain_count: + type: integer + required: + - user_count + - status_count + - domain_count + + V1InstanceConfiguration: + type: object + properties: + accounts: + $ref: "#/components/schemas/V1InstanceConfigurationAccounts" + statuses: + $ref: "#/components/schemas/V1InstanceConfigurationStatuses" + media_attachments: + $ref: "#/components/schemas/V1InstanceConfigurationMediaAttachments" + polls: + $ref: "#/components/schemas/V1InstanceConfigurationPolls" + required: + - accounts + - statuses + - media_attachments + - polls + + + V1InstanceConfigurationAccounts: + type: object + properties: + max_featured_tags: + type: integer + + V1InstanceConfigurationStatuses: + type: object + properties: + max_characters: + type: integer + max_media_attachments: + type: integer + characters_reserved_per_url: + type: integer + + V1InstanceConfigurationMediaAttachments: + type: object + properties: + supported_mime_types: + type: array + items: + type: string + image_size_limit: + type: integer + image_matrix_limit: + type: integer + video_size_limit: + type: integer + video_frame_rate_limit: + type: integer + video_matrix_limit: + type: integer + + V1InstanceConfigurationPolls: + type: object + properties: + max_options: + type: integer + max_characters_per_option: + type: integer + min_expiration: + type: integer + max_expiration: + type: integer + + V1InstanceActivity: + type: object + properties: + week: + type: integer + statuses: + type: integer + logins: + type: integer + registrations: + type: integer + + DomainBlock: + type: object + properties: + domain: + type: string + digest: + type: string + severity: + type: string + enum: + - silence + - suspend + comment: + type: string + + ExtendedDescription: + type: object + properties: + updated_at: + type: string + content: + type: string + + StatusesRequest: + type: object + properties: + status: + type: string + nullable: true + media_ids: + type: array + items: + type: string + poll: + $ref: "#/components/schemas/StatusesRequestPoll" + in_reply_to_id: + type: string + sensitive: + type: boolean + spoiler_text: + type: string + visibility: + type: string + enum: + - public + - unlisted + - private + - direct + language: + type: string + scheduled_at: + type: string + + StatusesRequestPoll: + type: object + properties: + options: + type: array + items: + type: string + expires_in: + type: integer + multiple: + type: boolean + hide_totals: + type: boolean + + Application: + type: object + properties: + name: + type: string + website: + type: string + nullable: true + vapid_key: + type: string + client_id: + type: string + client_secret: + type: string + required: + - name + - vapid_key + + AppsRequest: + type: object + properties: + client_name: + type: string + redirect_uris: + type: string + scopes: + type: string + website: + type: string + required: + - client_name + - redirect_uris + + securitySchemes: + OAuth2: + type: oauth2 + description: Mastodon Oauth + flows: + authorizationCode: + authorizationUrl: /oauth/authorize + tokenUrl: /oauth/token + scopes: + read:accounts: "" + read:blocks: "" + read:bookmarks: "" + read:favourites: "" + read:filters: "" + read:follows: "" + read:lists: "" + read:mutes: "" + read:notifications: "" + read:search: "" + read:statuses: "" + write:accounts: "" + write:blocks: "" + write:bookmarks: "" + write:conversations: "" + write:favourites: "" + write:filters: "" + write:follows: "" + write:lists: "" + write:media: "" + write:mutes: "" + write:notifications: "" + write:reports: "" + write:statuses: "" + admin:read:accounts: "" + admin:read:reports: "" + admin:read:domain_allows: "" + admin:read:domain_blocks: "" + admin:read:ip_blocks: "" + admin:read:email_domain_blocks: "" + admin:read:canonical_email_blocks: "" + admin:write:accounts: "" + admin:write:reports: "" + admin:write:domain_allows: "" + admin:write:domain_blocks: "" + admin:write:ip_blocks: "" + admin:write:email_domain_blocks: "" + admin:write:canonical_email_blocks: ""