feat(api): store authentication activity

closes #160
This commit is contained in:
Gauthier Roebroeck 2021-06-25 18:42:47 +08:00
parent 77a55be1d4
commit de96e0dcef
17 changed files with 352 additions and 11 deletions

View file

@ -0,0 +1,11 @@
create table AUTHENTICATION_ACTIVITY
(
USER_ID varchar NULL DEFAULT NULL,
EMAIL varchar NULL DEFAULT NULL,
IP varchar NULL DEFAULT NULL,
USER_AGENT varchar NULL DEFAULT NULL,
SUCCESS boolean NOT NULL,
ERROR varchar NULL DEFAULT NULL,
DATE_TIME datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (USER_ID) references USER (ID)
);

View file

@ -0,0 +1,13 @@
package org.gotson.komga.domain.model
import java.time.LocalDateTime
data class AuthenticationActivity(
val userId: String? = null,
val email: String? = null,
val ip: String? = null,
val userAgent: String? = null,
val success: Boolean,
val error: String? = null,
val dateTime: LocalDateTime = LocalDateTime.now(),
)

View file

@ -0,0 +1,17 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.AuthenticationActivity
import org.gotson.komga.domain.model.KomgaUser
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
interface AuthenticationActivityRepository {
fun findAll(pageable: Pageable): Page<AuthenticationActivity>
fun findAllByUser(user: KomgaUser, pageable: Pageable): Page<AuthenticationActivity>
fun insert(activity: AuthenticationActivity)
fun deleteByUser(user: KomgaUser)
fun deleteOlderThan(dateTime: LocalDateTime)
}

View file

