diff --git a/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt b/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt index 841abf53..2f5b4f59 100644 --- a/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/config/SecurityConfig.kt @@ -26,6 +26,7 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary import org.springframework.core.annotation.Order import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter import org.springframework.security.authentication.AccountStatusUserDetailsChecker @@ -45,9 +46,13 @@ import org.springframework.security.oauth2.server.authorization.settings.Authori 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.access.ExceptionTranslationFilter +import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler +import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher +import org.springframework.security.web.util.matcher.AnyRequestMatcher import org.springframework.web.servlet.handler.HandlerMappingIntrospector import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateKey @@ -69,17 +74,30 @@ class SecurityConfig { @Bean @Order(1) fun httpSignatureFilterChain(http: HttpSecurity, httpSignatureFilter: HttpSignatureFilter): SecurityFilterChain { - http.securityMatcher("/inbox", "/outbox", "/users/*/inbox", "/users/*/outbox") + http + .securityMatcher("/inbox", "/outbox", "/users/*/inbox", "/users/*/outbox", "/users/*/posts/*") .addFilter(httpSignatureFilter) + .addFilterBefore( + ExceptionTranslationFilter(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)), + HttpSignatureFilter::class.java + ) .authorizeHttpRequests { it.anyRequest().permitAll() } .csrf { it.disable() } + .exceptionHandling { + it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + it.defaultAuthenticationEntryPointFor( + HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + AnyRequestMatcher.INSTANCE + ) + } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + return http.build() } @@ -87,6 +105,12 @@ class SecurityConfig { fun getHttpSignatureFilter(authenticationManager: AuthenticationManager): HttpSignatureFilter { val httpSignatureFilter = HttpSignatureFilter(DefaultSignatureHeaderParser()) httpSignatureFilter.setAuthenticationManager(authenticationManager) + httpSignatureFilter.setContinueFilterChainOnUnsuccessfulAuthentication(false) + val authenticationEntryPointFailureHandler = + AuthenticationEntryPointFailureHandler(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + authenticationEntryPointFailureHandler.setRethrowAuthenticationServiceException(false) + httpSignatureFilter.setAuthenticationFailureHandler(authenticationEntryPointFailureHandler) + return httpSignatureFilter } @@ -99,8 +123,7 @@ class SecurityConfig { HttpSignatureVerifierComposite( mapOf( "rsa-sha256" to RsaSha256HttpSignatureVerifier( - DefaultSignatureHeaderParser(), - RsaSha256HttpSignatureSigner() + DefaultSignatureHeaderParser(), RsaSha256HttpSignatureSigner() ) ), DefaultSignatureHeaderParser() @@ -118,15 +141,13 @@ class SecurityConfig { val builder = MvcRequestMatcher.Builder(introspector) OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) - http - .exceptionHandling { - it.authenticationEntryPoint( - LoginUrlAuthenticationEntryPoint("/login") - ) - } - .oauth2ResourceServer { - it.jwt(Customizer.withDefaults()) - } + http.exceptionHandling { + it.authenticationEntryPoint( + LoginUrlAuthenticationEntryPoint("/login") + ) + }.oauth2ResourceServer { + it.jwt(Customizer.withDefaults()) + } return http.build() } @@ -135,43 +156,37 @@ class SecurityConfig { fun defaultSecurityFilterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { val builder = MvcRequestMatcher.Builder(introspector) - http - .authorizeHttpRequests { - it.requestMatchers(PathRequest.toH2Console()).permitAll() - it.requestMatchers( - builder.pattern("/inbox"), - builder.pattern("/users/*/inbox"), - builder.pattern("/api/v1/apps"), - builder.pattern("/api/v1/instance/**"), - builder.pattern("/.well-known/**"), - builder.pattern("/error"), - builder.pattern("/nodeinfo/2.0") - ).permitAll() - it.requestMatchers( - builder.pattern("/auth/**") - ).anonymous() - it.requestMatchers(builder.pattern("/change-password")).authenticated() - it.requestMatchers(builder.pattern("/api/v1/accounts/verify_credentials")) - .hasAnyAuthority("SCOPE_read", "SCOPE_read:accounts") - it.anyRequest().permitAll() - } - http - .oauth2ResourceServer { - it.jwt(Customizer.withDefaults()) - } - .passwordManagement { } - .formLogin(Customizer.withDefaults()) - .csrf { - it.ignoringRequestMatchers(builder.pattern("/users/*/inbox")) - it.ignoringRequestMatchers(builder.pattern(HttpMethod.POST, "/api/v1/apps")) - it.ignoringRequestMatchers(builder.pattern("/inbox")) - it.ignoringRequestMatchers(PathRequest.toH2Console()) - } - .headers { - it.frameOptions { - it.sameOrigin() - } + http.authorizeHttpRequests { + it.requestMatchers(PathRequest.toH2Console()).permitAll() + it.requestMatchers( + builder.pattern("/inbox"), + builder.pattern("/users/*/inbox"), + builder.pattern("/api/v1/apps"), + builder.pattern("/api/v1/instance/**"), + builder.pattern("/.well-known/**"), + builder.pattern("/error"), + builder.pattern("/nodeinfo/2.0") + ).permitAll() + it.requestMatchers( + builder.pattern("/auth/**") + ).anonymous() + it.requestMatchers(builder.pattern("/change-password")).authenticated() + it.requestMatchers(builder.pattern("/api/v1/accounts/verify_credentials")) + .hasAnyAuthority("SCOPE_read", "SCOPE_read:accounts") + it.anyRequest().permitAll() + } + http.oauth2ResourceServer { + it.jwt(Customizer.withDefaults()) + }.passwordManagement { }.formLogin(Customizer.withDefaults()).csrf { + it.ignoringRequestMatchers(builder.pattern("/users/*/inbox")) + it.ignoringRequestMatchers(builder.pattern(HttpMethod.POST, "/api/v1/apps")) + it.ignoringRequestMatchers(builder.pattern("/inbox")) + it.ignoringRequestMatchers(PathRequest.toH2Console()) + }.headers { + it.frameOptions { + it.sameOrigin() } + } return http.build() } @@ -186,11 +201,7 @@ class SecurityConfig { val generateKeyPair = keyPairGenerator.generateKeyPair() val rsaPublicKey = generateKeyPair.public as RSAPublicKey val rsaPrivateKey = generateKeyPair.private as RSAPrivateKey - val rsaKey = RSAKey - .Builder(rsaPublicKey) - .privateKey(rsaPrivateKey) - .keyID(UUID.randomUUID().toString()) - .build() + val rsaKey = RSAKey.Builder(rsaPublicKey).privateKey(rsaPrivateKey).keyID(UUID.randomUUID().toString()).build() val jwkSet = JWKSet(rsaKey) return ImmutableJWKSet(jwkSet) @@ -200,9 +211,7 @@ class SecurityConfig { @ConditionalOnProperty(name = ["hideout.security.jwt.generate"], havingValue = "") fun loadJwkSource(jwkConfig: JwkConfig): JWKSource { val rsaKey = RSAKey.Builder(RsaUtil.decodeRsaPublicKey(jwkConfig.publicKey)) - .privateKey(RsaUtil.decodeRsaPrivateKey(jwkConfig.privateKey)) - .keyID(jwkConfig.keyId) - .build() + .privateKey(RsaUtil.decodeRsaPrivateKey(jwkConfig.privateKey)).keyID(jwkConfig.keyId).build() return ImmutableJWKSet(JWKSet(rsaKey)) } @@ -212,11 +221,8 @@ class SecurityConfig { @Bean fun authorizationServerSettings(): AuthorizationServerSettings { - return AuthorizationServerSettings.builder() - .authorizationEndpoint("/oauth/authorize") - .tokenEndpoint("/oauth/token") - .tokenRevocationEndpoint("/oauth/revoke") - .build() + return AuthorizationServerSettings.builder().authorizationEndpoint("/oauth/authorize") + .tokenEndpoint("/oauth/token").tokenRevocationEndpoint("/oauth/revoke").build() } @Bean @@ -239,8 +245,7 @@ class SecurityConfig { @Bean fun mappingJackson2HttpMessageConverter(): MappingJackson2HttpMessageConverter { - val builder = Jackson2ObjectMapperBuilder() - .serializationInclusion(JsonInclude.Include.NON_NULL) + val builder = Jackson2ObjectMapperBuilder().serializationInclusion(JsonInclude.Include.NON_NULL) return MappingJackson2HttpMessageConverter(builder.build()) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/NoteApController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/NoteApController.kt new file mode 100644 index 00000000..f7fe6197 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/NoteApController.kt @@ -0,0 +1,16 @@ +package dev.usbharu.hideout.controller + +import dev.usbharu.hideout.domain.model.ap.Note +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable + +interface NoteApController { + @GetMapping("/users/*/posts/{postId}") + suspend fun postsAp( + @PathVariable("postId") postId: Long, + @CurrentSecurityContext context: SecurityContext + ): ResponseEntity +} diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/NoteApControllerImpl.kt b/src/main/kotlin/dev/usbharu/hideout/controller/NoteApControllerImpl.kt new file mode 100644 index 00000000..36e27859 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/NoteApControllerImpl.kt @@ -0,0 +1,32 @@ +package dev.usbharu.hideout.controller + +import dev.usbharu.hideout.domain.model.ap.Note +import dev.usbharu.hideout.service.api.NoteApApiService +import dev.usbharu.hideout.service.signature.HttpSignatureUser +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@RestController +class NoteApControllerImpl(private val noteApApiService: NoteApApiService) : NoteApController { + override suspend fun postsAp( + @PathVariable(value = "postId") postId: Long, + @CurrentSecurityContext context: SecurityContext + ): ResponseEntity { + val userId = + if (context.authentication is PreAuthenticatedAuthenticationToken && context.authentication.details is HttpSignatureUser) { + (context.authentication.details as HttpSignatureUser).id + } else { + null + } + + val note = noteApApiService.getNote(postId, userId) + if (note != null) { + return ResponseEntity.ok(note) + } + return ResponseEntity.notFound().build() + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt index 504ce898..edf64ed5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt @@ -1,12 +1,11 @@ package dev.usbharu.hideout.exception import java.io.Serial +import javax.naming.AuthenticationException -class HttpSignatureVerifyException : IllegalArgumentException { +class HttpSignatureVerifyException : AuthenticationException { constructor() : super() constructor(s: String?) : super(s) - constructor(message: String?, cause: Throwable?) : super(message, cause) - constructor(cause: Throwable?) : super(cause) companion object { @Serial diff --git a/src/main/kotlin/dev/usbharu/hideout/query/FollowerQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/query/FollowerQueryService.kt index 4cc73ef1..4922b268 100644 --- a/src/main/kotlin/dev/usbharu/hideout/query/FollowerQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/query/FollowerQueryService.kt @@ -1,9 +1,7 @@ package dev.usbharu.hideout.query import dev.usbharu.hideout.domain.model.hideout.entity.User -import org.springframework.stereotype.Repository -@Repository interface FollowerQueryService { suspend fun findFollowersById(id: Long): List suspend fun findFollowersByNameAndDomain(name: String, domain: String): List diff --git a/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryService.kt new file mode 100644 index 00000000..d0ee3adb --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryService.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.query.activitypub + +import dev.usbharu.hideout.domain.model.ap.Note +import dev.usbharu.hideout.domain.model.hideout.entity.Post + +interface NoteQueryService { + suspend fun findById(id: Long): Pair +} diff --git a/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryServiceImpl.kt new file mode 100644 index 00000000..4978b9f4 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/query/activitypub/NoteQueryServiceImpl.kt @@ -0,0 +1,38 @@ +package dev.usbharu.hideout.query.activitypub + +import dev.usbharu.hideout.domain.model.ap.Note +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import dev.usbharu.hideout.exception.FailedToGetResourcesException +import dev.usbharu.hideout.repository.Posts +import dev.usbharu.hideout.repository.Users +import dev.usbharu.hideout.repository.toPost +import dev.usbharu.hideout.util.singleOr +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.select +import org.springframework.stereotype.Repository +import java.time.Instant + +@Repository +class NoteQueryServiceImpl : NoteQueryService { + override suspend fun findById(id: Long): Pair { + return Posts + .leftJoin(Users) + .select { Posts.id eq id } + .singleOr { FailedToGetResourcesException("id $id is duplicate or does not exist.") } + .let { it.toNote() to it.toPost() } + } + + private fun ResultRow.toNote(): Note { + return Note( + name = "Post", + id = this[Posts.apId], + attributedTo = this[Users.url], + content = this[Posts.text], + published = Instant.ofEpochMilli(this[Posts.createdAt]).toString(), + to = listOf(), + cc = listOf(), + inReplyTo = null, + sensitive = false + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiService.kt new file mode 100644 index 00000000..38006f4b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiService.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.service.api + +import dev.usbharu.hideout.domain.model.ap.Note + +interface NoteApApiService { + suspend fun getNote(postId: Long, userId: Long?): Note? +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiServiceImpl.kt new file mode 100644 index 00000000..4fb14cfd --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/NoteApApiServiceImpl.kt @@ -0,0 +1,37 @@ +package dev.usbharu.hideout.service.api + +import dev.usbharu.hideout.domain.model.ap.Note +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility +import dev.usbharu.hideout.query.FollowerQueryService +import dev.usbharu.hideout.query.activitypub.NoteQueryService +import dev.usbharu.hideout.service.core.Transaction +import org.springframework.stereotype.Service + +@Service +class NoteApApiServiceImpl( + private val noteQueryService: NoteQueryService, + private val followerQueryService: FollowerQueryService, + private val transaction: Transaction +) : NoteApApiService { + override suspend fun getNote(postId: Long, userId: Long?): Note? = transaction.transaction { + val findById = noteQueryService.findById(postId) + when (findById.second.visibility) { + Visibility.PUBLIC, Visibility.UNLISTED -> { + return@transaction findById.first + } + + Visibility.FOLLOWERS -> { + if (userId == null) { + return@transaction null + } + + if (followerQueryService.alreadyFollow(findById.second.userId, userId).not()) { + return@transaction null + } + return@transaction findById.first + } + + Visibility.DIRECT -> return@transaction null + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureFilter.kt b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureFilter.kt index 2a7e15b8..07c2b7b1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureFilter.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureFilter.kt @@ -10,13 +10,19 @@ import java.net.URL class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeaderParser) : AbstractPreAuthenticatedProcessingFilter() { - override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any { + override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? { val headersList = request?.headerNames?.toList().orEmpty() val headers = headersList.associateWith { header -> request?.getHeaders(header)?.toList().orEmpty() } - val signature = httpSignatureHeaderParser.parse(HttpHeaders(headers)) + val signature = try { + httpSignatureHeaderParser.parse(HttpHeaders(headers)) + } catch (e: IllegalArgumentException) { + return null + } catch (e: RuntimeException) { + return "" + } return signature.keyId } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUser.kt b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUser.kt index d9c55816..cb6160ee 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUser.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUser.kt @@ -7,6 +7,7 @@ import java.io.Serial class HttpSignatureUser( username: String, val domain: String, + val id: Long, credentialsNonExpired: Boolean, accountNonLocked: Boolean, authorities: MutableCollection? @@ -19,6 +20,25 @@ class HttpSignatureUser( accountNonLocked, authorities ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HttpSignatureUser) return false + if (!super.equals(other)) return false + + if (domain != other.domain) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + domain.hashCode() + result = 31 * result + id.hashCode() + return result + } + companion object { @Serial private const val serialVersionUID: Long = -3330552099960982997L diff --git a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUserDetailsService.kt b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUserDetailsService.kt index f86d7bfb..0f58350c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUserDetailsService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureUserDetailsService.kt @@ -10,6 +10,8 @@ import dev.usbharu.httpsignature.common.PublicKey import dev.usbharu.httpsignature.verify.FailedVerification import dev.usbharu.httpsignature.verify.HttpSignatureVerifier import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.core.userdetails.AuthenticationUserDetailsService import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UsernameNotFoundException @@ -22,37 +24,47 @@ class HttpSignatureUserDetailsService( ) : AuthenticationUserDetailsService { override fun loadUserDetails(token: PreAuthenticatedAuthenticationToken): UserDetails = runBlocking { - transaction.transaction { - if (token.principal !is String) { - throw IllegalStateException("Token is not String") - } - if (token.credentials !is HttpRequest) { - throw IllegalStateException("Credentials is not HttpRequest") - } + if (token.principal !is String) { + throw IllegalStateException("Token is not String") + } + if (token.credentials !is HttpRequest) { + throw IllegalStateException("Credentials is not HttpRequest") + } - val keyId = token.principal as String - val findByKeyId = try { + val keyId = token.principal as String + val findByKeyId = transaction.transaction { + try { userQueryService.findByKeyId(keyId) } catch (e: FailedToGetResourcesException) { throw UsernameNotFoundException("User not found", e) } + } - val verify = httpSignatureVerifier.verify( + val verify = try { + httpSignatureVerifier.verify( token.credentials as HttpRequest, PublicKey(RsaUtil.decodeRsaPublicKeyPem(findByKeyId.publicKey), keyId) ) - - if (verify is FailedVerification) { - throw HttpSignatureVerifyException(verify.reason) - } - - HttpSignatureUser( - username = findByKeyId.name, - domain = findByKeyId.domain, - credentialsNonExpired = true, - accountNonLocked = true, - authorities = mutableListOf() - ) + } catch (e: RuntimeException) { + throw BadCredentialsException("", e) } + + if (verify is FailedVerification) { + logger.warn("FAILED Verify HTTP Signature reason: {}", verify.reason) + throw HttpSignatureVerifyException(verify.reason) + } + + HttpSignatureUser( + username = findByKeyId.name, + domain = findByKeyId.domain, + id = findByKeyId.id, + credentialsNonExpired = true, + accountNonLocked = true, + authorities = mutableListOf() + ) + } + + companion object { + private val logger = LoggerFactory.getLogger(HttpSignatureUserDetailsService::class.java) } }