Merge pull request #38 from usbharu/feature/mastodon-api

Feature/mastodon api
This commit is contained in:
usbharu 2023-09-22 23:35:55 +09:00 committed by GitHub
commit 887249c68d
26 changed files with 2208 additions and 292 deletions

1
.gitignore vendored
View File

@ -39,3 +39,4 @@ out/
/node_modules/
/src/main/web/generated/
/stats.html
/tomcat/

View File

@ -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<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
}
dependsOn("openApiGenerateServer")
mustRunAfter("openApiGenerateServer")
dependsOn("openApiGenerateMastodonCompatibleApi")
mustRunAfter("openApiGenerateMastodonCompatibleApi")
}
tasks.withType<ShadowJar> {
@ -63,30 +63,45 @@ tasks.clean {
delete += listOf("$rootDir/src/main/resources/static")
}
tasks.create<GenerateTask>("openApiGenerateServer", GenerateTask::class) {
//tasks.create<GenerateTask>("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<GenerateTask>("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")

View File

@ -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<JwtEncodingContext> {
return OAuth2TokenCustomizer { context: JwtEncodingContext ->
if (OAuth2TokenType.ACCESS_TOKEN == context.tokenType) {
val userDetailsImpl = context.getPrincipal<Authentication>().principal as UserDetailsImpl
context.claims.claim("uid", userDetailsImpl.id.toString())
}
}
}
}
@ConfigurationProperties("hideout.security.jwt")

View File

@ -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,

View File

@ -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<JwtToken> {
return ResponseEntity(HttpStatus.OK)
}
}

View File

@ -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<CredentialAccount> {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
return ResponseEntity(
accountApiService.verifyCredentials(principal.getClaim<String>("uid").toLong()),
HttpStatus.OK
)
}
}

View File

@ -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<Application> {
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<String, String>): ResponseEntity<Application> {
val appsRequest =
AppsRequest(map.getValue("client_name"), map.getValue("redirect_uris"), map["scopes"], map["website"])
return ResponseEntity(
appApiService.createApp(appsRequest),
HttpStatus.OK
)
}
}

View File

@ -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<V1Instance> {
return ResponseEntity(instanceApiService.v1Instance(), HttpStatus.OK)
}
}

View File

@ -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<Status> {
val principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal()
require(principal is UserDetailsImpl)
return ResponseEntity(statusesApiService.postStatus(statusesRequest, principal), HttpStatus.OK)
}
}

View File

@ -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<out GrantedAuthority>?
) : 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<UserDetailsImpl>() {
val SIMPLE_GRANTED_AUTHORITY_SET = object : TypeReference<Set<SimpleGrantedAuthority>>() {}
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<GrantedAuthority> = 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
}
}
}

View File

@ -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,32 +89,32 @@ 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
}
}
// org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql
object RegisteredClient : Table("registered_client") {
val id = varchar("id", 100)
val clientId = varchar("client_id", 100)
val clientIdIssuedAt = timestamp("client_id_issued_at").defaultExpression(CurrentTimestamp())
val clientSecret = varchar("client_secret", 200).nullable().default(null)
val clientSecretExpiresAt = timestamp("client_secret_expires_at").nullable().default(null)
val clientName = varchar("client_name", 200)
val clientAuthenticationMethods = varchar("client_authentication_methods", 1000)
val authorizationGrantTypes = varchar("authorization_grant_types", 1000)
val redirectUris = varchar("redirect_uris", 1000).nullable().default(null)
val postLogoutRedirectUris = varchar("post_logout_redirect_uris", 1000).nullable().default(null)
val scopes = varchar("scopes", 1000)
val clientSettings = varchar("client_settings", 2000)
val tokenSettings = varchar("token_settings", 2000)
private fun mapToJson(map: Map<*, *>): String = objectMapper.writeValueAsString(map)
override val primaryKey = PrimaryKey(id)
}
private fun <T, U> jsonToMap(json: String): Map<T, U> = objectMapper.readValue(json)
fun ResultRow.toRegisteredClient(): SpringRegisteredClient {
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
@ -156,9 +164,9 @@ fun ResultRow.toRegisteredClient(): SpringRegisteredClient {
.redirectUris { it.addAll(redirectUris) }
.postLogoutRedirectUris { it.addAll(postLogoutRedirectUris) }
.scopes { it.addAll(clientScopes) }
.clientSettings(ClientSettings.withSettings(JsonUtil.jsonToMap(this[clientSettings])).build())
.clientSettings(ClientSettings.withSettings(jsonToMap(this[clientSettings])).build())
val tokenSettingsMap = JsonUtil.jsonToMap<String, Any>(this[tokenSettings])
val tokenSettingsMap = jsonToMap<String, Any>(this[tokenSettings])
val withSettings = TokenSettings.withSettings(tokenSettingsMap)
if (tokenSettingsMap.containsKey(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT)) {
withSettings.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
@ -166,4 +174,25 @@ fun ResultRow.toRegisteredClient(): SpringRegisteredClient {
builder.tokenSettings(withSettings.build())
return builder.build()
}
}
// org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql
object RegisteredClient : Table("registered_client") {
val id = varchar("id", 100)
val clientId = varchar("client_id", 100)
val clientIdIssuedAt = timestamp("client_id_issued_at").defaultExpression(CurrentTimestamp())
val clientSecret = varchar("client_secret", 200).nullable().default(null)
val clientSecretExpiresAt = timestamp("client_secret_expires_at").nullable().default(null)
val clientName = varchar("client_name", 200)
val clientAuthenticationMethods = varchar("client_authentication_methods", 1000)
val authorizationGrantTypes = varchar("authorization_grant_types", 1000)
val redirectUris = varchar("redirect_uris", 1000).nullable().default(null)
val postLogoutRedirectUris = varchar("post_logout_redirect_uris", 1000).nullable().default(null)
val scopes = varchar("scopes", 1000)
val clientSettings = varchar("client_settings", 2000)
val tokenSettings = varchar("token_settings", 2000)
override val primaryKey = PrimaryKey(id)
}

View File

@ -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)
)
}
}

