From 43a914331f5c6386e0312814293abe9dfd3d4e99 Mon Sep 17 00:00:00 2001 From: usbharu <64310155+usbharu@users.noreply.github.com> Date: Fri, 22 Sep 2023 17:32:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Twidere=E3=81=A7OAuth2=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=82=A4=E3=83=B3=E3=81=8C=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../usbharu/hideout/config/SecurityConfig.kt | 44 +++++- .../mastodon/MastodonAccountApiController.kt | 22 +++ .../hideout/domain/model/UserDetailsImpl.kt | 62 ++++++++ .../service/api/mastodon/AccountApiService.kt | 63 ++++++++ .../auth/ExposedOAuth2AuthorizationService.kt | 14 +- .../service/auth/UserDetailsServiceImpl.kt | 11 +- src/main/resources/logback.xml | 1 + src/main/resources/openapi/mastodon.yaml | 137 ++++++++++++++++++ 9 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonAccountApiController.kt create mode 100644 src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/AccountApiService.kt diff --git a/build.gradle.kts b/build.gradle.kts index 68602880..1941b614 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ 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 "7.0.1" // id("org.jetbrains.kotlin.plugin.serialization") version "1.8.10" @@ -153,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") @@ -174,6 +175,7 @@ dependencies { 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 3421d12b..8dc456bc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt @@ -5,47 +5,57 @@ 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 import org.springframework.core.annotation.Order +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 { - it.authenticationEntryPoint(LoginUrlAuthenticationEntryPoint("/login")) + it.defaultAuthenticationEntryPointFor( + LoginUrlAuthenticationEntryPoint("/login"), + MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) } .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } - .csrf { - it.disable() - } return http.build() } @@ -54,7 +64,9 @@ class SecurityConfig { 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( @@ -63,12 +75,18 @@ class SecurityConfig { 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.ignoringRequestMatchers(builder.pattern("/api/**")) @@ -127,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/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/domain/model/UserDetailsImpl.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt index dff04cdc..b666c5e8 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt @@ -1,6 +1,18 @@ 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 @@ -18,4 +30,54 @@ class UserDetailsImpl( @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/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/auth/ExposedOAuth2AuthorizationService.kt b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt index 1e3f7a7e..c1d4b287 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/auth/ExposedOAuth2AuthorizationService.kt @@ -3,12 +3,15 @@ package dev.usbharu.hideout.service.auth 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 @@ -53,7 +56,7 @@ class ExposedOAuth2AuthorizationService( it[registeredClientId] = authorization.registeredClientId it[principalName] = authorization.principalName it[authorizationGrantType] = authorization.authorizationGrantType.value - it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isEmpty() } + it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isNotEmpty() } it[attributes] = mapToJson(authorization.attributes) it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue @@ -66,7 +69,8 @@ class ExposedOAuth2AuthorizationService( 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.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 @@ -95,7 +99,7 @@ class ExposedOAuth2AuthorizationService( it[registeredClientId] = authorization.registeredClientId it[principalName] = authorization.principalName it[authorizationGrantType] = authorization.authorizationGrantType.value - it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isEmpty() } + it[authorizedScopes] = authorization.authorizedScopes.joinToString(",").takeIf { it.isNotEmpty() } it[attributes] = mapToJson(authorization.attributes) it[state] = authorization.getAttribute(OAuth2ParameterNames.STATE) it[authorizationCodeValue] = authorizationCodeToken?.token?.tokenValue @@ -108,7 +112,7 @@ class ExposedOAuth2AuthorizationService( 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.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 @@ -331,6 +335,8 @@ class ExposedOAuth2AuthorizationService( 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) } } } 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 f896209f..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,10 +1,10 @@ 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 @@ -24,10 +24,15 @@ class UserDetailsServiceImpl( } transaction.transaction { val findById = userQueryService.findByNameAndDomain(username, URL(applicationConfig.url).host) - User( + UserDetailsImpl( + findById.id, findById.name, findById.password, - emptyList() + true, + true, + true, + true, + mutableListOf() ) } } diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index ad457f2b..25579496 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -12,4 +12,5 @@ + diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 3df85bd4..9b10b266 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -167,6 +167,21 @@ paths: 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: Account: @@ -251,6 +266,128 @@ components: - 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: