Skip to content

Commit

Permalink
feat: save activity in DB bayang#116
Browse files Browse the repository at this point in the history
  • Loading branch information
bayang committed Oct 26, 2024
1 parent 605fce2 commit baa86f8
Show file tree
Hide file tree
Showing 44 changed files with 473 additions and 156 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.data:spring-data-jdbc") // required since exposed 0.51.0
implementation("org.springframework.session:spring-session-core")
implementation("com.github.gotson:spring-session-caffeine:2.0.0")
implementation("org.springframework.session:spring-session-jdbc")
implementation("org.springframework.security:spring-security-ldap")
// implementation("com.unboundid:unboundid-ldapsdk:6.0.5")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
Expand Down
39 changes: 38 additions & 1 deletion src/jelu-ui/src/components/ProfilePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import { key } from '../store'
import UserModalVue from './UserModal.vue'
import { Provider } from "../model/User"
import { LoginHistoryInfo, Provider } from "../model/User"
import { Shelf } from "../model/Shelf"
import dataService from "../services/DataService";
import { Tag } from "../model/Tag"
Expand All @@ -32,6 +32,8 @@ const user = computed(() => {
let filteredTags: Ref<Array<Tag>> = ref([]);
const isFetching = ref(false)
let loginHistoryInfo: Ref<Array<LoginHistoryInfo>> = ref([])
function getFilteredTags(text: string) {
isFetching.value = true
dataService.findTagsByCriteria(text).then((data) => filteredTags.value = data.content)
Expand Down Expand Up @@ -86,6 +88,14 @@ const shelves = computed(() => {
return store.getters.getShelves
})
const fetchHistoryInfo = async () => {
dataService.userLoginHistory()
.then( res => loginHistoryInfo.value = res)
.catch(err => console.log(err))
}
fetchHistoryInfo()
</script>

<template>
Expand Down Expand Up @@ -197,6 +207,33 @@ const shelves = computed(() => {
</div>
</div>
</div>
<div class="overflow-x-auto mt-6">
<h1 class="text-2xl typewriter capitalize">
{{ t('login_info.activity') }}
</h1>
<table class="table">
<thead>
<tr class="text-xl">
<th>{{ t('login_info.ip') }}</th>
<th>{{ t('login_info.user_agent') }}</th>
<th>{{ t('login_info.source') }}</th>
<th>{{ t('login_info.last_activity') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="line in loginHistoryInfo"
:key="line.date"
class="text-lg"
>
<td>{{ line.ip }}</td>
<td>{{ line.userAgent }}</td>
<td>{{ line.source }}</td>
<td>{{ line.date }}</td>
</tr>
</tbody>
</table>
</div>
</template>

<style lang="scss" scoped>
Expand Down
7 changes: 7 additions & 0 deletions src/jelu-ui/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,5 +351,12 @@
"edit_series" : "edit series",
"create_series" : "create series",
"name" : "name"
},
"login_info" : {
"ip" : "ip",
"user_agent" : "user agent",
"source" : "source",
"last_activity" : "last activity",
"activity" : "login activity"
}
}
8 changes: 7 additions & 1 deletion src/jelu-ui/src/model/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,10 @@ export enum Provider {
LDAP = 'LDAP',
JELU_DB = 'JELU_DB',
PROXY = 'PROXY'
}
}
export interface LoginHistoryInfo {
ip?: string,
userAgent?: string,
source?: string,
date?: string,
}
20 changes: 19 additions & 1 deletion src/jelu-ui/src/services/DataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosHeaders, AxiosInstance } from "axios";
import { UserBook, Book, UserBookBulkUpdate, UserBookUpdate } from "../model/Book";
import { Author } from "../model/Author";
import router from '../router'
import { CreateUser, UpdateUser, User, UserAuthentication } from "../model/User";
import { CreateUser, LoginHistoryInfo, UpdateUser, User, UserAuthentication } from "../model/User";
import { CreateReadingEvent, ReadingEvent, ReadingEventType, ReadingEventWithUserBook } from "../model/ReadingEvent";
import { Tag } from "../model/Tag";
import { Metadata } from "../model/Metadata";
Expand Down Expand Up @@ -39,6 +39,8 @@ class DataService {
private API_USERBOOK = '/userbooks';

private API_USER = '/users';

private API_HISTORY = '/history';

private API_AUTHOR = '/authors';

Expand Down Expand Up @@ -977,6 +979,22 @@ class DataService {
throw new Error("error random quotes " + error)
}
}

userLoginHistory = async () => {
try {
const response = await this.apiClient.get<Array<LoginHistoryInfo>>(`${this.API_USER}${this.API_HISTORY}`);
console.log("called history info")
console.log(response)
return response.data;
}
catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.log("error axios " + error.response.status + " " + error.response.data.error)
}
console.log("error history info " + (error as AxiosError).code)
throw new Error("error history info " + error)
}
}

/*
* Dates are deserialized as strings, convert to Date instead
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ private val LOGGER = KotlinLogging.logger {}
class AuthHeaderFilter(
private val userService: UserService,
private val properties: JeluProperties,
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
) : OncePerRequestFilter() {

override fun doFilterInternal(
Expand All @@ -37,16 +38,16 @@ class AuthHeaderFilter(
val user: JeluUser = if (res.isEmpty()) {
val isAdmin = properties.auth.proxy.adminName.isNotBlank() && properties.auth.proxy.adminName == headerAuth
val saved = userService.save(CreateUserDto(login = headerAuth, password = "proxy", isAdmin = isAdmin, Provider.PROXY))
JeluUser(userService.findUserEntityById(saved.id!!))
JeluUser(userService.findUserEntityById(saved.id!!).toUserDto())
} else {
JeluUser(userService.findUserEntityById(res.first().id!!))
JeluUser(userService.findUserEntityById(res.first().id!!).toUserDto())
}
val authentication = UsernamePasswordAuthenticationToken(
user,
null,
user.authorities,
)
authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
authentication.details = userAgentWebAuthenticationDetailsSource.buildDetails(request)
SecurityContextHolder.getContext().authentication = authentication
}
filterChain.doFilter(request, response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ class JeluLdapUserDetailsContextMapper(
val res = userRepository.findByLoginAndProvider(username!!, Provider.LDAP)
if (res.empty()) {
val saved = userRepository.save(CreateUserDto(login = username, password = "ldap", isAdmin = isAdmin, Provider.LDAP))
return JeluUser(saved)
return JeluUser(saved.toUserDto())
}
var user = res.first()
if (user.isAdmin != isAdmin) {
user = userRepository.updateUser(user.id.value, UpdateUserDto(password = "ldap", isAdmin = isAdmin, provider = null))
}
return JeluUser(user)
return JeluUser(user.toUserDto())
}

private fun findAdminMembership(attributes: Attributes?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ data class JeluProperties(
)

data class Session(
@get:Positive var duration: Int,
@get:Positive var duration: Long,
)

data class Cors(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource

@Configuration
@EnableWebSecurity
Expand All @@ -22,6 +23,7 @@ class SecurityConfig(
private val userDetailsService: UserDetailsService,
private val passwordEncoder: PasswordEncoder,
private val authHeaderFilter: AuthHeaderFilter?,
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
) {

@Bean
Expand Down Expand Up @@ -70,7 +72,9 @@ class SecurityConfig(
"/api/**",
).hasRole("USER")
}
.httpBasic { }
.httpBasic {
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
}
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
}
Expand Down
77 changes: 71 additions & 6 deletions src/main/kotlin/io/github/bayang/jelu/config/SessionConfig.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
package io.github.bayang.jelu.config

import com.github.gotson.spring.session.caffeine.CaffeineIndexedSessionRepository
import com.github.gotson.spring.session.caffeine.config.annotation.web.http.EnableCaffeineHttpSession
import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.BeanClassLoaderAware
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.support.GenericConversionService
import org.springframework.core.serializer.Deserializer
import org.springframework.core.serializer.Serializer
import org.springframework.core.serializer.support.DeserializingConverter
import org.springframework.core.serializer.support.SerializingConverter
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.jackson2.SecurityJackson2Modules
import org.springframework.session.FindByIndexNameSessionRepository
import org.springframework.session.config.SessionRepositoryCustomizer
import org.springframework.session.jdbc.JdbcIndexedSessionRepository
import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession
import org.springframework.session.security.SpringSessionBackedSessionRegistry
import org.springframework.session.web.http.HeaderHttpSessionIdResolver
import org.springframework.session.web.http.HttpSessionIdResolver
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.time.Duration

@EnableCaffeineHttpSession
@EnableJdbcHttpSession
@Configuration
class SessionConfig {
class SessionConfig : BeanClassLoaderAware {

val CREATE_SESSION_ATTRIBUTE_QUERY: String =
"INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)\nVALUES (?, ?, json(?))\n"
val UPDATE_SESSION_ATTRIBUTE_QUERY: String =
"UPDATE %TABLE_NAME%_ATTRIBUTES\nSET ATTRIBUTE_BYTES = json(?)\nWHERE SESSION_PRIMARY_ID = ?\nAND ATTRIBUTE_NAME = ?\n"
val DELETE_SESSIONS_BY_EXPIRY_TIME_QUERY: String =
"DELETE FROM %TABLE_NAME% WHERE PRIMARY_ID IN (SELECT SESSION_PRIMARY_ID FROM %TABLE_NAME%_ATTRIBUTES WHERE ATTRIBUTE_NAME = 'org.springframework.session.security.SpringSessionBackedSessionInformation.EXPIRED' AND ATTRIBUTE_BYTES = 'true') OR EXPIRY_TIME < ?\n"

@Bean
fun httpSessionIdResolver(): HttpSessionIdResolver {
Expand All @@ -26,7 +46,52 @@ class SessionConfig {

@Bean
fun customizeSessionRepository(properties: JeluProperties) =
SessionRepositoryCustomizer<CaffeineIndexedSessionRepository>() {
it.setDefaultMaxInactiveInterval(properties.session.duration)
SessionRepositoryCustomizer<JdbcIndexedSessionRepository>() {
it.setDefaultMaxInactiveInterval(Duration.ofSeconds(properties.session.duration))
it.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY)
it.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY)
it.setDeleteSessionsByExpiryTimeQuery(DELETE_SESSIONS_BY_EXPIRY_TIME_QUERY)
}

// https://github.com/spring-projects/spring-session/issues/1011#issuecomment-919639470
// @Bean("springSessionTransactionOperations")
// fun springSessionTransactionOperations(): TransactionOperations {
// return TransactionOperations.withoutTransaction()
// }

private var classLoader: ClassLoader? = null

@Bean("springSessionConversionService")
fun springSessionConversionService(objectMapper: ObjectMapper): GenericConversionService {
val copy = objectMapper.copy()
// https://docs.spring.io/spring-session/reference/configuration/jdbc.html#session-attributes-as-json
// Register Spring Security Jackson Modules
copy.registerModules(SecurityJackson2Modules.getModules(this.classLoader))
copy.disable(MapperFeature.USE_GETTERS_AS_SETTERS) // mandatory to deserialize setterless authorities on user
copy.addMixIn(UserAgentWebAuthenticationDetails::class.java, UserAgentWebAuthenticationDetailsMixin::class.java)
val converter = GenericConversionService()
converter.addConverter(Any::class.java, ByteArray::class.java, SerializingConverter(JsonSerializer(copy)))
converter.addConverter(ByteArray::class.java, Any::class.java, DeserializingConverter(JsonDeserializer(copy)))
return converter
}

override fun setBeanClassLoader(classLoader: ClassLoader) {
this.classLoader = classLoader
}

class JsonSerializer internal constructor(private val objectMapper: ObjectMapper) :
Serializer<Any?> {
@Throws(IOException::class)
override fun serialize(`object`: Any, outputStream: OutputStream) {
objectMapper.writeValue(outputStream, `object`)
}
}

class JsonDeserializer internal constructor(private val objectMapper: ObjectMapper) :
Deserializer<Any?> {
@Throws(IOException::class)
override fun deserialize(inputStream: InputStream): Any {
return objectMapper.readValue(inputStream, Any::class.java)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.github.bayang.jelu.config

import com.fasterxml.jackson.annotation.JsonAutoDetect
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonTypeInfo
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpHeaders
import org.springframework.security.web.authentication.WebAuthenticationDetails

class UserAgentWebAuthenticationDetails : WebAuthenticationDetails {

var userAgent: String = ""

constructor(remoteAddress: String?, sessionId: String?, userAgent: String?) : super(remoteAddress, sessionId) {
if (userAgent != null) {
this.userAgent = userAgent
}
}

constructor(request: HttpServletRequest) : super(request) {
this.userAgent = request.getHeader(HttpHeaders.USER_AGENT).orEmpty()
}

override fun toString(): String {
val sb = StringBuilder()
sb.append(javaClass.simpleName).append(" [")
sb.append("RemoteIpAddress=").append(this.remoteAddress).append(", ")
sb.append("UserAgent=").append(this.userAgent).append(", ")
sb.append("SessionId=").append(this.sessionId).append("]")
return sb.toString()
}
}

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
creatorVisibility = JsonAutoDetect.Visibility.ANY,
)
class UserAgentWebAuthenticationDetailsMixin @JsonCreator constructor(
@JsonProperty("remoteAddress") remoteAddress: String?,
@JsonProperty("sessionId") sessionId: String?,
@JsonProperty("userAgent") userAgent: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.github.bayang.jelu.config

import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component

@Component
class UserAgentWebAuthenticationDetailsSource : WebAuthenticationDetailsSource() {
override fun buildDetails(context: HttpServletRequest): UserAgentWebAuthenticationDetails =
UserAgentWebAuthenticationDetails(context)
}
Loading

0 comments on commit baa86f8

Please sign in to comment.