Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ data class TokenInfo(
)

interface JWTProvider {
fun generate(user: User): TokenInfo
fun generate(user: User): DomainResult<TokenInfo>

fun verify(jwt: String): DomainResult<UUID>
}
Original file line number Diff line number Diff line change
@@ -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<String>

fun matches(
password: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,8 +30,9 @@ class Authenticate(
operator fun invoke(request: AuthenticateRequest): DomainResult<AuthenticateResponse> =
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()
}

Expand All @@ -56,31 +56,23 @@ class Authenticate(
return request.right()
}

private fun findUser(email: String): DomainResult<User> =
either {
val optUser: Option<User> = 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<User> {
if (passwordProvider.matches(validatedRequest.password, user.passwordHash)) {
return user.right()
): DomainResult<User> = 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<AuthenticateResponse> {
val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser)
return AuthenticateResponse(
private fun createToken(authenticatedUser: User): DomainResult<AuthenticateResponse> = either {
val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser).bind()
AuthenticateResponse(
token = tokenInfo.token,
expires = tokenInfo.expires,
).right()
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
either {
try {
BCrypt.hashpw(password, BCrypt.gensalt())
} catch (e: Exception) {
raise(DomainError.ValidationError("Error encoding password: ${e.message}"))
}
}

override fun matches(
password: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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
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,
Expand All @@ -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<TokenInfo> =
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<UUID> =
either {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading