mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 07:23:34 +01:00
parent
2d05fa16c8
commit
7438bf4c95
13 changed files with 265 additions and 47 deletions
|
|
@ -27,3 +27,6 @@ ERR_1020 | Book to upgrade does not belong to provided series
|
|||
ERR_1021 | Destination file already exists
|
||||
ERR_1022 | Newly imported book could not be scanned
|
||||
ERR_1023 | Book already present in ReadingList
|
||||
ERR_1024 | OAuth2 login error: no email attribute
|
||||
ERR_1025 | OAuth2 login error: no local user exist with that email
|
||||
ERR_1026 | OpenIDConnect login error: email not verified
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ dependencies {
|
|||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
implementation("org.springframework.boot:spring-boot-starter-artemis")
|
||||
implementation("org.springframework.boot:spring-boot-starter-jooq")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE AUTHENTICATION_ACTIVITY
|
||||
ADD COLUMN SOURCE varchar NULL DEFAULT NULL;
|
||||
|
|
@ -10,4 +10,5 @@ data class AuthenticationActivity(
|
|||
val success: Boolean,
|
||||
val error: String? = null,
|
||||
val dateTime: LocalDateTime = LocalDateTime.now(),
|
||||
val source: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ class AuthenticationActivityDao(
|
|||
}
|
||||
|
||||
override fun insert(activity: AuthenticationActivity) {
|
||||
dsl.insertInto(aa, aa.USER_ID, aa.EMAIL, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR)
|
||||
.values(activity.userId, activity.email, activity.ip, activity.userAgent, activity.success, activity.error)
|
||||
dsl.insertInto(aa, aa.USER_ID, aa.EMAIL, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR, aa.SOURCE)
|
||||
.values(activity.userId, activity.email, activity.ip, activity.userAgent, activity.success, activity.error, activity.source)
|
||||
.execute()
|
||||
}
|
||||
|
||||
|
|
@ -101,5 +101,6 @@ class AuthenticationActivityDao(
|
|||
success = success,
|
||||
error = error,
|
||||
dateTime = dateTime.toCurrentTimeZone(),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import org.gotson.komga.domain.model.KomgaUser
|
|||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken
|
||||
import org.springframework.security.oauth2.core.oidc.OidcUserInfo
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
|
||||
class KomgaPrincipal(
|
||||
val user: KomgaUser
|
||||
) : UserDetails {
|
||||
val user: KomgaUser,
|
||||
val oAuth2User: OAuth2User? = null,
|
||||
val oidcUser: OidcUser? = null,
|
||||
) : UserDetails, OAuth2User, OidcUser {
|
||||
|
||||
override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
|
||||
user.roles()
|
||||
|
|
@ -25,4 +31,14 @@ class KomgaPrincipal(
|
|||
override fun isAccountNonExpired() = true
|
||||
|
||||
override fun isAccountNonLocked() = true
|
||||
|
||||
override fun getName() = user.email
|
||||
|
||||
override fun getAttributes(): MutableMap<String, Any> = oAuth2User?.attributes ?: mutableMapOf()
|
||||
|
||||
override fun getClaims(): MutableMap<String, Any> = oidcUser?.claims ?: mutableMapOf()
|
||||
|
||||
override fun getUserInfo(): OidcUserInfo? = oidcUser?.userInfo
|
||||
|
||||
override fun getIdToken(): OidcIdToken? = oidcUser?.idToken
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
|
|||
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken
|
||||
import org.springframework.security.authentication.RememberMeAuthenticationToken
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent
|
||||
import org.springframework.security.authentication.event.AuthenticationSuccessEvent
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetails
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.EventObject
|
||||
|
|
@ -23,12 +26,19 @@ class LoginListener(
|
|||
@EventListener
|
||||
fun onSuccess(event: AuthenticationSuccessEvent) {
|
||||
val user = (event.authentication.principal as KomgaPrincipal).user
|
||||
val source = when (event.source) {
|
||||
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
|
||||
is UsernamePasswordAuthenticationToken -> "Password"
|
||||
is RememberMeAuthenticationToken -> "RememberMe"
|
||||
else -> null
|
||||
}
|
||||
val activity = AuthenticationActivity(
|
||||
userId = user.id,
|
||||
email = user.email,
|
||||
ip = event.getIp(),
|
||||
userAgent = event.getUserAgent(),
|
||||
success = true,
|
||||
source = source
|
||||
)
|
||||
|
||||
logger.info { activity }
|
||||
|
|
@ -37,13 +47,20 @@ class LoginListener(
|
|||
|
||||
@EventListener
|
||||
fun onFailure(event: AbstractAuthenticationFailureEvent) {
|
||||
val user = event.authentication.principal.toString()
|
||||
val user = event.authentication?.principal?.toString().orEmpty()
|
||||
val source = when (event.source) {
|
||||
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
|
||||
is UsernamePasswordAuthenticationToken -> "Password"
|
||||
is RememberMeAuthenticationToken -> "RememberMe"
|
||||
else -> null
|
||||
}
|
||||
val activity = AuthenticationActivity(
|
||||
userId = userRepository.findByEmailIgnoreCaseOrNull(user)?.id,
|
||||
email = user,
|
||||
ip = event.getIp(),
|
||||
userAgent = event.getUserAgent(),
|
||||
success = false,
|
||||
source = source,
|
||||
error = event.exception.message,
|
||||
)
|
||||
|
||||
|
|
@ -52,16 +69,24 @@ class LoginListener(
|
|||
}
|
||||
|
||||
private fun EventObject.getIp(): String? =
|
||||
when (source) {
|
||||
is WebAuthenticationDetails -> (source as WebAuthenticationDetails).remoteAddress
|
||||
is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as WebAuthenticationDetails).remoteAddress
|
||||
else -> null
|
||||
try {
|
||||
when (source) {
|
||||
is WebAuthenticationDetails -> (source as WebAuthenticationDetails).remoteAddress
|
||||
is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as WebAuthenticationDetails).remoteAddress
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun EventObject.getUserAgent(): String? =
|
||||
when (source) {
|
||||
is UserAgentWebAuthenticationDetails -> (source as UserAgentWebAuthenticationDetails).userAgent
|
||||
is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as UserAgentWebAuthenticationDetails).userAgent
|
||||
else -> null
|
||||
try {
|
||||
when (source) {
|
||||
is UserAgentWebAuthenticationDetails -> (source as UserAgentWebAuthenticationDetails).userAgent
|
||||
is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as UserAgentWebAuthenticationDetails).userAgent
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
|||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
|
||||
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
|
@ -21,47 +28,68 @@ private val logger = KotlinLogging.logger {}
|
|||
class SecurityConfiguration(
|
||||
private val komgaProperties: KomgaProperties,
|
||||
private val komgaUserDetailsLifecycle: UserDetailsService,
|
||||
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
|
||||
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
|
||||
private val sessionRegistry: SessionRegistry
|
||||
) : WebSecurityConfigurerAdapter() {
|
||||
|
||||
override fun configure(http: HttpSecurity) {
|
||||
// @formatter:off
|
||||
val userAgentWebAuthenticationDetailsSource = UserAgentWebAuthenticationDetailsSource()
|
||||
|
||||
http
|
||||
.cors()
|
||||
.and()
|
||||
.csrf().disable()
|
||||
.cors {}
|
||||
.csrf { it.disable() }
|
||||
|
||||
.authorizeRequests()
|
||||
// restrict all actuator endpoints to ADMIN only
|
||||
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole(ROLE_ADMIN)
|
||||
.authorizeRequests {
|
||||
// restrict all actuator endpoints to ADMIN only
|
||||
it.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole(ROLE_ADMIN)
|
||||
|
||||
// claim is unprotected
|
||||
.antMatchers("/api/v1/claim").permitAll()
|
||||
// claim is unprotected
|
||||
it.antMatchers(
|
||||
"/api/v1/claim",
|
||||
"/api/v1/oauth2/providers",
|
||||
).permitAll()
|
||||
|
||||
// all other endpoints are restricted to authenticated users
|
||||
.antMatchers(
|
||||
"/api/**",
|
||||
"/opds/**",
|
||||
"/sse/**"
|
||||
).hasRole(ROLE_USER)
|
||||
// all other endpoints are restricted to authenticated users
|
||||
it.antMatchers(
|
||||
"/api/**",
|
||||
"/opds/**",
|
||||
"/sse/**"
|
||||
).hasRole(ROLE_USER)
|
||||
}
|
||||
|
||||
.and()
|
||||
.headers {
|
||||
it.cacheControl().disable() // headers are set in WebMvcConfiguration
|
||||
}
|
||||
|
||||
.httpBasic()
|
||||
.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
.httpBasic {
|
||||
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
}
|
||||
|
||||
.and()
|
||||
.logout()
|
||||
.logoutUrl("/api/v1/users/logout")
|
||||
.deleteCookies("JSESSIONID")
|
||||
.invalidateHttpSession(true)
|
||||
.oauth2Login { oauth2 ->
|
||||
oauth2.userInfoEndpoint {
|
||||
it.userService(oauth2UserService)
|
||||
it.oidcUserService(oidcUserService)
|
||||
}
|
||||
oauth2.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
oauth2.loginPage("/login")
|
||||
.defaultSuccessUrl("/", true)
|
||||
.failureHandler { request, response, exception ->
|
||||
val errorMessage = when (exception) {
|
||||
is OAuth2AuthenticationException -> exception.error.errorCode
|
||||
else -> exception.message
|
||||
}
|
||||
val url = "/login?error=$errorMessage"
|
||||
SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception)
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
it.logoutUrl("/api/v1/users/logout")
|
||||
it.deleteCookies("JSESSIONID")
|
||||
it.invalidateHttpSession(true)
|
||||
}
|
||||
|
||||
.and()
|
||||
.sessionManagement()
|
||||
.maximumSessions(10)
|
||||
.sessionRegistry(sessionRegistry)
|
||||
|
|
@ -70,16 +98,16 @@ class SecurityConfiguration(
|
|||
logger.info { "RememberMe is active, validity: ${komgaProperties.rememberMe.validity}s" }
|
||||
|
||||
http
|
||||
.rememberMe()
|
||||
.rememberMeServices(
|
||||
TokenBasedRememberMeServices(komgaProperties.rememberMe.key, komgaUserDetailsLifecycle).apply {
|
||||
setTokenValiditySeconds(komgaProperties.rememberMe.validity)
|
||||
setAlwaysRemember(true)
|
||||
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
}
|
||||
)
|
||||
.rememberMe {
|
||||
it.rememberMeServices(
|
||||
TokenBasedRememberMeServices(komgaProperties.rememberMe.key, komgaUserDetailsLifecycle).apply {
|
||||
setTokenValiditySeconds(komgaProperties.rememberMe.validity)
|
||||
setAlwaysRemember(true)
|
||||
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
override fun configure(web: WebSecurity) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package org.gotson.komga.infrastructure.security
|
||||
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetails
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
class UserAgentWebAuthenticationDetails(request: HttpServletRequest) : WebAuthenticationDetails(request) {
|
||||
val userAgent: String = request.getHeader("User-Agent")
|
||||
val userAgent: String = request.getHeader(HttpHeaders.USER_AGENT)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
package org.gotson.komga.infrastructure.security.oauth2
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.springframework.core.ParameterizedTypeReference
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.RequestEntity
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.util.UriComponentsBuilder
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
class GithubOAuth2UserService : DefaultOAuth2UserService() {
|
||||
private val emailScopes = listOf("user:email", "user")
|
||||
|
||||
private val parameterizedResponseType = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
|
||||
|
||||
override fun loadUser(userRequest: OAuth2UserRequest?): OAuth2User {
|
||||
requireNotNull(userRequest) { "userRequest cannot be null" }
|
||||
|
||||
var oAuth2User = super.loadUser(userRequest)
|
||||
|
||||
if (userRequest.clientRegistration.scopes.intersect(emailScopes).isNotEmpty() &&
|
||||
oAuth2User.getAttribute<String>("email") == null
|
||||
) {
|
||||
try {
|
||||
val email = RestTemplate().exchange(
|
||||
RequestEntity<Any>(
|
||||
HttpHeaders().apply { setBearerAuth(userRequest.accessToken.tokenValue) },
|
||||
HttpMethod.GET,
|
||||
UriComponentsBuilder.fromUriString("${userRequest.clientRegistration.providerDetails.userInfoEndpoint.uri}/emails").build().toUri()
|
||||
),
|
||||
parameterizedResponseType,
|
||||
)
|
||||
.body?.let { emails ->
|
||||
emails
|
||||
.filter { it["verified"] == true }
|
||||
.filter { it["primary"] == true }
|
||||
.firstNotNullOfOrNull { it["email"].toString() }
|
||||
}
|
||||
oAuth2User = DefaultOAuth2User(
|
||||
oAuth2User.authorities,
|
||||
oAuth2User.attributes.toMutableMap().apply { put("email", email) },
|
||||
userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn { "Could not retrieve emails" }
|
||||
}
|
||||
}
|
||||
|
||||
return oAuth2User
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package org.gotson.komga.infrastructure.security.oauth2
|
||||
|
||||
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
|
||||
@Configuration
|
||||
class KomgaOAuth2UserServiceConfiguration(
|
||||
private val userRepository: KomgaUserRepository,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun oauth2UserService(): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
val defaultDelegate = DefaultOAuth2UserService()
|
||||
val githubDelegate = GithubOAuth2UserService()
|
||||
|
||||
return OAuth2UserService { userRequest: OAuth2UserRequest ->
|
||||
val delegate = when (userRequest.clientRegistration.registrationId.lowercase()) {
|
||||
"github" -> githubDelegate
|
||||
else -> defaultDelegate
|
||||
}
|
||||
|
||||
val oAuth2User = delegate.loadUser(userRequest)
|
||||
|
||||
val email = oAuth2User.getAttribute<String>("email")
|
||||
?: throw OAuth2AuthenticationException("ERR_1024")
|
||||
|
||||
userRepository.findByEmailIgnoreCaseOrNull(email)?.let {
|
||||
KomgaPrincipal(it, oAuth2User = oAuth2User)
|
||||
} ?: throw OAuth2AuthenticationException("ERR_1025")
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun oidcUserService(): OAuth2UserService<OidcUserRequest, OidcUser> {
|
||||
val delegate = OidcUserService()
|
||||
return OAuth2UserService { userRequest: OidcUserRequest ->
|
||||
val oidcUser = delegate.loadUser(userRequest)
|
||||
|
||||
if (!oidcUser.emailVerified) throw OAuth2AuthenticationException("ERR_1026")
|
||||
|
||||
userRepository.findByEmailIgnoreCaseOrNull(oidcUser.email)?.let {
|
||||
KomgaPrincipal(it, oidcUser)
|
||||
} ?: throw OAuth2AuthenticationException("ERR_1025")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/oauth2", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
class OAuth2Controller(
|
||||
clientRegistrationRepository: InMemoryClientRegistrationRepository,
|
||||
) {
|
||||
|
||||
val registrationIds = clientRegistrationRepository.map {
|
||||
OAuth2ClientDto(it.clientName, it.registrationId)
|
||||
}
|
||||
|
||||
@RequestMapping("providers")
|
||||
fun getProviders() = registrationIds
|
||||
}
|
||||
|
||||
data class OAuth2ClientDto(
|
||||
val name: String,
|
||||
val registrationId: String,
|
||||
)
|
||||
|
|
@ -13,6 +13,7 @@ data class AuthenticationActivityDto(
|
|||
val error: String?,
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
val dateTime: LocalDateTime,
|
||||
val source: String?,
|
||||
)
|
||||
|
||||
fun AuthenticationActivity.toDto() =
|
||||
|
|
@ -24,4 +25,5 @@ fun AuthenticationActivity.toDto() =
|
|||
success = success,
|
||||
error = error,
|
||||
dateTime = dateTime,
|
||||
source = source,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue