Prefix maven modules

This commit is contained in:
2020-10-23 15:45:28 +02:00
parent 4ff97044f0
commit 4c9ac8944e
135 changed files with 30 additions and 30 deletions
@@ -0,0 +1,37 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.export.ExportUseCaseImpl
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCaseImpl
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCaseImpl
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCaseImpl
import org.koin.dsl.module
val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
single<DeleteUseCase> { DeleteUseCaseImpl(get(), get(), get()) }
single { UserService(get(), get(), get(), get()) }
single<PasswordHash> { BcryptPasswordHash() }
single { SimpleJwt(get()) }
single { JwtPayloadExtractor(get()) }
single {
NoteService(get(), get(), get(), get()).apply {
dropAllIndexes()
indexAll()
}
}
single<MarkdownConverter> { MarkdownConverterImpl() }
single<ExportUseCase> { ExportUseCaseImpl(get(), get()) }
}
@@ -0,0 +1,47 @@
package be.simplenotes.domain.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
import java.util.*
@Serializable
data class NoteMetadata(
val title: String,
val tags: List<String>,
)
@Serializable
data class PersistedNoteMetadata(
val title: String,
val tags: List<String>,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
)
@Serializable
data class Note(
val meta: NoteMetadata,
val markdown: String,
val html: String,
)
@Serializable
data class PersistedNote(
val meta: NoteMetadata,
val markdown: String,
val html: String,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
val public: Boolean,
)
@Serializable
data class ExportedNote(
val title: String,
val tags: List<String>,
val markdown: String,
val html: String,
@Contextual val updatedAt: LocalDateTime,
val trash: Boolean,
)
@@ -0,0 +1,4 @@
package be.simplenotes.domain.model
data class User(val username: String, val password: String)
data class PersistedUser(val username: String, val password: String, val id: Int)
@@ -0,0 +1,20 @@
package be.simplenotes.domain.security
import org.owasp.html.HtmlPolicyBuilder
object HtmlSanitizer {
private val htmlPolicy = HtmlPolicyBuilder()
.allowElements("a")
.allowCommonBlockElements()
.allowCommonInlineFormattingElements()
.allowElements("pre")
.allowAttributes("class").onElements("code")
.allowUrlProtocols("http", "https")
.allowAttributes("href").onElements("a")
.allowElements("input")
.allowAttributes("type", "checked", "disabled", "readonly").onElements("input")
.requireRelNofollowOnLinks()
.toFactory()!!
fun sanitize(unsafeHtml: String) = htmlPolicy.sanitize(unsafeHtml)!!
}
@@ -0,0 +1,21 @@
package be.simplenotes.domain.security
import be.simplenotes.domain.model.PersistedUser
import com.auth0.jwt.exceptions.JWTVerificationException
data class JwtPayload(val userId: Int, val username: String) {
constructor(user: PersistedUser) : this(user.id, user.username)
}
class JwtPayloadExtractor(private val jwt: SimpleJwt) {
operator fun invoke(token: String): JwtPayload? = try {
val decodedJWT = jwt.verifier.verify(token)
val id = decodedJWT.getClaim(userIdField).asInt() ?: null
val username = decodedJWT.getClaim(usernameField).asString() ?: null
id?.let { username?.let { JwtPayload(id, username) } }
} catch (e: JWTVerificationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
@@ -0,0 +1,14 @@
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
internal interface PasswordHash {
fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean
}
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash {
private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
}
@@ -0,0 +1,25 @@
package be.simplenotes.domain.security
import be.simplenotes.shared.config.JwtConfig
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.*
import java.util.concurrent.TimeUnit
internal const val userIdField = "i"
internal const val usernameField = "u"
class SimpleJwt(jwtConfig: JwtConfig) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
private val algorithm = Algorithm.HMAC256(jwtConfig.secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(jwtPayload: JwtPayload): String = JWT.create()
.withClaim(userIdField, jwtPayload.userId)
.withClaim(usernameField, jwtPayload.username)
.withExpiresAt(getExpiration())
.sign(algorithm)
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}
@@ -0,0 +1,97 @@
package be.simplenotes.domain.usecases
import arrow.core.Either
import arrow.core.extensions.fx
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.usecases.search.SearchTerms
import java.util.*
class NoteService(
private val markdownConverter: MarkdownConverter,
private val noteRepository: NoteRepository,
private val userRepository: UserRepository,
private val searcher: NoteSearcher,
) {
fun create(userId: Int, markdownText: String) = Either.fx<MarkdownParsingError, PersistedNote> {
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.create(userId, it) }
searcher.indexNote(userId, persistedNote)
persistedNote
}
fun update(userId: Int, uuid: UUID, markdownText: String) = Either.fx<MarkdownParsingError, PersistedNote?> {
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.update(userId, uuid, it) }
persistedNote?.let { searcher.updateIndex(userId, it) }
persistedNote
}
fun paginatedNotes(
userId: Int,
page: Int,
itemsPerPage: Int = 20,
tag: String? = null,
deleted: Boolean = false,
): PaginatedNotes {
val count = noteRepository.count(userId, tag, deleted)
val offset = (page - 1) * itemsPerPage
val numberOfPages = (count / itemsPerPage) + 1
val notes = if (count == 0) emptyList() else noteRepository.findAll(userId, itemsPerPage, offset, tag, deleted)
return PaginatedNotes(numberOfPages, notes)
}
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun trash(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.delete(userId, uuid, permanent = false)
if (res) searcher.deleteIndex(userId, uuid)
return res
}
fun restore(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.restore(userId, uuid)
if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) }
return res
}
fun delete(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.delete(userId, uuid, permanent = true)
if (res) searcher.deleteIndex(userId, uuid)
return res
}
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
fun indexAll() {
val userIds = userRepository.findAll()
userIds.forEach { id ->
val notes = noteRepository.findAllDetails(id)
searcher.indexNotes(id, notes)
}
}
fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms)
fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid)
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
}
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase,
deleteUseCase: DeleteUseCase,
exportUseCase: ExportUseCase,
) : LoginUseCase by loginUseCase,
RegisterUseCase by registerUseCase,
DeleteUseCase by deleteUseCase,
ExportUseCase by exportUseCase
@@ -0,0 +1,8 @@
package be.simplenotes.domain.usecases.export
import java.io.InputStream
interface ExportUseCase {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@@ -0,0 +1,50 @@
package be.simplenotes.domain.usecases.export
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.usecases.repositories.NoteRepository
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, private val json: Json) : ExportUseCase {
override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
}
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
override fun exportAsZip(userId: Int): InputStream {
val notes = noteRepository.export(userId)
val zipOutput = ZipOutput()
zipOutput.use { zip ->
notes.forEach {
val name = sanitizeFilename(it.title)
zip.write("notes/$name.md", it.markdown)
}
}
return ByteArrayInputStream(zipOutput.outputStream.toByteArray())
}
}
class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream)
fun write(path: String, content: String) {
val entry = ZipArchiveEntry(path)
zipOutputStream.putArchiveEntry(entry)
zipOutputStream.write(content.toByteArray())
zipOutputStream.closeArchiveEntry()
}
override fun close() {
zipOutputStream.finish()
zipOutputStream.close()
}
}
@@ -0,0 +1,83 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.validation.NoteValidations
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import io.konform.validation.ValidationErrors
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
typealias MetaMdPair = Pair<String, String>
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
internal class MarkdownConverterImpl : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return InvalidMeta.left()
} catch (e: ScannerException) {
return InvalidMeta.left()
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left()
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
}
private val parser: Parser
private val renderer: HtmlRenderer
init {
val options = MutableDataSet()
options.set(Parser.EXTENSIONS, listOf(TaskListExtension.create()))
options.set(HtmlRenderer.SOFT_BREAK, "<br>")
parser = Parser.builder(options).build()
renderer = HtmlRenderer.builder(options).build()
}
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = Either.fx<MarkdownParsingError, Document> {
val (meta, md) = !splitMetaFromDocument(input)
val parsedMeta = !parseMeta(meta)
!NoteValidations.validateMetadata(parsedMeta).toEither { }.swap()
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
@@ -0,0 +1,35 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import java.util.*
interface NoteRepository {
fun findAll(
userId: Int,
limit: Int = 20,
offset: Int = 0,
tag: String? = null,
deleted: Boolean = false
): List<PersistedNoteMetadata>
fun count(userId: Int, tag: String? = null, deleted: Boolean = false): Int
fun delete(userId: Int, uuid: UUID, permanent: Boolean = false): Boolean
fun restore(userId: Int, uuid: UUID): Boolean
// These methods only access notes where `Notes.deleted = false`
fun getTags(userId: Int): List<String>
fun exists(userId: Int, uuid: UUID): Boolean
fun create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote>
fun makePublic(userId: Int, uuid: UUID): Boolean
fun makePrivate(userId: Int, uuid: UUID): Boolean
fun findPublic(uuid: UUID): PersistedNote?
}
@@ -0,0 +1,14 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
interface UserRepository {
fun create(user: User): PersistedUser?
fun find(username: String): PersistedUser?
fun find(id: Int): PersistedUser?
fun exists(username: String): Boolean
fun exists(id: Int): Boolean
fun delete(id: Int): Boolean
fun findAll(): List<Int>
}
@@ -0,0 +1,17 @@
package be.simplenotes.domain.usecases.search
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import java.util.*
data class SearchTerms(val title: String?, val tag: String?, val content: String?, val all: String?)
interface NoteSearcher {
fun indexNote(userId: Int, note: PersistedNote)
fun indexNotes(userId: Int, notes: List<PersistedNote>)
fun deleteIndex(userId: Int, uuid: UUID)
fun updateIndex(userId: Int, note: PersistedNote)
fun search(userId: Int, terms: SearchTerms): List<PersistedNoteMetadata>
fun dropIndex(userId: Int)
fun dropAll()
}
@@ -0,0 +1,27 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.validation.UserValidations
internal class DeleteUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val searcher: NoteSearcher,
) : DeleteUseCase {
override fun delete(form: DeleteForm) = Either.fx<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
!Either.cond(
passwordHash.verify(user.password, persistedUser.password),
{ Unit },
{ DeleteError.WrongPassword }
)
!Either.cond(userRepository.delete(persistedUser.id), { Unit }, { DeleteError.Unregistered })
searcher.dropIndex(persistedUser.id)
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
interface DeleteUseCase {
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@@ -0,0 +1,25 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt
) : LoginUseCase {
override fun login(form: LoginForm) = Either.fx<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(JwtPayload(it)) }
}
}
@@ -0,0 +1,19 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}
@@ -0,0 +1,22 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> {
return UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.usecases.users.login.LoginForm
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}
@@ -0,0 +1,36 @@
package be.simplenotes.domain.validation
import arrow.core.*
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.usecases.markdown.ValidationError
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.uniqueItems
internal object NoteValidations {
private val metaValidator = Validation<NoteMetadata> {
NoteMetadata::title required {
addConstraint("must not be blank") { it.isNotBlank() }
maxLength(50)
}
NoteMetadata::tags required {
maxItems(5)
uniqueItems(true)
}
NoteMetadata::tags onEach {
maxLength(15)
addConstraint("must not be blank") { it.isNotBlank() }
addConstraint("must only contain alphanumeric characters, `-` and `_`") {
it.matches("^[a-zA-Z0-9-_]+\$".toRegex())
}
}
}
fun validateMetadata(meta: NoteMetadata): Option<ValidationError> {
val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) none()
else return ValidationError(errors).some()
}
}
@@ -0,0 +1,60 @@
package be.simplenotes.domain.validation
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
internal object UserValidations {
private val loginValidator = Validation<LoginForm> {
LoginForm::username required {
minLength(3)
maxLength(50)
}
LoginForm::password required {
minLength(8)
maxLength(72) // jbcrypt limit, see https://security.stackexchange.com/a/39851
}
}
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidLoginForm(errors).left()
}
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidRegisterForm(errors).left()
}
private val deleteValidator = Validation<DeleteForm> {
DeleteForm::username required {
minLength(3)
maxLength(50)
}
DeleteForm::password required {
minLength(8)
maxLength(72)
}
DeleteForm::checked required {
addConstraint("Should be checked") { it }
}
}
fun validateDelete(form: DeleteForm): Either<DeleteError.InvalidForm, User> {
val errors = deleteValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return DeleteError.InvalidForm(errors).left()
}
}
@@ -0,0 +1,5 @@
package be.simplenotes.domain
/**
* Empty file @see [root-package-declaration](https://discuss.kotlinlang.org/t/root-package-declaration-to-reduce-folder-clutter/2247/4)
*/
@@ -0,0 +1,50 @@
package be.simplenotes.domain.security
import be.simplenotes.domain.usecases.users.login.Token
import be.simplenotes.shared.config.JwtConfig
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.natpryce.hamkrest.absent
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.concurrent.TimeUnit
import java.util.stream.Stream
internal class JwtPayloadExtractorTest {
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val jwtPayloadExtractor = JwtPayloadExtractor(simpleJwt)
private fun createToken(username: String? = null, id: Int? = null, secret: String = jwtConfig.secret): Token {
val algo = Algorithm.HMAC256(secret)
return JWT.create().apply {
username?.let { withClaim(usernameField, it) }
id?.let { withClaim(userIdField, it) }
}.sign(algo)
}
@Suppress("Unused")
private fun invalidTokens() = Stream.of(
createToken(id = 1),
createToken(username = "user"),
createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"),
createToken(username = "user", id = 1) + "\"efesfsef",
"something that is not even a token"
)
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid")
@MethodSource("invalidTokens")
fun `parse invalid tokens`(token: String) {
assertThat(jwtPayloadExtractor(token), absent())
}
@Test
fun `parse valid token`() {
val token = createToken(username = "someone", id = 1)
assertThat(jwtPayloadExtractor(token), equalTo(JwtPayload(1, "someone")))
}
}
@@ -0,0 +1,64 @@
package be.simplenotes.domain.usecases.users.login
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class LoginUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(form.username!!) } returns null
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { mockUserRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = loginUseCase.login(loginForm)
assertThat(res, isRight())
}
}
@@ -0,0 +1,50 @@
package be.simplenotes.domain.usecases.users.register
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class RegisterUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns true
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns false
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = registerUseCase.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
}
@@ -0,0 +1,77 @@
package be.simplenotes.domain.validation
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
internal class UserValidationsTest {
@Nested
inner class Login {
@Suppress("Unused")
fun invalidLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = null, password = null),
LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"),
LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
LoginForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidLoginForms")
fun `validate invalid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
LoginForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validLoginForms")
fun `validate valid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
@Nested
inner class Register {
@Suppress("Unused")
fun invalidRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = null, password = null),
RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"),
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
RegisterForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidRegisterForms")
fun `validate invalid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
RegisterForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validRegisterForms")
fun `validate valid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
}