@ -6,7 +6,7 @@ interface KomgaUserRepository {
fun count(): Long
fun findByIdOrNull(id: String): KomgaUser?
fun findByEmailIgnoreCase(email: String): KomgaUser?
fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser?
fun findAll(): Collection<KomgaUser>

View file

@ -3,6 +3,7 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
@ -20,18 +21,19 @@ private val logger = KotlinLogging.logger {}
class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository,
private val readProgressRepository: ReadProgressRepository,
private val authenticationActivityRepository: AuthenticationActivityRepository,
private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry
private val sessionRegistry: SessionRegistry,
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
userRepository.findByEmailIgnoreCase(username)?.let {
userRepository.findByEmailIgnoreCaseOrNull(username)?.let {
KomgaPrincipal(it)
} ?: throw UsernameNotFoundException(username)
fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails {
userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser ->
userRepository.findByEmailIgnoreCaseOrNull(user.username)?.let { komgaUser ->
logger.info { "Changing password for user ${user.username}" }
val updatedUser = komgaUser.copy(password = passwordEncoder.encode(newPassword))
userRepository.update(updatedUser)
@ -58,8 +60,11 @@ class KomgaUserLifecycle(
@Transactional
fun deleteUser(user: KomgaUser) {
logger.info { "Deleting user: $user" }
readProgressRepository.deleteByUserId(user.id)
authenticationActivityRepository.deleteByUser(user)
userRepository.delete(user.id)
expireSessions(user)
}

View file

@ -0,0 +1,96 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.AuthenticationActivity
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.AuthenticationActivityRecord
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class AuthenticationActivityDao(
private val dsl: DSLContext
) : AuthenticationActivityRepository {
private val aa = Tables.AUTHENTICATION_ACTIVITY
private val sorts = mapOf(
"dateTime" to aa.DATE_TIME,
"email" to aa.EMAIL,
"success" to aa.SUCCESS,
"ip" to aa.IP,
"error" to aa.ERROR,
"userId" to aa.USER_ID,
"userAgent" to aa.USER_AGENT,
)
override fun findAll(pageable: Pageable): Page<AuthenticationActivity> {
val conditions: Condition = DSL.trueCondition()
return findAll(conditions, pageable)
}
override fun findAllByUser(user: KomgaUser, pageable: Pageable): Page<AuthenticationActivity> {
val conditions = aa.USER_ID.eq(user.id).or(aa.EMAIL.eq(user.email))
return findAll(conditions, pageable)
}
private fun findAll(conditions: Condition, pageable: Pageable): PageImpl<AuthenticationActivity> {
val count = dsl.fetchCount(aa)
val orderBy = pageable.sort.toOrderBy(sorts)
val items = dsl.selectFrom(aa)
.where(conditions)
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchInto(aa)
.map { it.toDomain() }
val pageSort = if (orderBy.size > 1) pageable.sort else Sort.unsorted()
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
else PageRequest.of(0, maxOf(count, 20), pageSort),
count.toLong(),
)
}
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)
.execute()
}
override fun deleteByUser(user: KomgaUser) {
dsl.deleteFrom(aa)
.where(aa.USER_ID.eq(user.id))
.or(aa.EMAIL.eq(user.email))
.execute()
}
override fun deleteOlderThan(dateTime: LocalDateTime) {
dsl.deleteFrom(aa)
.where(aa.DATE_TIME.lt(dateTime))
.execute()
}
private fun AuthenticationActivityRecord.toDomain() =
AuthenticationActivity(
userId = userId,
email = email,
ip = ip,
userAgent = userAgent,
success = success,
error = error,
dateTime = dateTime.toCurrentTimeZone(),
)
}

View file

@ -118,7 +118,7 @@ class KomgaUserDao(
.where(u.EMAIL.equalIgnoreCase(email))
)
override fun findByEmailIgnoreCase(email: String): KomgaUser? =
override fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? =
selectBase()
.where(u.EMAIL.equalIgnoreCase(email))
.fetchAndMap()

View file

@ -0,0 +1,67 @@
package org.gotson.komga.infrastructure.security
import mu.KotlinLogging
import org.gotson.komga.domain.model.AuthenticationActivity
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.event.AbstractAuthenticationFailureEvent
import org.springframework.security.authentication.event.AuthenticationSuccessEvent
import org.springframework.security.web.authentication.WebAuthenticationDetails
import org.springframework.stereotype.Component
import java.util.EventObject
private val logger = KotlinLogging.logger {}
@Component
class LoginListener(
private val authenticationActivityRepository: AuthenticationActivityRepository,
private val userRepository: KomgaUserRepository,
) {
@EventListener
fun onSuccess(event: AuthenticationSuccessEvent) {
val user = (event.authentication.principal as KomgaPrincipal).user
val activity = AuthenticationActivity(
userId = user.id,
email = user.email,
ip = event.getIp(),
userAgent = event.getUserAgent(),
success = true,
)
logger.info { activity }
authenticationActivityRepository.insert(activity)
}
@EventListener
fun onFailure(event: AbstractAuthenticationFailureEvent) {
val user = event.authentication.principal.toString()
val activity = AuthenticationActivity(
userId = userRepository.findByEmailIgnoreCaseOrNull(user)?.id,
email = user,
ip = event.getIp(),
userAgent = event.getUserAgent(),
success = false,
error = event.exception.message,
)
logger.info { activity }
authenticationActivityRepository.insert(activity)
}
private fun EventObject.getIp(): String? =
when (source) {
is WebAuthenticationDetails -> (source as WebAuthenticationDetails).remoteAddress
is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as WebAuthenticationDetails).remoteAddress
else -> 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
}
}

View file

@ -12,6 +12,7 @@ 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.web.authentication.rememberme.TokenBasedRememberMeServices
private val logger = KotlinLogging.logger {}
@ -25,6 +26,7 @@ class SecurityConfiguration(
override fun configure(http: HttpSecurity) {
// @formatter:off
val userAgentWebAuthenticationDetailsSource = UserAgentWebAuthenticationDetailsSource()
http
.cors()
@ -51,11 +53,13 @@ class SecurityConfiguration(
}
.httpBasic()
.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
.and()
.logout()
.logoutUrl("/api/v1/users/logout")
.deleteCookies("JSESSIONID")
.invalidateHttpSession(true)
.and()
.sessionManagement()
@ -67,10 +71,13 @@ class SecurityConfiguration(
http
.rememberMe()
.key(komgaProperties.rememberMe.key)
.tokenValiditySeconds(komgaProperties.rememberMe.validity)
.alwaysRemember(true)
.userDetailsService(komgaUserDetailsLifecycle)
.rememberMeServices(
TokenBasedRememberMeServices(komgaProperties.rememberMe.key, komgaUserDetailsLifecycle).apply {
setTokenValiditySeconds(komgaProperties.rememberMe.validity)
setAlwaysRemember(true)
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
}
)
}
// @formatter:on
}

View file

@ -0,0 +1,8 @@
package org.gotson.komga.infrastructure.security
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")
}

View file

@ -0,0 +1,9 @@
package org.gotson.komga.infrastructure.security
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import javax.servlet.http.HttpServletRequest
class UserAgentWebAuthenticationDetailsSource : WebAuthenticationDetailsSource() {
override fun buildDetails(context: HttpServletRequest): UserAgentWebAuthenticationDetails =
UserAgentWebAuthenticationDetails(context)
}

View file

@ -1,14 +1,18 @@
package org.gotson.komga.interfaces.rest
import io.swagger.v3.oas.annotations.Parameter
import mu.KotlinLogging
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.rest.dto.AuthenticationActivityDto
import org.gotson.komga.interfaces.rest.dto.PasswordUpdateDto
import org.gotson.komga.interfaces.rest.dto.RolesUpdateDto
import org.gotson.komga.interfaces.rest.dto.SharedLibrariesUpdateDto
@ -17,7 +21,12 @@ import org.gotson.komga.interfaces.rest.dto.UserDto
import org.gotson.komga.interfaces.rest.dto.UserWithSharedLibrariesDto
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.dto.toWithSharedLibrariesDto
import org.springdoc.core.converters.models.PageableAsQueryParam
import org.springframework.core.env.Environment
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
@ -29,6 +38,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
@ -42,6 +52,7 @@ class UserController(
private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository,
private val authenticationActivityRepository: AuthenticationActivityRepository,
env: Environment
) {
@ -126,4 +137,48 @@ class UserController(
logger.info { "Updated user shared libraries: $updatedUser" }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("me/authentication-activity")
@PageableAsQueryParam
fun getMyAuthenticationActivity(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort
)
return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() }
}
@GetMapping("authentication-activity")
@PageableAsQueryParam
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAuthenticationActivity(
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort
)
return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() }
}
}

