diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt index 58f6186..42f69d4 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt @@ -10,7 +10,7 @@ data class TokenInfo( ) interface JWTProvider { - fun generate(user: User): TokenInfo + fun generate(user: User): DomainResult fun verify(jwt: String): DomainResult } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt index 6dc4b48..c816c6c 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt @@ -1,7 +1,9 @@ package za.co.ee.learning.domain.security +import za.co.ee.learning.domain.DomainResult + interface PasswordProvider { - fun encode(password: String): String + fun encode(password: String): DomainResult fun matches( password: String, diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt index 11b8d68..dc59f6b 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt @@ -1,9 +1,10 @@ package za.co.ee.learning.domain.users +import za.co.ee.learning.domain.DomainResult import java.util.UUID data class User( val id: UUID, val email: String, - val passwordHash: String, + val passwordHash: DomainResult, ) diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt index 1b5e559..c6befe8 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt @@ -1,6 +1,5 @@ package za.co.ee.learning.domain.users.usecases -import arrow.core.Option import arrow.core.left import arrow.core.raise.either import arrow.core.right @@ -31,8 +30,9 @@ class Authenticate( operator fun invoke(request: AuthenticateRequest): DomainResult = either { val validatedRequest = validate(request).bind() - val user = findUser(validatedRequest.email).bind() - val authenticatedUser = checkPassword(user, validatedRequest).bind() + val userOption = userRepository.findByEmail(validatedRequest.email).bind() + val user = userOption.toEither { DomainError.InvalidCredentials }.bind() + val authenticatedUser = authenticateUser(user, validatedRequest).bind() createToken(authenticatedUser).bind() } @@ -56,31 +56,23 @@ class Authenticate( return request.right() } - private fun findUser(email: String): DomainResult = - either { - val optUser: Option = userRepository.findByEmail(email).bind() - return optUser.fold( - ifEmpty = { DomainError.InvalidCredentials.left() }, - ifSome = { user -> user.right() }, - ) - } - - private fun checkPassword( + private fun authenticateUser( user: User, validatedRequest: AuthenticateRequest, - ): DomainResult { - if (passwordProvider.matches(validatedRequest.password, user.passwordHash)) { - return user.right() + ): DomainResult = either { + val passwordHashString = user.passwordHash.bind() + if (passwordProvider.matches(validatedRequest.password, passwordHashString)) { + user + } else { + raise(DomainError.InvalidCredentials) } - - return DomainError.InvalidCredentials.left() } - private fun createToken(authenticatedUser: User): DomainResult { - val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser) - return AuthenticateResponse( + private fun createToken(authenticatedUser: User): DomainResult = either { + val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser).bind() + AuthenticateResponse( token = tokenInfo.token, expires = tokenInfo.expires, - ).right() + ) } } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt index 1c063af..a92b25b 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt @@ -1,7 +1,9 @@ package za.co.ee.learning.infrastructure.database +import arrow.core.Either import arrow.core.Option import arrow.core.right +import za.co.ee.learning.domain.DomainError import za.co.ee.learning.domain.DomainResult import za.co.ee.learning.domain.users.User import za.co.ee.learning.domain.users.UserRepository diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt index 6c88a01..9fb0f16 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt @@ -1,10 +1,20 @@ package za.co.ee.learning.infrastructure.security +import arrow.core.raise.either import org.mindrot.jbcrypt.BCrypt +import za.co.ee.learning.domain.DomainError +import za.co.ee.learning.domain.DomainResult import za.co.ee.learning.domain.security.PasswordProvider class BCryptPasswordProvider : PasswordProvider { - override fun encode(password: String): String = BCrypt.hashpw(password, BCrypt.gensalt()) + override fun encode(password: String): DomainResult = + either { + try { + BCrypt.hashpw(password, BCrypt.gensalt()) + } catch (e: Exception) { + raise(DomainError.ValidationError("Error encoding password: ${e.message}")) + } + } override fun matches( password: String, diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt index 9a6f63e..6d2180d 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt @@ -3,6 +3,7 @@ package za.co.ee.learning.infrastructure.security import arrow.core.raise.either import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTCreationException import com.auth0.jwt.exceptions.JWTVerificationException import za.co.ee.learning.domain.DomainError import za.co.ee.learning.domain.DomainResult @@ -10,8 +11,7 @@ import za.co.ee.learning.domain.security.JWTProvider import za.co.ee.learning.domain.security.TokenInfo import za.co.ee.learning.domain.users.User import java.time.Instant -import java.util.Date -import java.util.UUID +import java.util.* class DefaultJWTProvider( private val secret: String, @@ -26,24 +26,30 @@ class DefaultJWTProvider( .withIssuer(issuer) .build() - override fun generate(user: User): TokenInfo { - val now = Instant.now() - val expiresAt = now.plusSeconds(expirationSeconds) + override fun generate(user: User): DomainResult = + either { + try { + val now = Instant.now() + val expiresAt = now.plusSeconds(expirationSeconds) - val token = - JWT - .create() - .withIssuer(issuer) - .withSubject(user.id.toString()) - .withIssuedAt(Date.from(now)) - .withExpiresAt(Date.from(expiresAt)) - .sign(algorithm) + val token = JWT + .create() + .withIssuer(issuer) + .withSubject(user.id.toString()) + .withIssuedAt(Date.from(now)) + .withExpiresAt(Date.from(expiresAt)) + .sign(algorithm) - return TokenInfo( - token = token, - expires = expiresAt.epochSecond, - ) - } + TokenInfo( + token = token, + expires = expiresAt.epochSecond, + ) + } catch (e: JWTCreationException) { + raise(DomainError.JWTError("Token creation error: ${e.message}")) + } catch (e: Exception) { + raise(DomainError.JWTError("Token creation failed: ${e.message}")) + } + } override fun verify(jwt: String): DomainResult = either { diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt index dcf75e3..1df94f5 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.domain.users.usecases +import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.core.some @@ -33,12 +34,16 @@ class AuthenticateTest : User( id = UUID.randomUUID(), email = validEmail, - passwordHash = passwordHash, + passwordHash = Either.Right(passwordHash), ) + val testToken = "jwt.token.here" + val testExpires = 1234567890L val tokenInfo = - TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + Either.Right( + TokenInfo( + token = testToken, + expires = testExpires, + ) ) beforeTest { @@ -57,10 +62,10 @@ class AuthenticateTest : val value = result.shouldBeRight() value shouldBe - AuthenticateResponse( - token = tokenInfo.token, - expires = tokenInfo.expires, - ) + AuthenticateResponse( + token = testToken, + expires = testExpires, + ) verify { userRepository.findByEmail(validEmail) } verify { passwordProvider.matches(validPassword, passwordHash) } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt index 29876c9..35b2531 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.domain.users.usecases +import arrow.core.Either import arrow.core.left import arrow.core.right import io.kotest.assertions.arrow.core.shouldBeLeft @@ -26,13 +27,13 @@ class GetUsersTest : User( id = UUID.randomUUID(), email = "user1@example.com", - passwordHash = "hash1", + passwordHash = Either.Right("hash1"), ) val user2 = User( id = UUID.randomUUID(), email = "user2@example.com", - passwordHash = "hash2", + passwordHash = Either.Right("hash2"), ) beforeTest { diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt index 6973780..59c26a6 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.api +import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.core.some @@ -36,14 +37,17 @@ class AuthenticateEndpointTest : User( id = UUID.randomUUID(), email = validEmail, - passwordHash = passwordHash, + passwordHash = Either.Right(passwordHash), ) + val testToken = "jwt.token.here" + val testExpires = 1234567890L val tokenInfo = - TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + Either.Right( + TokenInfo( + token = testToken, + expires = testExpires, + ) ) - beforeTest { clearAllMocks() } @@ -60,8 +64,8 @@ class AuthenticateEndpointTest : val response = endpoint.handler(request) response.status shouldBe Status.OK - response.bodyString() shouldContain "\"token\":\"${tokenInfo.token}\"" - response.bodyString() shouldContain "\"expires\":${tokenInfo.expires}" + response.bodyString() shouldContain "\"token\":\"$testToken\"" + response.bodyString() shouldContain "\"expires\":$testExpires" verify { userRepository.findByEmail(validEmail) } verify { passwordProvider.matches(validPassword, passwordHash) } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt index 218f5e9..3793934 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.api +import arrow.core.Either import arrow.core.left import arrow.core.right import io.kotest.core.spec.style.FunSpec @@ -27,13 +28,13 @@ class GetUsersEndpointTest : User( id = UUID.randomUUID(), email = "user1@example.com", - passwordHash = "hash1", + passwordHash = Either.Right("hash1"), ) val user2 = User( id = UUID.randomUUID(), email = "user2@example.com", - passwordHash = "hash2", + passwordHash = Either.Right("hash2"), ) beforeTest { @@ -151,7 +152,7 @@ class GetUsersEndpointTest : User( id = UUID.randomUUID(), email = "user$index@example.com", - passwordHash = "hash$index", + passwordHash = Either.Right("hash$index"), ) } every { userRepository.findAll() } returns manyUsers.right() diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt index e14278e..cd4d481 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt @@ -34,7 +34,8 @@ class InMemoryUserRepositoryTest : val option = result.shouldBeRight() val user = option.shouldBeSome() - user.passwordHash.isNotEmpty() shouldBe true + val pwdHash = user.passwordHash.shouldBeRight() + pwdHash.isNotEmpty() shouldBe true } test("should return None for non-existent user") { diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt index 673ceb5..68990bf 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.security +import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -15,8 +16,9 @@ class BCryptPasswordProviderTest : val hash = passwordProvider.encode(password) - hash shouldStartWith "\$2a\$" - hash.length shouldBe 60 + val hashResult = hash.shouldBeRight() + hashResult shouldStartWith "\$2a\$" + hashResult.length shouldBe 60 } test("should generate different hashes for the same password") { @@ -42,15 +44,15 @@ class BCryptPasswordProviderTest : val password = "" val hash = passwordProvider.encode(password) - - hash shouldStartWith "\$2a\$" + val hashResult = hash.shouldBeRight() + hashResult shouldStartWith "\$2a\$" } } context("matches") { test("should return true when password matches hash") { val password = "correctPassword123" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() val result = passwordProvider.matches(password, hash) @@ -60,7 +62,7 @@ class BCryptPasswordProviderTest : test("should return false when password does not match hash") { val correctPassword = "correctPassword123" val wrongPassword = "wrongPassword456" - val hash = passwordProvider.encode(correctPassword) + val hash = passwordProvider.encode(correctPassword).shouldBeRight() val result = passwordProvider.matches(wrongPassword, hash) @@ -69,7 +71,7 @@ class BCryptPasswordProviderTest : test("should return false for empty password when hash is for non-empty password") { val password = "actualPassword" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() val result = passwordProvider.matches("", hash) @@ -78,7 +80,7 @@ class BCryptPasswordProviderTest : test("should be case sensitive") { val password = "Password123" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() val result = passwordProvider.matches("password123", hash) @@ -87,7 +89,7 @@ class BCryptPasswordProviderTest : test("should handle special characters in password") { val password = "P@ssw0rd!#\$%^&*()" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() val result = passwordProvider.matches(password, hash) diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt index 697fe54..d442a33 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.security +import arrow.core.Either import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import io.kotest.assertions.arrow.core.shouldBeLeft @@ -27,26 +28,26 @@ class DefaultJWTProviderTest : User( id = UUID.randomUUID(), email = "test@example.com", - passwordHash = "hash", + passwordHash = Either.Right("hash"), ) context("generate") { test("should generate a valid JWT token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() tokenInfo.token shouldContain "." tokenInfo.expires shouldBeGreaterThan Instant.now().epochSecond } test("should include correct issuer in token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() val decodedJWT = JWT.decode(tokenInfo.token) decodedJWT.issuer shouldBe issuer } test("should include user ID as subject in token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() val decodedJWT = JWT.decode(tokenInfo.token) decodedJWT.subject shouldBe testUser.id.toString() @@ -54,7 +55,7 @@ class DefaultJWTProviderTest : test("should set expiration time correctly") { val beforeGeneration = Instant.now().epochSecond - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() // Expiration should be approximately expirationSeconds from now // Allow for 2 seconds of tolerance @@ -63,7 +64,7 @@ class DefaultJWTProviderTest : test("should include issued at timestamp") { val beforeGeneration = Instant.now().epochSecond - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() val decodedJWT = JWT.decode(tokenInfo.token) val issuedAt = decodedJWT.issuedAt.toInstant().epochSecond @@ -74,7 +75,7 @@ class DefaultJWTProviderTest : context("verify") { test("should successfully verify a valid token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() val result = jwtProvider.verify(tokenInfo.token) @@ -85,7 +86,7 @@ class DefaultJWTProviderTest : test("should return error for expired token") { // Create a JWT provider with very short expiration val shortExpirationProvider = DefaultJWTProvider(secret, issuer, -1) - val tokenInfo = shortExpirationProvider.generate(testUser) + val tokenInfo = shortExpirationProvider.generate(testUser).shouldBeRight() // Wait a bit to ensure token is expired Thread.sleep(1000) @@ -99,7 +100,7 @@ class DefaultJWTProviderTest : test("should return error for token with invalid signature") { val differentSecretProvider = DefaultJWTProvider("different-secret", issuer, expirationSeconds) - val tokenInfo = differentSecretProvider.generate(testUser) + val tokenInfo = differentSecretProvider.generate(testUser).shouldBeRight() val result = jwtProvider.verify(tokenInfo.token) @@ -126,7 +127,7 @@ class DefaultJWTProviderTest : test("should return error for token with wrong issuer") { val wrongIssuerProvider = DefaultJWTProvider(secret, "wrong-issuer", expirationSeconds) - val tokenInfo = wrongIssuerProvider.generate(testUser) + val tokenInfo = wrongIssuerProvider.generate(testUser).shouldBeRight() val result = jwtProvider.verify(tokenInfo.token)