From 97d9bf0898eef78807d1ab700f286db631248b91 Mon Sep 17 00:00:00 2001 From: usbharu <64310155+usbharu@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:05:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20OAuth2=E3=81=A7=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=B3=E3=81=AE=E7=99=BA=E8=A1=8C=E3=81=8C=E3=81=A7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegisterApplicationApplicationService.kt | 3 +- .../application/RegisteredApplication.kt | 2 +- .../hideout/core/config/SecurityConfig.kt | 19 +++- .../HideoutJdbcOauth2AuthorizationService.kt | 46 +++++++++ .../oauth2/HideoutUserDetails.kt | 94 ++++++++++++++++++- .../oauth2/UserDetailsServiceImpl.kt | 2 +- .../resources/db/migration/V1__Init_DB.sql | 48 +++++++++- hideout-core/src/main/resources/log4j2.xml | 2 +- .../mastodon/interfaces/api/SpringAppApi.kt | 4 +- 9 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutJdbcOauth2AuthorizationService.kt diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisterApplicationApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisterApplicationApplicationService.kt index 96f5867e..d287b28a 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisterApplicationApplicationService.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisterApplicationApplicationService.kt @@ -61,6 +61,7 @@ class RegisterApplicationApplicationService( .apply { if (registerApplication.useRefreshToken) { authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + } else { tokenSettings( TokenSettings .builder() @@ -84,7 +85,7 @@ class RegisterApplicationApplicationService( id = id, name = registerApplication.name, clientSecret = clientSecret, - clientId = id, + clientId = id.toString(), redirectUris = registerApplication.redirectUris ) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisteredApplication.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisteredApplication.kt index 280afcf0..a5a18032 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisteredApplication.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/application/RegisteredApplication.kt @@ -23,5 +23,5 @@ data class RegisteredApplication( val name: String, val redirectUris: Set, val clientSecret: String, - val clientId: Long, + val clientId: String, ) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/config/SecurityConfig.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/config/SecurityConfig.kt index 9289f80b..805b7730 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/config/SecurityConfig.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/config/SecurityConfig.kt @@ -29,14 +29,17 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.annotation.web.invoke import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository 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.web.SecurityFilterChain import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint @Configuration -@EnableWebSecurity(debug = false) +@EnableWebSecurity(debug = true) class SecurityConfig { @Bean fun passwordEncoder(): PasswordEncoder { @@ -82,6 +85,20 @@ class SecurityConfig { return JdbcRegisteredClientRepository(jdbcOperations) } + @Bean + fun oauth2AuthorizationConsentService( + jdbcOperations: JdbcOperations, + registeredClientRepository: RegisteredClientRepository, + ): OAuth2AuthorizationConsentService { + return JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository) + } + + @Bean + fun authorizationServerSettings(): AuthorizationServerSettings { + return AuthorizationServerSettings.builder().authorizationEndpoint("/oauth/authorize") + .tokenEndpoint("/oauth/token").tokenRevocationEndpoint("/oauth/revoke").build() + } + @Bean fun roleHierarchy(): RoleHierarchy { val roleHierarchyImpl = RoleHierarchyImpl.fromHierarchy( diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutJdbcOauth2AuthorizationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutJdbcOauth2AuthorizationService.kt new file mode 100644 index 00000000..20c65b68 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutJdbcOauth2AuthorizationService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 usbharu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.usbharu.hideout.core.infrastructure.springframework.oauth2 + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcOperations +import org.springframework.jdbc.support.lob.DefaultLobHandler +import org.springframework.jdbc.support.lob.LobHandler +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository +import org.springframework.stereotype.Component + +@Component +class HideoutJdbcOauth2AuthorizationService( + registeredClientRepository: RegisteredClientRepository, + jdbcOperations: JdbcOperations, + @Autowired(required = false) lobHandler: LobHandler = DefaultLobHandler(), +) : JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository, lobHandler) { + + + + init { + super.setAuthorizationRowMapper(HideoutOAuth2AuthorizationRowMapper(registeredClientRepository = registeredClientRepository)) + } + + class HideoutOAuth2AuthorizationRowMapper(registeredClientRepository: RegisteredClientRepository?) : + OAuth2AuthorizationRowMapper(registeredClientRepository) { + init { + objectMapper.addMixIn(HideoutUserDetails::class.java, UserDetailsMixin::class.java) + } + } +} \ No newline at end of file diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutUserDetails.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutUserDetails.kt index 2ba083e0..a85b948b 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutUserDetails.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/HideoutUserDetails.kt @@ -16,16 +16,31 @@ package dev.usbharu.hideout.core.infrastructure.springframework.oauth2 +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.UserDetails +import java.io.Serial +import java.util.* class HideoutUserDetails( - private val authorities: MutableList, + authorities: Set, private val password: String, private val username: String, val userDetailsId: Long, ) : UserDetails { - override fun getAuthorities(): MutableCollection { + private val authorities: MutableSet = Collections.unmodifiableSet(authorities) + override fun getAuthorities(): MutableSet { return authorities } @@ -36,4 +51,79 @@ class HideoutUserDetails( override fun getUsername(): String { return username } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HideoutUserDetails + + if (authorities != other.authorities) return false + if (password != other.password) return false + if (username != other.username) return false + if (userDetailsId != other.userDetailsId) return false + + return true + } + + override fun hashCode(): Int { + var result = authorities.hashCode() + result = 31 * result + password.hashCode() + result = 31 * result + username.hashCode() + result = 31 * result + userDetailsId.hashCode() + return result + } + + override fun toString(): String { + return "HideoutUserDetails(authorities=$authorities, password='$password', username='$username', userDetailsId=$userDetailsId)" + } + + companion object { + @Serial + private const val serialVersionUID = -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 +@Suppress("UnnecessaryAbstractClass") +abstract class UserDetailsMixin + +class UserDetailsDeserializer : JsonDeserializer() { + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): HideoutUserDetails { + val mapper = p.codec as ObjectMapper + val jsonNode: JsonNode = mapper.readTree(p) + val authorities: Set = mapper.convertValue( + jsonNode["authorities"], + SIMPLE_GRANTED_AUTHORITY_SET + ) + + val password = jsonNode.readText("password") + return HideoutUserDetails( + userDetailsId = jsonNode["userDetailsId"].longValue(), + username = jsonNode.readText("username"), + password = password, + authorities = authorities + ) + } + + fun JsonNode.readText(field: String, defaultValue: String = ""): String { + return when { + has(field) -> get(field).asText(defaultValue) + else -> defaultValue + } + } + + companion object { + private val SIMPLE_GRANTED_AUTHORITY_SET = object : TypeReference>() {} + } } \ No newline at end of file diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsServiceImpl.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsServiceImpl.kt index c7db5d46..505be86c 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsServiceImpl.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsServiceImpl.kt @@ -43,7 +43,7 @@ class UserDetailsServiceImpl( val userDetail = userDetailRepository.findByActorId(actor.id.id) ?: throw UsernameNotFoundException("${actor.id.id} not found") HideoutUserDetails( - authorities = mutableListOf(), + authorities = HashSet(), password = userDetail.password.password, actor.name.name, userDetailsId = userDetail.id.id diff --git a/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql b/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql index 03ce40a6..3f76c59b 100644 --- a/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql +++ b/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql @@ -55,7 +55,7 @@ create table if not exists actors suspend boolean not null, move_to bigint null default null, emojis varchar(3000) not null default '', - deleted boolean not null default false, + deleted boolean not null default false, unique ("name", "domain"), constraint fk_actors_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict, constraint fk_actors_actors__move_to foreign key ("move_to") references actors (id) on delete restrict on update restrict @@ -185,3 +185,49 @@ create table if not exists oauth2_registered_client token_settings varchar(2000) NOT NULL, PRIMARY KEY (id) ); + +CREATE TABLE if not exists oauth2_authorization_consent +( + registered_client_id varchar(100) NOT NULL, + principal_name varchar(200) NOT NULL, + authorities varchar(1000) NOT NULL, + PRIMARY KEY (registered_client_id, principal_name) +); + +CREATE TABLE oauth2_authorization +( + id varchar(100) NOT NULL, + registered_client_id varchar(100) NOT NULL, + principal_name varchar(200) NOT NULL, + authorization_grant_type varchar(100) NOT NULL, + authorized_scopes varchar(1000) DEFAULT NULL, + attributes varchar(4000) DEFAULT NULL, + state varchar(500) DEFAULT NULL, + authorization_code_value varchar(4000) DEFAULT NULL, + authorization_code_issued_at timestamp DEFAULT NULL, + authorization_code_expires_at timestamp DEFAULT NULL, + authorization_code_metadata varchar(4000) DEFAULT NULL, + access_token_value varchar(4000) DEFAULT NULL, + access_token_issued_at timestamp DEFAULT NULL, + access_token_expires_at timestamp DEFAULT NULL, + access_token_metadata varchar(4000) DEFAULT NULL, + access_token_type varchar(100) DEFAULT NULL, + access_token_scopes varchar(1000) DEFAULT NULL, + oidc_id_token_value varchar(4000) DEFAULT NULL, + oidc_id_token_issued_at timestamp DEFAULT NULL, + oidc_id_token_expires_at timestamp DEFAULT NULL, + oidc_id_token_metadata varchar(4000) DEFAULT NULL, + refresh_token_value varchar(4000) DEFAULT NULL, + refresh_token_issued_at timestamp DEFAULT NULL, + refresh_token_expires_at timestamp DEFAULT NULL, + refresh_token_metadata varchar(4000) DEFAULT NULL, + user_code_value varchar(4000) DEFAULT NULL, + user_code_issued_at timestamp DEFAULT NULL, + user_code_expires_at timestamp DEFAULT NULL, + user_code_metadata varchar(4000) DEFAULT NULL, + device_code_value varchar(4000) DEFAULT NULL, + device_code_issued_at timestamp DEFAULT NULL, + device_code_expires_at timestamp DEFAULT NULL, + device_code_metadata varchar(4000) DEFAULT NULL, + PRIMARY KEY (id) +); diff --git a/hideout-core/src/main/resources/log4j2.xml b/hideout-core/src/main/resources/log4j2.xml index 6d467f2d..834d3910 100644 --- a/hideout-core/src/main/resources/log4j2.xml +++ b/hideout-core/src/main/resources/log4j2.xml @@ -6,7 +6,7 @@ - + diff --git a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringAppApi.kt b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringAppApi.kt index 22d0a4a6..cc870055 100644 --- a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringAppApi.kt +++ b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringAppApi.kt @@ -33,7 +33,7 @@ class SpringAppApi(private val registerApplicationApplicationService: RegisterAp appsRequest.clientName, setOf(URI.create(appsRequest.redirectUris)), false, - appsRequest.scopes?.split(" ").orEmpty().toSet() + appsRequest.scopes?.split(" ").orEmpty().toSet().ifEmpty { setOf("read") } ) val registeredApplication = registerApplicationApplicationService.register(registerApplication) return ResponseEntity.ok( @@ -41,7 +41,7 @@ class SpringAppApi(private val registerApplicationApplicationService: RegisterAp registeredApplication.name, "invalid-vapid-key", null, - registeredApplication.clientId.toString(), + registeredApplication.clientId, registeredApplication.clientSecret, appsRequest.redirectUris )