View file

@ -0,0 +1,27 @@
package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat
import org.gotson.komga.domain.model.AuthenticationActivity
import java.time.LocalDateTime
data class AuthenticationActivityDto(
val userId: String?,
val email: String?,
val ip: String?,
val userAgent: String?,
val success: Boolean,
val error: String?,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val dateTime: LocalDateTime,
)
fun AuthenticationActivity.toDto() =
AuthenticationActivityDto(
userId = userId,
email = email,
ip = ip,
userAgent = userAgent,
success = success,
error = error,
dateTime = dateTime,
)

View file

@ -0,0 +1,24 @@
package org.gotson.komga.interfaces.scheduler
import mu.KotlinLogging
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneId
private val logger = KotlinLogging.logger {}
@Component
class AuthenticationActivityCleanupController(
private val authenticationActivityRepository: AuthenticationActivityRepository,
) {
// Run every day
@Scheduled(fixedRate = 86_400_000)
fun cleanup() {
val olderThan = LocalDateTime.now(ZoneId.of("Z")).minusMonths(1)
logger.info { "Remove authentication activity older than $olderThan (UTC)" }
authenticationActivityRepository.deleteOlderThan(olderThan)
}
}

View file

@ -70,6 +70,7 @@ class BookImporterTest(
@AfterAll
fun teardown() {
libraryRepository.deleteAll()
readProgressRepository.deleteAll()
userRepository.deleteAll()
}

View file

@ -56,6 +56,7 @@ class BookLifecycleTest(
@AfterAll
fun teardown() {
libraryRepository.deleteAll()
readProgressRepository.deleteAll()
userRepository.deleteAll()
}

View file

@ -173,8 +173,8 @@ class KomgaUserDaoTest(
KomgaUser("user1@example.org", "p", false)
)
val found = komgaUserDao.findByEmailIgnoreCase("USER1@EXAMPLE.ORG")
val notFound = komgaUserDao.findByEmailIgnoreCase("USER2@EXAMPLE.ORG")
val found = komgaUserDao.findByEmailIgnoreCaseOrNull("USER1@EXAMPLE.ORG")
val notFound = komgaUserDao.findByEmailIgnoreCaseOrNull("USER2@EXAMPLE.ORG")
assertThat(found).isNotNull
assertThat(found?.email).isEqualTo("user1@example.org")