feat: oauth2 login

closes #143
This commit is contained in:
Gauthier Roebroeck 2021-09-27 11:32:54 +08:00
parent 2d05fa16c8
commit 7438bf4c95
13 changed files with 265 additions and 47 deletions

View file

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

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE AUTHENTICATION_ACTIVITY
ADD COLUMN SOURCE varchar NULL DEFAULT NULL;

View file

@ -10,4 +10,5 @@ data class AuthenticationActivity(
val success: Boolean,
val error: String? = null,
val dateTime: LocalDateTime = LocalDateTime.now(),
val source: String? = null,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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