Skip to content

Commit

Permalink
feat(api): store authentication activity
Browse files Browse the repository at this point in the history
closes #160
  • Loading branch information
gotson committed Jun 25, 2021
1 parent 77a55be commit de96e0d
Show file tree
Hide file tree
Showing 17 changed files with 352 additions and 11 deletions.
@@ -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)
);
@@ -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(),
)
@@ -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)
}
Expand Up @@ -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>

Expand Down
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}

Expand Down
@@ -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(),
)
}
Expand Up @@ -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()
Expand Down
@@ -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
}
}
Expand Up @@ -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 {}

Expand All @@ -25,6 +26,7 @@ class SecurityConfiguration(

override fun configure(http: HttpSecurity) {
// @formatter:off
val userAgentWebAuthenticationDetailsSource = UserAgentWebAuthenticationDetailsSource()

http
.cors()
Expand All @@ -51,11 +53,13 @@ class SecurityConfiguration(
}

.httpBasic()
.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)

.and()
.logout()
.logoutUrl("/api/v1/users/logout")
.deleteCookies("JSESSIONID")
.invalidateHttpSession(true)

.and()
.sessionManagement()
Expand All @@ -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
}
Expand Down
@@ -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")
}
@@ -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)
}

0 comments on commit de96e0d

Please sign in to comment.