feat: TwidereでOAuth2ログインができるように

This commit is contained in:
usbharu 2023-09-22 17:32:47 +09:00
parent 834e40894b
commit 43a914331f
9 changed files with 343 additions and 15 deletions

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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