Merge pull request #92 from usbharu/feature/ap-posts-endpoint

Feature: 投稿のURLにアクセスすると投稿を取得できるように
This commit is contained in:
usbharu 2023-10-21 15:30:34 +09:00 committed by GitHub
commit 16f67dc040
12 changed files with 270 additions and 92 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<User>
suspend fun findFollowersByNameAndDomain(name: String, domain: String): List<User>

View File

@ -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<Note, Post>
}

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import java.io.Serial
class HttpSignatureUser(
username: String,
val domain: String,
val id: Long,
credentialsNonExpired: Boolean,
accountNonLocked: Boolean,
authorities: MutableCollection<out GrantedAuthority>?
@ -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

View File

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