View File

@ -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<String> {
return string.split(" ").toSet()
}
}

View File

@ -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()
)
}
}

View File

@ -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
)
}
}

View File

@ -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,10 +12,25 @@ 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)
transaction.transaction {
val singleOrNull =
OAuth2AuthorizationConsent.select {
OAuth2AuthorizationConsent.registeredClientId
@ -28,6 +46,7 @@ class ExposedOAuth2AuthorizationConsentService(private val registeredClientRepos
}
}
}
}
override fun remove(authorizationConsent: AuthorizationConsent?) {
if (authorizationConsent == null) {
@ -38,16 +57,18 @@ 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.select {
(OAuth2AuthorizationConsent.registeredClientId eq registeredClientId)
.and(OAuth2AuthorizationConsent.principalName eq principalName)
}
.singleOrNull()?.toAuthorizationConsent()
}
}
fun ResultRow.toAuthorizationConsent(): AuthorizationConsent {
val registeredClientId = this[OAuth2AuthorizationConsent.registeredClientId]

View File

@ -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,13 +22,27 @@ 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)
transaction.transaction {
val singleOrNull = Authorization.select { Authorization.id eq authorization.id }.singleOrNull()
if (singleOrNull == null) {
val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java)
@ -33,35 +56,37 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository:
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[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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> mapToJson(it1) }
it[accessTokenType] = accessToken?.token?.tokenType?.value
it[accessTokenScopes] = accessToken?.run { token.scopes.joinToString(",").takeIf { it.isEmpty() } }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> mapToJson(it1) }
}
} else {
val authorizationCodeToken = authorization.getToken(OAuth2AuthorizationCode::class.java)
@ -74,35 +99,37 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository:
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[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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> mapToJson(it1) }
it[accessTokenType] = accessToken?.token?.tokenType?.value
it[accessTokenScopes] = accessToken?.token?.scopes?.joinToString(",")?.takeIf { it.isEmpty() }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
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 -> JsonUtil.mapToJson(it1) }
it[deviceCodeMetadata] = deviceCode?.metadata?.let { it1 -> mapToJson(it1) }
}
}
}
}
@ -121,9 +148,12 @@ 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) {
transaction.transaction {
when (tokenType?.value) {
null -> {
Authorization.select {
Authorization.authorizationCodeValue eq token
@ -173,6 +203,7 @@ class ExposedOAuth2AuthorizationService(private val registeredClientRepository:
}
}?.singleOrNull()?.toAuthorization()
}
}
fun ResultRow.toAuthorization(): OAuth2Authorization {
val registeredClientId = this[Authorization.registeredClientId]
@ -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<String, Any>(it) }.orEmpty()
val attributes = this[Authorization.attributes]?.let { jsonToMap<String, Any>(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<String, Any>(
jsonToMap<String, Any>(
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<String, Any>(it) }.orEmpty()
this[Authorization.accessTokenMetadata]?.let { jsonToMap<String, Any>(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<String, Any>(it) }.orEmpty()
this[Authorization.oidcIdTokenMetadata]?.let { jsonToMap<String, Any>(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<String, Any>(it) }.orEmpty()
this[Authorization.refreshTokenMetadata]?.let { jsonToMap<String, Any>(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<String, Any>(it) }.orEmpty()
this[Authorization.userCodeMetadata]?.let { jsonToMap<String, Any>(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<String, Any>(it) }.orEmpty()
this[Authorization.deviceCodeMetadata]?.let { jsonToMap<String, Any>(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 <T, U> jsonToMap(json: String): Map<T, U> = 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)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.service.auth
import org.springframework.stereotype.Component
@Component
interface SecureTokenGenerator {
fun generate(): String
}

View File

@ -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)
}
}

View File

@ -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()
)
}
}

View File

@ -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,
)
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -4,7 +4,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<root level="TRACE">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
@ -12,4 +12,5 @@
<logger name="kjob.core.internal.scheduler.JobServiceImpl" level="INFO"/>
<logger name="Exposed" level="INFO"/>
<logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/>
<logger name="org.springframework.security" level="trace"/>
</configuration>

File diff suppressed because it is too large Load Diff