From 7438bf4c95ec44ef473ee1e8cc9336bb43f26811 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Mon, 27 Sep 2021 11:32:54 +0800 Subject: [PATCH] feat: oauth2 login closes #143 --- ERRORCODES.md | 3 + komga/build.gradle.kts | 1 + ...222612__authentication_activity_source.sql | 2 + .../domain/model/AuthenticationActivity.kt | 1 + .../jooq/AuthenticationActivityDao.kt | 5 +- .../infrastructure/security/KomgaPrincipal.kt | 20 +++- .../infrastructure/security/LoginListener.kt | 43 +++++++-- .../security/SecurityConfiguration.kt | 94 ++++++++++++------- .../UserAgentWebAuthenticationDetails.kt | 3 +- .../oauth2/GithubOAuth2UserService.kt | 57 +++++++++++ .../KomgaOAuth2UserServiceConfiguration.kt | 56 +++++++++++ .../komga/interfaces/rest/OAuth2Controller.kt | 25 +++++ .../rest/dto/AuthenticationActivityDto.kt | 2 + 13 files changed, 265 insertions(+), 47 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/OAuth2Controller.kt diff --git a/ERRORCODES.md b/ERRORCODES.md index 18a1abdbe..03f74d5ad 100644 --- a/ERRORCODES.md +++ b/ERRORCODES.md @@ -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 diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index f1b025a63..c796343b5 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -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") diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql b/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql new file mode 100644 index 000000000..23d92a655 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql @@ -0,0 +1,2 @@ +ALTER TABLE AUTHENTICATION_ACTIVITY + ADD COLUMN SOURCE varchar NULL DEFAULT NULL; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt index 15a1f6fcb..66bce47d1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt @@ -10,4 +10,5 @@ data class AuthenticationActivity( val success: Boolean, val error: String? = null, val dateTime: LocalDateTime = LocalDateTime.now(), + val source: String? = null, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/AuthenticationActivityDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/AuthenticationActivityDao.kt index 45b0588c9..f9abcefc5 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/AuthenticationActivityDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/AuthenticationActivityDao.kt @@ -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, ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt index b71ad87d6..987b004f9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt @@ -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 = 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 = oAuth2User?.attributes ?: mutableMapOf() + + override fun getClaims(): MutableMap = oidcUser?.claims ?: mutableMapOf() + + override fun getUserInfo(): OidcUserInfo? = oidcUser?.userInfo + + override fun getIdToken(): OidcIdToken? = oidcUser?.idToken } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt index 13e1646be..12ef937db 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt @@ -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 } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index a9932a332..9e1ad6305 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -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, + private val oidcUserService: OAuth2UserService, 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) { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt index b9dbcad93..07da0c9dd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt @@ -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) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt new file mode 100644 index 000000000..e9dbcee05 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt @@ -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>>() {} + + 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("email") == null + ) { + try { + val email = RestTemplate().exchange( + RequestEntity( + 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 + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt new file mode 100644 index 000000000..299f6c14b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt @@ -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 { + 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("email") + ?: throw OAuth2AuthenticationException("ERR_1024") + + userRepository.findByEmailIgnoreCaseOrNull(email)?.let { + KomgaPrincipal(it, oAuth2User = oAuth2User) + } ?: throw OAuth2AuthenticationException("ERR_1025") + } + } + + @Bean + fun oidcUserService(): OAuth2UserService { + 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") + } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/OAuth2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/OAuth2Controller.kt new file mode 100644 index 000000000..0e880678b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/OAuth2Controller.kt @@ -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, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/AuthenticationActivityDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/AuthenticationActivityDto.kt index 79fa09d56..bdc7fb58e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/AuthenticationActivityDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/AuthenticationActivityDto.kt @@ -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, )