diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/SupabaseAutoConfiguration.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/SupabaseAutoConfiguration.kt index 18741fe..a5317e6 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/SupabaseAutoConfiguration.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/SupabaseAutoConfiguration.kt @@ -10,7 +10,6 @@ import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationPro import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtVerifier import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseSecurityConfig import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService -import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserServiceGoTrueImpl import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.gotrue.Auth import io.github.jan.supabase.gotrue.auth @@ -47,7 +46,7 @@ class SupabaseAutoConfiguration( applicationEventPublisher: ApplicationEventPublisher ): SupabaseUserService { logger.debug("Registering the SupabaseUserService") - return SupabaseUserServiceGoTrueImpl( + return SupabaseUserService( supabaseProperties, goTrueClient, applicationEventPublisher, diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/SupabaseProperties.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/SupabaseProperties.kt index 64e0cf8..69d22dd 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/SupabaseProperties.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/SupabaseProperties.kt @@ -15,6 +15,7 @@ class SupabaseProperties( val passwordRecoveryPage: String?, val unauthenticatedPage: String?, val unauthorizedPage: String?, + val postLogoutPage: String?, val sslOnly: Boolean = true, val public: Public = Public(), val roles: MutableMap = mutableMapOf(), diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/controller/SupabaseUserController.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/controller/SupabaseUserController.kt index c54da62..0b8af9a 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/controller/SupabaseUserController.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/controller/SupabaseUserController.kt @@ -2,7 +2,6 @@ package de.tschuehly.htmx.spring.supabase.auth.controller import de.tschuehly.htmx.spring.supabase.auth.exception.info.MissingCredentialsException.Companion.MissingCredentials import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService -import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -27,35 +26,39 @@ class SupabaseUserController( ) { checkCredentialsAndExecute(email, password) { checkedEmail, checkedPassword -> logger.debug("User with the email $checkedEmail is trying to login") - supabaseUserService.loginWithEmail(checkedEmail, checkedPassword, response) + supabaseUserService.loginWithEmail(checkedEmail, checkedPassword) } } @PostMapping("/signup") fun signUp( @RequestParam email: String?, - @RequestParam password: String?, - response: HttpServletResponse + @RequestParam password: String? ) { checkCredentialsAndExecute(email, password) { checkedEmail, checkedPassword -> logger.debug("User with the email $checkedEmail is trying to signup") - supabaseUserService.signUpWithEmail(checkedEmail, checkedPassword, response) + supabaseUserService.signUpWithEmail(checkedEmail, checkedPassword) } } - @PostMapping("/anon") - fun anonSignIn(request: HttpServletRequest, response: HttpServletResponse) { - supabaseUserService.signInAnonymously(request, response) + @PostMapping("/loginAnon") + fun anonSignIn() { + supabaseUserService.signInAnonymously() } + @PostMapping("/loginAnonWithEmail") + fun anonSignInWithEmail() { + + } + + @PostMapping("/linkIdentity") fun linkIdentity( - @RequestParam email: String?, - request: HttpServletRequest, response: HttpServletResponse + @RequestParam email: String? ) { if (email != null) { logger.debug("User with the email $email is linking an Anonymous User") - supabaseUserService.linkAnonToIdentity(email, request, response) + supabaseUserService.linkAnonToIdentity(email) } else { MissingCredentials.EMAIL_MISSING.throwExc() } @@ -87,19 +90,20 @@ class SupabaseUserController( password.isNullOrBlank() -> MissingCredentials.PASSWORD_MISSING.throwExc() + else -> function(email.trim(), password.trim()) } } @PostMapping("/jwt") - fun authorizeWithJwtOrResetPassword(request: HttpServletRequest, response: HttpServletResponse) { - supabaseUserService.handleClientAuthentication(request, response) + fun authorizeWithJwtOrResetPassword() { + supabaseUserService.handleClientAuthentication() } @GetMapping("/logout") - fun logout(request: HttpServletRequest, response: HttpServletResponse) { - supabaseUserService.logout(request, response) + fun logout() { + supabaseUserService.logout() } @PutMapping("/setRoles") @@ -107,14 +111,13 @@ class SupabaseUserController( fun setRoles( @RequestParam roles: List?, - request: HttpServletRequest, @RequestParam userId: String, ) { if (userId == "") { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "UserId required") } - supabaseUserService.setRolesWithRequest(request, userId, roles) + supabaseUserService.setRolesWithRequest(userId, roles) } @PostMapping("/sendPasswordResetEmail") @@ -129,11 +132,7 @@ class SupabaseUserController( @PostMapping("/updatePassword") @ResponseBody - fun updatePassword( - request: HttpServletRequest, - @RequestParam - password: String - ) { - supabaseUserService.updatePassword(request, password) + fun updatePassword(@RequestParam password: String) { + supabaseUserService.updatePassword( password) } } diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/htmx/HtmxUtil.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/htmx/HtmxUtil.kt index 40bc50a..0a032ed 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/htmx/HtmxUtil.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/htmx/HtmxUtil.kt @@ -1,8 +1,10 @@ package de.tschuehly.htmx.spring.supabase.auth.htmx +import de.tschuehly.htmx.spring.supabase.auth.exception.HxCurrentUrlHeaderNotFound import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxSwapType +import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.web.context.request.RequestContextHolder @@ -17,6 +19,9 @@ object HtmxUtil { setHeader(HtmxResponseHeader.HX_RETARGET.getValue(), cssSelector) } + fun getCurrentUrl() = getRequest().getHeader("HX-Current-URL") + ?: throw HxCurrentUrlHeaderNotFound() + fun retargetToId(id: String) { val request: HttpServletRequest = getRequest() if (request.getHeader(HtmxRequestHeader.HX_REQUEST.getValue()) != null) { @@ -41,10 +46,18 @@ object HtmxUtil { getResponse().setHeader(headerName, headerValue) } + fun setHeader(htmxResponseHeader: HtmxResponseHeader, headerValue: String?) { + getResponse().setHeader(htmxResponseHeader.value, headerValue) + } + fun isHtmxRequest(): Boolean { return getRequest().getHeader(HtmxRequestHeader.HX_REQUEST.value) == "true" } + fun getCookie(name: String): Cookie? { + return getRequest().cookies?.find { it.name == name } + } + fun getResponse(): HttpServletResponse { return (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.response ?: throw RuntimeException("No response found in RequestContextHolder") @@ -55,5 +68,4 @@ object HtmxUtil { ?: throw RuntimeException("No response found in RequestContextHolder") } - } \ No newline at end of file diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityContextHolder.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityContextHolder.kt index 2033000..6a97691 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityContextHolder.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityContextHolder.kt @@ -4,15 +4,13 @@ import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser import org.springframework.security.authentication.AnonymousAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder -class SupabaseSecurityContextHolder { - companion object { - @JvmStatic - fun getAuthenticatedUser(): SupabaseUser? { - val authentication = SecurityContextHolder.getContext().authentication - if (authentication !is AnonymousAuthenticationToken) { - return (authentication as SupabaseAuthenticationToken).principal - } - return null +object SupabaseSecurityContextHolder { + fun getAuthenticatedUser(): SupabaseUser? { + val authentication = SecurityContextHolder.getContext().authentication + if (authentication !is AnonymousAuthenticationToken) { + return (authentication as SupabaseAuthenticationToken).principal } + return null } + } \ No newline at end of file diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserService.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserService.kt index c268b56..3295f45 100644 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserService.kt +++ b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserService.kt @@ -1,25 +1,251 @@ package de.tschuehly.htmx.spring.supabase.auth.service +import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties +import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserAuthenticated +import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserEmailUpdated +import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserRolesUpdated +import de.tschuehly.htmx.spring.supabase.auth.exception.* +import de.tschuehly.htmx.spring.supabase.auth.exception.email.OtpEmailSent +import de.tschuehly.htmx.spring.supabase.auth.exception.email.PasswordRecoveryEmailSent +import de.tschuehly.htmx.spring.supabase.auth.exception.email.RegistrationConfirmationEmailSent +import de.tschuehly.htmx.spring.supabase.auth.exception.email.SuccessfulPasswordUpdate +import de.tschuehly.htmx.spring.supabase.auth.exception.info.InvalidLoginCredentialsException +import de.tschuehly.htmx.spring.supabase.auth.exception.info.NewPasswordShouldBeDifferentFromOldPasswordException +import de.tschuehly.htmx.spring.supabase.auth.exception.info.UserAlreadyRegisteredException +import de.tschuehly.htmx.spring.supabase.auth.exception.info.UserNeedsToConfirmEmailBeforeLoginException +import de.tschuehly.htmx.spring.supabase.auth.htmx.HtmxUtil +import de.tschuehly.htmx.spring.supabase.auth.security.JwtAuthenticationToken +import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationProvider +import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtFilter.Companion.setJWTCookie +import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseSecurityContextHolder import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse +import io.github.jan.supabase.exceptions.RestException +import io.github.jan.supabase.gotrue.Auth +import io.github.jan.supabase.gotrue.exception.AuthErrorCode +import io.github.jan.supabase.gotrue.exception.AuthRestException +import io.github.jan.supabase.gotrue.providers.builtin.Email +import io.github.jan.supabase.gotrue.providers.builtin.OTP +import io.github.jan.supabase.gotrue.user.UserInfo +import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_REDIRECT +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonArray +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository +import org.springframework.security.web.context.SecurityContextRepository -interface SupabaseUserService { - fun signUpWithEmail(email: String, password: String, response: HttpServletResponse) - fun loginWithEmail(email: String, password: String, response: HttpServletResponse) +class SupabaseUserService( + private val supabaseProperties: SupabaseProperties, + private val goTrueClient: Auth, + private val applicationEventPublisher: ApplicationEventPublisher, + private val authenticationManager: SupabaseAuthenticationProvider, +) { + + private val securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy() + private val securityContextRepository: SecurityContextRepository = RequestAttributeSecurityContextRepository() + private val logger: Logger = LoggerFactory.getLogger(SupabaseUserService::class.java) + + + fun signUpWithEmail(email: String, password: String) { + runGoTrue(email) { + val user = goTrueClient.signUpWith(Email) { + this.email = email + this.password = password + } + if (emailConfirmationEnabled(user)) { + logger.debug("User $email signed up, email confirmation sent") + throw RegistrationConfirmationEmailSent(email, user?.confirmationSentAt) + } + logger.debug("User $email successfully signed up") + loginWithEmail(email, password) + } + } + + private fun emailConfirmationEnabled(user: UserInfo?) = user?.email != null + + + fun loginWithEmail(email: String, password: String) { + runGoTrue(email) { + goTrueClient.signInWith(Email) { + this.email = email + this.password = password + } + val user = authenticateWithCurrentSession() + applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) + logger.debug("User: $email successfully logged in") + } + } + + fun sendOtp(email: String) { + runGoTrue(email) { + goTrueClient.signInWith(OTP) { + this.email = email + this.createUser = supabaseProperties.otpCreateUser + } + throw OtpEmailSent(email) + } + } fun handleClientAuthentication( - request: HttpServletRequest, - response: HttpServletResponse - ) - - fun logout(request: HttpServletRequest, response: HttpServletResponse) - fun setRolesWithRequest(request: HttpServletRequest, userId: String, roles: List?) - fun sendPasswordRecoveryEmail(email: String) - fun updatePassword(request: HttpServletRequest, password: String) - fun sendOtp(email: String) - fun signInAnonymously(request: HttpServletRequest, response: HttpServletResponse) - fun linkAnonToIdentity(email: String, request: HttpServletRequest, response: HttpServletResponse) - fun authenticate(jwt: String): SupabaseUser - fun authenticateWithCurrentSession(): SupabaseUser -} \ No newline at end of file + ) { + + val header = HtmxUtil.getCurrentUrl() + val user = SupabaseSecurityContextHolder.getAuthenticatedUser() + ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository") + if (header.contains("type=recovery")) { + logger.debug("User: ${user.email} is trying to reset his password") + HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.passwordRecoveryPage) + } else { + applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) + HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.successfulLoginRedirectPage) + } + } + + fun signInAnonymously() { + runGoTrue { + goTrueClient.signInAnonymously() + val user = authenticateWithCurrentSession() + applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) + } + } + + fun linkAnonToIdentity(email: String) { + runGoTrue { + val user = SupabaseSecurityContextHolder.getAuthenticatedUser() + ?: throw UnknownSupabaseException("No authenticated user found in SecurityContext") + goTrueClient.importAuthToken(user.verifiedJwt) + goTrueClient.updateUser { + this.email = email + } + applicationEventPublisher.publishEvent(SupabaseUserEmailUpdated(user.id, email)) + throw UserNeedsToConfirmEmailBeforeLoginException(email) + } + + } + + private fun authenticateWithCurrentSession(): SupabaseUser { + val token = goTrueClient.currentSessionOrNull()?.accessToken + ?: throw JWTTokenNullException("The JWT that requested from supabase is null") + HtmxUtil.getResponse().setJWTCookie(token, supabaseProperties) + HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.successfulLoginRedirectPage) + return authenticate(token) + + } + + fun authenticate(jwt: String): SupabaseUser { + val authResult = authenticationManager.authenticate(JwtAuthenticationToken(jwt)) + val context: SecurityContext = securityContextHolderStrategy.createEmptyContext() + context.authentication = authResult + HtmxUtil.getResponse().setJWTCookie(jwt, supabaseProperties) + securityContextRepository.saveContext(context, HtmxUtil.getRequest(), HtmxUtil.getResponse()) + SecurityContextHolder.setContext(context) + return authResult.principal + } + + fun logout() { + SecurityContextHolder.getContext().authentication = null + HtmxUtil.getCookie("JWT")?.let { + var cookieString = "JWT=${it.value}; HttpOnly; Path=/;Max-Age=0;" + if (supabaseProperties.sslOnly) { + cookieString += "Secure;" + } + HtmxUtil.setHeader("Set-Cookie", cookieString) + HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.postLogoutPage ?: "/") + } + } + + fun setRolesWithRequest(userId: String, roles: List?) { + HtmxUtil.getCookie("JWT")?.let { + setRoles(it.value, userId, roles) + } + } + + + private fun setRoles(serviceRoleJWT: String, userId: String, roles: List?) { + val roleArray = roles ?: listOf() + runGoTrue() { + goTrueClient.importAuthToken(serviceRoleJWT) + goTrueClient.admin.updateUserById(uid = userId) { + appMetadata = buildJsonObject { + putJsonArray("roles") { + roleArray.map { add(it) } + } + } + } + applicationEventPublisher.publishEvent(SupabaseUserRolesUpdated(userId, roleArray)) + logger.debug("The roles of the user with id {} were updated to {}", userId, roleArray) + } + } + + fun sendPasswordRecoveryEmail(email: String) { + runGoTrue(email) { + goTrueClient.resetPasswordForEmail(email) + throw PasswordRecoveryEmailSent("User with $email has requested a password recovery email") + } + } + + fun updatePassword(password: String) { + val user = SupabaseSecurityContextHolder.getAuthenticatedUser() + ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository") + val email = user.email ?: "no-email" + runGoTrue(email) { + val jwt = HtmxUtil.getCookie("JWT")?.value + ?: throw JWTTokenNullException("No JWT found in request") + goTrueClient.importAuthToken(jwt) + goTrueClient.updateUser { + this.password = password + } + throw SuccessfulPasswordUpdate(user.email) + } + } + + private fun runGoTrue( + email: String = "no-email", + block: suspend CoroutineScope.() -> Unit + ) { + runBlocking { + try { + block() + } catch (exc: AuthRestException) { + handleAuthException(exc, email) + } catch (e: RestException) { + handleGoTrueException(e, email) + } finally { + goTrueClient.clearSession() + } + } + } + + private fun handleAuthException(exc: AuthRestException, email: String) { + when (exc.errorCode) { + AuthErrorCode.UserAlreadyExists -> throw UserAlreadyRegisteredException(email) + AuthErrorCode.SamePassword -> throw NewPasswordShouldBeDifferentFromOldPasswordException(email) + AuthErrorCode.WeakPassword -> throw WeakPasswordException(email) + AuthErrorCode.OtpExpired -> throw OtpExpiredException(email) + AuthErrorCode.NotAdmin -> throw MissingServiceRoleForAdminAccessException(SupabaseSecurityContextHolder.getAuthenticatedUser()?.id) + else -> throw SupabaseAuthException(exc) + } + } + + private fun handleGoTrueException(e: RestException, email: String) { + val message = e.message ?: let { + logger.error(e.message) + throw UnknownSupabaseException() + } + when { + message.contains("Anonymous sign-ins are disabled", true) -> throw AnonymousSignInDisabled() + message.contains("Invalid login credentials", true) -> throw InvalidLoginCredentialsException(email) + message.contains("Email not confirmed", true) -> throw UserNeedsToConfirmEmailBeforeLoginException(email) + message.contains("Signups not allowed for otp", true) -> throw OtpSignupNotAllowedExceptions(message) + } + logger.error(e.message) + throw UnknownSupabaseException() + } +} diff --git a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserServiceGoTrueImpl.kt b/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserServiceGoTrueImpl.kt deleted file mode 100644 index 151849b..0000000 --- a/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserServiceGoTrueImpl.kt +++ /dev/null @@ -1,253 +0,0 @@ -package de.tschuehly.htmx.spring.supabase.auth.service - -import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties -import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserAuthenticated -import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserEmailUpdated -import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserRolesUpdated -import de.tschuehly.htmx.spring.supabase.auth.exception.* -import de.tschuehly.htmx.spring.supabase.auth.exception.email.OtpEmailSent -import de.tschuehly.htmx.spring.supabase.auth.exception.email.PasswordRecoveryEmailSent -import de.tschuehly.htmx.spring.supabase.auth.exception.email.RegistrationConfirmationEmailSent -import de.tschuehly.htmx.spring.supabase.auth.exception.email.SuccessfulPasswordUpdate -import de.tschuehly.htmx.spring.supabase.auth.exception.info.InvalidLoginCredentialsException -import de.tschuehly.htmx.spring.supabase.auth.exception.info.NewPasswordShouldBeDifferentFromOldPasswordException -import de.tschuehly.htmx.spring.supabase.auth.exception.info.UserAlreadyRegisteredException -import de.tschuehly.htmx.spring.supabase.auth.exception.info.UserNeedsToConfirmEmailBeforeLoginException -import de.tschuehly.htmx.spring.supabase.auth.htmx.HtmxUtil -import de.tschuehly.htmx.spring.supabase.auth.security.JwtAuthenticationToken -import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationProvider -import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtFilter.Companion.setJWTCookie -import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseSecurityContextHolder -import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser -import io.github.jan.supabase.exceptions.RestException -import io.github.jan.supabase.gotrue.Auth -import io.github.jan.supabase.gotrue.exception.AuthErrorCode -import io.github.jan.supabase.gotrue.exception.AuthRestException -import io.github.jan.supabase.gotrue.providers.builtin.Email -import io.github.jan.supabase.gotrue.providers.builtin.OTP -import io.github.jan.supabase.gotrue.user.UserInfo -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.putJsonArray -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.context.ApplicationEventPublisher -import org.springframework.security.core.context.SecurityContext -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.context.RequestAttributeSecurityContextRepository -import org.springframework.security.web.context.SecurityContextRepository - -class SupabaseUserServiceGoTrueImpl( - private val supabaseProperties: SupabaseProperties, - private val goTrueClient: Auth, - private val applicationEventPublisher: ApplicationEventPublisher, - - private val authenticationManager: SupabaseAuthenticationProvider, -) : SupabaseUserService { - - private val securityContextHolderStrategy = SecurityContextHolder - .getContextHolderStrategy() - private val securityContextRepository: SecurityContextRepository = RequestAttributeSecurityContextRepository() - private val logger: Logger = LoggerFactory.getLogger(SupabaseUserServiceGoTrueImpl::class.java) - - - override fun signUpWithEmail(email: String, password: String, response: HttpServletResponse) { - runGoTrue(email) { - val user = goTrueClient.signUpWith(Email) { - this.email = email - this.password = password - } - if (emailConfirmationEnabled(user)) { - logger.debug("User $email signed up, email confirmation sent") - throw RegistrationConfirmationEmailSent(email, user?.confirmationSentAt) - } - logger.debug("User $email successfully signed up") - loginWithEmail(email, password, response) - } - } - - private fun emailConfirmationEnabled(user: UserInfo?) = user?.email != null - - - override fun loginWithEmail(email: String, password: String, response: HttpServletResponse) { - runGoTrue(email) { - goTrueClient.signInWith(Email) { - this.email = email - this.password = password - } - val user = authenticateWithCurrentSession() - applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) - logger.debug("User: $email successfully logged in") - } - } - - override fun sendOtp(email: String) { - runGoTrue(email) { - goTrueClient.signInWith(OTP){ - this.email = email - this.createUser = supabaseProperties.otpCreateUser - } - throw OtpEmailSent(email) - } - } - - override fun handleClientAuthentication( - request: HttpServletRequest, response: HttpServletResponse - ) { - val header = request.getHeader("HX-Current-URL") ?: throw HxCurrentUrlHeaderNotFound() - val user = SupabaseSecurityContextHolder.getAuthenticatedUser() - ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository") - if (header.contains("type=recovery")) { - logger.debug("User: ${user.email} is trying to reset his password") - response.setHeader("HX-Redirect", supabaseProperties.passwordRecoveryPage) - } else { - applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) - response.setHeader("HX-Redirect", supabaseProperties.successfulLoginRedirectPage) - } - } - - override fun signInAnonymously(request: HttpServletRequest, response: HttpServletResponse) { - runGoTrue { - goTrueClient.signInAnonymously() - val user = authenticateWithCurrentSession() - applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user)) - } - } - - override fun linkAnonToIdentity(email: String, request: HttpServletRequest, response: HttpServletResponse) { - runGoTrue { - val user = SupabaseSecurityContextHolder.getAuthenticatedUser() - ?: throw UnknownSupabaseException("No authenticated user found in SecurityContext") - goTrueClient.importAuthToken(user.verifiedJwt) - goTrueClient.updateUser { - this.email = email - } - applicationEventPublisher.publishEvent(SupabaseUserEmailUpdated(user.id, email)) - throw UserNeedsToConfirmEmailBeforeLoginException(email) - } - - } - - override fun authenticateWithCurrentSession(): SupabaseUser { - val token = goTrueClient.currentSessionOrNull()?.accessToken - ?: throw JWTTokenNullException("The JWT that requested from supabase is null") - HtmxUtil.getResponse().setJWTCookie(token, supabaseProperties) - HtmxUtil.setHeader("HX-Redirect", supabaseProperties.successfulLoginRedirectPage) - return authenticate(token) - - } - - override fun authenticate(jwt: String): SupabaseUser { - val authResult = authenticationManager.authenticate(JwtAuthenticationToken(jwt)) - val context: SecurityContext = securityContextHolderStrategy.createEmptyContext() - context.authentication = authResult - HtmxUtil.getResponse().setJWTCookie(jwt, supabaseProperties) - securityContextRepository.saveContext(context, HtmxUtil.getRequest(), HtmxUtil.getResponse()) - SecurityContextHolder.setContext(context) - return authResult.principal - } - - override fun logout(request: HttpServletRequest, response: HttpServletResponse) { - SecurityContextHolder.getContext().authentication = null - request.cookies?.find { it.name == "JWT" }?.let { - var cookieString = "JWT=${it.value}; HttpOnly; Path=/;Max-Age=0;" - if (supabaseProperties.sslOnly) { - cookieString += "Secure;" - } - response.setHeader("Set-Cookie", cookieString) - response.setHeader("HX-Redirect", "/") - } - } - - override fun setRolesWithRequest(request: HttpServletRequest, userId: String, roles: List?) { - request.cookies?.find { it.name == "JWT" }?.let { - setRoles(it.value, userId, roles) - } - } - - - private fun setRoles(serviceRoleJWT: String, userId: String, roles: List?) { - val roleArray = roles ?: listOf() - runGoTrue() { - goTrueClient.importAuthToken(serviceRoleJWT) - goTrueClient.admin.updateUserById(uid = userId) { - appMetadata = buildJsonObject { - putJsonArray("roles") { - roleArray.map { add(it) } - } - } - } - applicationEventPublisher.publishEvent(SupabaseUserRolesUpdated(userId, roleArray)) - logger.debug("The roles of the user with id {} were updated to {}", userId, roleArray) - } - } - - override fun sendPasswordRecoveryEmail(email: String) { - runGoTrue(email) { - goTrueClient.resetPasswordForEmail(email) - throw PasswordRecoveryEmailSent("User with $email has requested a password recovery email") - } - } - - override fun updatePassword(request: HttpServletRequest, password: String) { - val user = SupabaseSecurityContextHolder.getAuthenticatedUser() - ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository") - val email = user.email ?: "no-email" - runGoTrue(email) { - val jwt = request.cookies?.find { it.name == "JWT" }?.value - ?: throw JWTTokenNullException("No JWT found in request") - goTrueClient.importAuthToken(jwt) - goTrueClient.updateUser { - this.password = password - } - throw SuccessfulPasswordUpdate(user.email) - } - } - - private fun runGoTrue( - email: String = "no-email", - block: suspend CoroutineScope.() -> Unit - ) { - runBlocking { - try { - block() - } catch (exc: AuthRestException) { - handleAuthException(exc, email) - } catch (e: RestException) { - handleGoTrueException(e, email) - } finally { - goTrueClient.clearSession() - } - } - } - - private fun handleAuthException(exc: AuthRestException, email: String) { - when (exc.errorCode) { - AuthErrorCode.UserAlreadyExists -> throw UserAlreadyRegisteredException(email) - AuthErrorCode.SamePassword -> throw NewPasswordShouldBeDifferentFromOldPasswordException(email) - AuthErrorCode.WeakPassword -> throw WeakPasswordException(email) - AuthErrorCode.OtpExpired -> throw OtpExpiredException(email) - AuthErrorCode.NotAdmin -> throw MissingServiceRoleForAdminAccessException(SupabaseSecurityContextHolder.getAuthenticatedUser()?.id) - else -> throw SupabaseAuthException(exc) - } - } - - private fun handleGoTrueException(e: RestException, email: String) { - val message = e.message ?: let { - logger.error(e.message) - throw UnknownSupabaseException() - } - when { - message.contains("Anonymous sign-ins are disabled", true) -> throw AnonymousSignInDisabled() - message.contains("Invalid login credentials", true) -> throw InvalidLoginCredentialsException(email) - message.contains("Email not confirmed", true) -> throw UserNeedsToConfirmEmailBeforeLoginException(email) - message.contains("Signups not allowed for otp", true) -> throw OtpSignupNotAllowedExceptions(message) - } - logger.error(e.message) - throw UnknownSupabaseException() - } -}