WIP: Migrate to Ktor

This commit is contained in:
William Brawner 2021-07-29 10:40:22 -06:00
parent b882d8b089
commit ed1dde49bf
40 changed files with 432 additions and 798 deletions

21
api/build.gradle.kts Normal file
View file

@ -0,0 +1,21 @@
plugins {
kotlin("jvm")
`java-library`
}
val ktorVersion: String by rootProject.extra
dependencies {
implementation(kotlin("stdlib"))
api(project(":core"))
implementation(project(":storage"))
api("io.ktor:ktor-server-core:$ktorVersion")
api("io.ktor:ktor-auth:$ktorVersion")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -0,0 +1,32 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.UserPermission
import java.util.*
data class BudgetRequest(
val name: String? = null,
val description: String? = null,
val users: Set<UserPermissionRequest>? = null
)
data class BudgetResponse(
val id: String,
val name: String?,
val description: String?,
private val users: List<UserPermissionResponse>
) {
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
Objects.requireNonNull<String>(budget.id),
budget.name,
budget.description,
users.map { userPermission: UserPermission ->
UserPermissionResponse(
userPermission.userId,
userPermission.permission
)
}
)
}
data class BudgetBalanceResponse(val id: String, val balance: Long)

View file

@ -0,0 +1,124 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.util.pipeline.*
fun Application.budgetRoutes(
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository
) {
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
budgetId: String,
permission: Permission,
block: suspend (Budget) -> Unit
) {
val session = call.principal<Session>()!!
val userPermission =
permissionRepository.findAllByUserId(session.userId).firstOrNull { it.budgetId == budgetId }
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
call.respond(HttpStatusCode.Forbidden)
return
}
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
}
routing {
authenticate(optional = false) {
get("/") {
val session = call.principal<Session>()!!
val budgetIds = permissionRepository.findAllByUserId(session.userId).map { it.budgetId }
val budgets = budgetRepository.findAllByIds(budgetIds).map {
BudgetResponse(it, permissionRepository.findAllByBudgetId(it.id))
}
if (call.request.contentType() == ContentType.Application.Json) {
} else {
call.respondHtml()
}
call.respond(budgets)
}
get("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
val users = permissionRepository.findAllByBudgetId(budget.id)
call.respond(BudgetResponse(budget, users))
}
}
post("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<BudgetRequest>()
if (request.name.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, "Name cannot be empty or null")
return@post
}
val budget = budgetRepository.save(
Budget(
name = request.name,
description = request.description
)
)
val users = request.users?.map {
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = it.user,
permission = it.permission
)
)
}?.toMutableSet() ?: mutableSetOf()
if (users.none { it.userId == session.userId }) {
users.add(
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = session.userId,
permission = Permission.OWNER
)
)
)
}
call.respond(BudgetResponse(budget, users))
}
put("/{id}") {
budgetWithPermission(call.parameters["id"]!!, Permission.MANAGE) { budget ->
val request = call.receive<BudgetRequest>()
val name = request.name ?: budget.name
val description = request.description ?: budget.description
val users = request.users?.map {
permissionRepository.save(UserPermission(budget.id, it.user, it.permission))
} ?: permissionRepository.findAllByBudgetId(budget.id)
permissionRepository.findAllByBudgetId(budget.id).forEach {
if (it.permission != Permission.OWNER && users.none { userPermission -> userPermission.userId == it.userId }) {
permissionRepository.delete(it)
}
}
call.respond(
BudgetResponse(
budgetRepository.save(budget.copy(name = name, description = description)),
users
)
)
}
}
delete("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
budgetRepository.delete(budget)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}

View file

@ -1,23 +1,6 @@
package com.wbrawner.twigs.server.category package com.wbrawner.twigs
import com.wbrawner.twigs.server.budget.Budget import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.server.randomString
import javax.persistence.*
@Entity
data class Category(
@Id
val id: String = randomString(),
var title: String= "",
var description: String? = null,
var amount: Long = 0L,
@field:ManyToOne
@field:JoinColumn(nullable = false)
var budget: Budget? = null,
var expense: Boolean = true,
@field:Column(nullable = false, columnDefinition = "boolean default false")
var archived: Boolean = false
)
data class NewCategoryRequest( data class NewCategoryRequest(
val title: String, val title: String,

View file

@ -1,3 +1,3 @@
package com.wbrawner.twigs.server package com.wbrawner.twigs
data class ErrorResponse(val message: String) data class ErrorResponse(val message: String)

View file

@ -0,0 +1,12 @@
package com.wbrawner.twigs
import io.ktor.auth.*
import java.util.*
data class Session(
val userId: String = "",
val id: String = randomString(),
val token: String = randomString(255),
var expiration: Date = twoWeeksFromNow
) : Principal

View file

@ -1,28 +1,6 @@
package com.wbrawner.twigs.server.transaction package com.wbrawner.twigs
import com.wbrawner.twigs.server.budget.Budget import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.server.category.Category
import com.wbrawner.twigs.server.randomString
import com.wbrawner.twigs.server.user.User
import java.time.Instant
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
data class Transaction(
@Id
val id: String = randomString(),
var title: String? = null,
var description: String? = null,
var date: Instant? = null,
var amount: Long? = null,
@field:ManyToOne var category: Category? = null,
var expense: Boolean? = null,
@field:JoinColumn(nullable = false) @field:ManyToOne val createdBy: User? = null,
@field:JoinColumn(nullable = false) @field:ManyToOne var budget: Budget? = null
)
data class NewTransactionRequest( data class NewTransactionRequest(
val title: String, val title: String,

View file

@ -0,0 +1,41 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.User
import java.util.*
data class NewUserRequest(
val username: String,
val password: String,
val email: String? = null
)
data class UpdateUserRequest(
val username: String? = null,
val password: String? = null,
val email: String? = null
)
data class LoginRequest(val username: String? = null, val password: String? = null)
data class UserResponse(val id: String, val username: String, val email: String?) {
constructor(user: User) : this(user.id, user.name, user.email)
}
data class UserPermissionRequest(
val user: String,
val permission: Permission = Permission.READ
)
data class UserPermissionResponse(val user: String, val permission: Permission?)
data class SessionResponse(val token: String, val expiration: String) {
constructor(session: Session) : this(session.token, session.expiration.toInstant().toString())
}
data class PasswordResetRequest(
val userId: Long,
val id: String = randomString(),
private val date: Calendar = GregorianCalendar(),
private val token: String = randomString()
)

View file

@ -21,6 +21,9 @@ val kotlinVersion: String by rootProject.extra
val ktorVersion: String by rootProject.extra val ktorVersion: String by rootProject.extra
dependencies { dependencies {
implementation(project(":api"))
implementation(project(":core"))
implementation(project(":storage"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-core:$ktorVersion")

View file

@ -1,15 +1,13 @@
package com.wbrawner.twigs.server package com.wbrawner.twigs.server
import com.wbrawner.twigs.budgetRoutes
import io.ktor.application.* import io.ktor.application.*
import io.ktor.response.* import io.ktor.auth.*
import io.ktor.routing.*
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args) fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
fun Application.module(testing: Boolean = false) { fun Application.module(budgetReposi) {
routing {
get("/") { install(Authentication)
call.respondText("Hello, world!") budgetRoutes()
}
}
} }

View file

@ -1,16 +0,0 @@
package com.wbrawner.twigs.server
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
open class TwigsServerApplication {
companion object {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.run(TwigsServerApplication::class.java, *args)
}
}
}

View file

@ -1,74 +0,0 @@
package com.wbrawner.twigs.server
import com.wbrawner.twigs.server.budget.Budget
import com.wbrawner.twigs.server.permission.Permission
import com.wbrawner.twigs.server.permission.UserPermissionRepository
import com.wbrawner.twigs.server.transaction.TransactionRepository
import com.wbrawner.twigs.server.user.User
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import java.util.*
private val CALENDAR_FIELDS = intArrayOf(
Calendar.MILLISECOND,
Calendar.SECOND,
Calendar.MINUTE,
Calendar.HOUR_OF_DAY,
Calendar.DATE
)
val firstOfMonth: Date
get() = GregorianCalendar().run {
for (calField in CALENDAR_FIELDS) {
set(calField, getActualMinimum(calField))
}
time
}
val endOfMonth: Date
get() = GregorianCalendar().run {
for (calField in CALENDAR_FIELDS) {
set(calField, getActualMaximum(calField))
}
time
}
val twoWeeksFromNow: Date
get() = GregorianCalendar().run {
add(Calendar.DATE, 14)
time
}
val currentUser: User?
get() = SecurityContextHolder.getContext().authentication.principal as? User
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
fun randomString(length: Int = 32): String {
val id = StringBuilder()
for (i in 0 until length) {
id.append(CHARACTERS.random())
}
return id.toString()
}
fun <T> getBudgetWithPermission(
transactionRepository: TransactionRepository,
userPermissionsRepository: UserPermissionRepository,
transactionId: String,
permission: Permission,
action: (Budget) -> ResponseEntity<T>
): ResponseEntity<T> {
val transaction = transactionRepository.findById(transactionId).orElse(null)
?: return ResponseEntity.notFound().build()
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
transaction.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(permission)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
return action(userPermission.budget!!)
}

View file

@ -1,49 +0,0 @@
package com.wbrawner.twigs.server.budget
import com.wbrawner.twigs.server.category.Category
import com.wbrawner.twigs.server.permission.UserPermission
import com.wbrawner.twigs.server.permission.UserPermissionRequest
import com.wbrawner.twigs.server.permission.UserPermissionResponse
import com.wbrawner.twigs.server.randomString
import com.wbrawner.twigs.server.transaction.Transaction
import java.util.*
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.OneToMany
@Entity
data class Budget(
@Id
var id: String = randomString(),
var name: String? = null,
var description: String? = null,
var currencyCode: String? = "USD",
@OneToMany(mappedBy = "budget")
val transactions: Set<Transaction> = TreeSet(),
@OneToMany(mappedBy = "budget")
val categories: Set<Category> = TreeSet(),
@OneToMany(mappedBy = "budget")
val users: Set<Transaction> = HashSet()
)
data class BudgetRequest(
val name: String = "",
val description: String = "",
val users: Set<UserPermissionRequest> = emptySet()
)
data class BudgetResponse(
val id: String,
val name: String?,
val description: String?,
private val users: List<UserPermissionResponse>
) {
constructor(budget: Budget, users: List<UserPermission>) : this(
Objects.requireNonNull<String>(budget.id),
budget.name,
budget.description,
users.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) }
)
}
data class BudgetBalanceResponse(val id: String, val balance: Long)

View file

@ -1,165 +0,0 @@
package com.wbrawner.twigs.server.budget
import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.permission.Permission
import com.wbrawner.twigs.server.permission.UserPermission
import com.wbrawner.twigs.server.permission.UserPermissionRepository
import com.wbrawner.twigs.server.permission.UserPermissionRequest
import com.wbrawner.twigs.server.transaction.TransactionRepository
import com.wbrawner.twigs.server.user.User
import com.wbrawner.twigs.server.user.UserRepository
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.time.Instant
import java.util.function.Consumer
import java.util.function.Function
import javax.transaction.Transactional
@RestController
@RequestMapping(value = ["/budgets"])
@Transactional
open class BudgetController(
private val budgetRepository: BudgetRepository,
private val transactionRepository: TransactionRepository,
private val userRepository: UserRepository,
private val userPermissionsRepository: UserPermissionRepository
) {
private val logger = LoggerFactory.getLogger(BudgetController::class.java)
@GetMapping(value = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getBudgets(page: Int?, count: Int?): ResponseEntity<List<BudgetResponse>> {
val user = currentUser ?: return ResponseEntity.status(401).build()
val budgets: List<BudgetResponse> = userPermissionsRepository.findAllByUser(
user,
PageRequest.of(
page ?: 0,
count ?: 1000
)
).mapNotNull { userPermission: UserPermission ->
val budget = userPermission.budget ?: return@mapNotNull null
BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null))
}
return ResponseEntity.ok(budgets)
}
@GetMapping(value = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getBudget(@PathVariable id: String): ResponseEntity<BudgetResponse> {
return getBudgetWithPermission(id, Permission.READ) { budget: Budget ->
ResponseEntity.ok(BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)))
}
}
@GetMapping(value = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getBudgetBalance(
@PathVariable id: String,
@RequestParam(value = "from", required = false) from: String? = null,
@RequestParam(value = "to", required = false) to: String? = null
): ResponseEntity<BudgetBalanceResponse> {
return getBudgetWithPermission(id, Permission.READ) { budget: Budget ->
val fromInstant: Instant = try {
Instant.parse(from)
} catch (e: Exception) {
if (e !is NullPointerException) logger.error(
"Failed to parse '$from' to Instant for 'from' parameter",
e
)
Instant.ofEpochSecond(0)
}
val toInstant: Instant = try {
Instant.parse(to)
} catch (e: Exception) {
if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e)
Instant.now()
}
val balance = transactionRepository.sumBalanceByBudgetId(budget.id, fromInstant, toInstant)
ResponseEntity.ok(BudgetBalanceResponse(budget.id, balance))
}
}
@PostMapping(
value = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun newBudget(@RequestBody request: BudgetRequest): ResponseEntity<BudgetResponse> {
val budget = budgetRepository.save(Budget(request.name, request.description))
val users: MutableSet<UserPermission> = request.users
.mapNotNull { userPermissionRequest: UserPermissionRequest ->
val user = userRepository.findById(userPermissionRequest.user!!).orElse(null) ?: return@mapNotNull null
userPermissionsRepository.save(
UserPermission(budget, user, userPermissionRequest.permission)
)
}
.toMutableSet()
val currentUserIncluded = users.any { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id }
if (!currentUserIncluded) {
users.add(
userPermissionsRepository.save(
UserPermission(budget, currentUser!!, Permission.OWNER)
)
)
}
return ResponseEntity.ok(BudgetResponse(budget, ArrayList(users)))
}
@PutMapping(
value = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun updateBudget(
@PathVariable id: String,
@RequestBody request: BudgetRequest
): ResponseEntity<BudgetResponse> {
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
budget.name = request.name
budget.description = request.description
val users = ArrayList<UserPermission>()
if (request.users.isNotEmpty()) {
request.users.forEach(Consumer { userPermissionRequest: UserPermissionRequest ->
userRepository.findById(userPermissionRequest.user!!).ifPresent { requestedUser: User ->
users.add(
userPermissionsRepository.save(
UserPermission(
budget,
requestedUser,
userPermissionRequest.permission
)
)
)
}
})
} else {
users.addAll(userPermissionsRepository.findAllByBudget(budget, null))
}
ResponseEntity.ok(BudgetResponse(budgetRepository.save(budget), users))
}
}
@DeleteMapping(value = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
open fun deleteBudget(@PathVariable id: String): ResponseEntity<Void?> {
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
budgetRepository.delete(budget)
ResponseEntity.ok().build()
}
}
private fun <T> getBudgetWithPermission(
budgetId: String,
permission: Permission,
callback: Function<Budget, ResponseEntity<T>>
): ResponseEntity<T> {
val user = currentUser ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(permission)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val budget = userPermission.budget ?: return ResponseEntity.notFound().build()
return callback.apply(budget)
}
}

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs.server.budget
import org.springframework.data.repository.PagingAndSortingRepository
interface BudgetRepository : PagingAndSortingRepository<Budget, String>

View file

@ -1,6 +1,6 @@
package com.wbrawner.twigs.server.category package com.wbrawner.twigs.server.category
import com.wbrawner.twigs.server.ErrorResponse import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.currentUser import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.firstOfMonth import com.wbrawner.twigs.server.firstOfMonth
import com.wbrawner.twigs.server.permission.Permission import com.wbrawner.twigs.server.permission.Permission

View file

@ -1,25 +0,0 @@
package com.wbrawner.twigs.server.config
import com.wbrawner.twigs.server.user.UserRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Component
@Component
open class JdbcUserDetailsService @Autowired constructor(private val userRepository: UserRepository) : UserDetailsService {
@Throws(UsernameNotFoundException::class)
override fun loadUserByUsername(username: String): UserDetails {
var userDetails: UserDetails?
userDetails = userRepository.findByName(username).orElse(null)
if (userDetails != null) {
return userDetails
}
userDetails = userRepository.findByEmail(username).orElse(null)
if (userDetails != null) {
return userDetails
}
throw UsernameNotFoundException("Unable to find user with username \$username")
}
}

View file

@ -1,9 +0,0 @@
package com.wbrawner.twigs.server.config
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
open class MethodSecurity : GlobalMethodSecurityConfiguration()

View file

@ -1,92 +0,0 @@
package com.wbrawner.twigs.server.config
import com.wbrawner.twigs.server.passwordresetrequest.PasswordResetRequestRepository
import com.wbrawner.twigs.server.session.UserSessionRepository
import com.wbrawner.twigs.server.user.UserRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.env.Environment
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.JdbcUserDetailsManager
import org.springframework.web.cors.CorsConfiguration
import java.util.*
import javax.sql.DataSource
@Configuration
@EnableWebSecurity
open class SecurityConfig(
private val env: Environment,
private val datasource: DataSource,
private val userSessionRepository: UserSessionRepository,
private val userRepository: UserRepository,
private val passwordResetRequestRepository: PasswordResetRequestRepository,
private val userDetailsService: JdbcUserDetailsService,
private val environment: Environment
) : WebSecurityConfigurerAdapter() {
@get:Bean
open val userDetailsManager: JdbcUserDetailsManager
get() {
val userDetailsManager = JdbcUserDetailsManager()
userDetailsManager.dataSource = datasource
return userDetailsManager
}
@get:Bean
open val authenticationProvider: DaoAuthenticationProvider
get() {
val authProvider = TokenAuthenticationProvider(userSessionRepository, userRepository)
authProvider.setPasswordEncoder(passwordEncoder)
authProvider.setUserDetailsService(userDetailsService)
return authProvider
}
@get:Bean
open val passwordEncoder: PasswordEncoder
get() = BCryptPasswordEncoder()
public override fun configure(auth: AuthenticationManagerBuilder) {
auth.authenticationProvider(authenticationProvider)
}
@Throws(Exception::class)
public override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.antMatchers("/users/new", "/users/login")
.permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic()
.authenticationEntryPoint(SilentAuthenticationEntryPoint())
.and()
.cors()
.configurationSource {
val corsConfig = CorsConfiguration()
corsConfig.applyPermitDefaultValues()
val corsDomains = environment.getProperty("twigs.cors.domains", "*")
corsConfig.allowedOrigins = Arrays.asList(*corsDomains.split(",").toTypedArray())
corsConfig.allowedMethods = listOf(
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.DELETE,
HttpMethod.OPTIONS
).map { obj: HttpMethod -> obj.name }
corsConfig.allowCredentials = true
corsConfig
}
.and()
.csrf()
.disable()
.addFilter(TokenAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
}

View file

@ -1,18 +0,0 @@
package com.wbrawner.twigs.server.config
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
/**
* Creates a token with the supplied array of authorities.
*
* @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
* represented by this authentication object.
* @param credentials
* @param principal
*/
class SessionAuthenticationToken(
principal: Any?,
credentials: Any?,
authorities: Collection<GrantedAuthority>
) : UsernamePasswordAuthenticationToken(principal, credentials, authorities)

View file

@ -1,22 +0,0 @@
package com.wbrawner.twigs.server.config
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import java.io.IOException
import javax.servlet.ServletException
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
/**
* Used to avoid browser prompts for authentication
*/
class SilentAuthenticationEntryPoint : AuthenticationEntryPoint {
@Throws(IOException::class, ServletException::class)
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message)
}
}

View file

@ -1,26 +0,0 @@
package com.wbrawner.twigs.server.config
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import java.io.IOException
import javax.servlet.FilterChain
import javax.servlet.ServletException
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class TokenAuthenticationFilter(authenticationManager: AuthenticationManager?) :
BasicAuthenticationFilter(authenticationManager) {
@Throws(IOException::class, ServletException::class)
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response)
return
}
val token = authHeader.substring(7)
val authentication = authenticationManager.authenticate(SessionAuthenticationToken(null, token, emptyList()))
SecurityContextHolder.getContext().authentication = authentication
chain.doFilter(request, response)
}
}

View file

@ -1,53 +0,0 @@
package com.wbrawner.twigs.server.config
import com.wbrawner.twigs.server.session.UserSessionRepository
import com.wbrawner.twigs.server.twoWeeksFromNow
import com.wbrawner.twigs.server.user.UserRepository
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.InternalAuthenticationServiceException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.core.Authentication
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.userdetails.UserDetails
import java.util.*
class TokenAuthenticationProvider(
private val userSessionRepository: UserSessionRepository,
private val userRepository: UserRepository
) : DaoAuthenticationProvider() {
@Throws(AuthenticationException::class)
override fun additionalAuthenticationChecks(
userDetails: UserDetails,
authentication: UsernamePasswordAuthenticationToken
) {
if (authentication !is SessionAuthenticationToken) {
// Additional checks aren't needed since they've already been handled
super.additionalAuthenticationChecks(userDetails, authentication)
}
}
@Throws(AuthenticationException::class)
override fun authenticate(authentication: Authentication): Authentication {
return if (authentication is SessionAuthenticationToken) {
val session = userSessionRepository.findByToken(authentication.getCredentials() as String)
if (session!!.isEmpty || session.get().expiration.before(Date())) {
throw BadCredentialsException("Credentials expired")
}
val user = userRepository.findById(session.get().userId)
if (user.isEmpty) {
throw InternalAuthenticationServiceException("Failed to find user for token")
}
Thread {
// Update the session on a background thread to avoid holding up the request longer than necessary
val updatedSession = session.get()
updatedSession.expiration = twoWeeksFromNow
userSessionRepository.save(updatedSession)
}.start()
SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities())
} else {
super.authenticate(authentication)
}
}
}

View file

@ -1,17 +0,0 @@
package com.wbrawner.twigs.server.passwordresetrequest
import com.wbrawner.twigs.server.randomString
import com.wbrawner.twigs.server.user.User
import java.util.*
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.ManyToOne
@Entity
data class PasswordResetRequest(
@Id
val id: String = randomString(),
@field:ManyToOne private val user: User? = null,
private val date: Calendar = GregorianCalendar(),
private val token: String = randomString()
)

View file

@ -1,87 +0,0 @@
package com.wbrawner.twigs.server.permission
import com.wbrawner.twigs.server.budget.Budget
import com.wbrawner.twigs.server.user.User
import com.wbrawner.twigs.server.user.UserResponse
import java.io.Serializable
import javax.persistence.*
enum class Permission {
/**
* The user can read the content but cannot make any modifications.
*/
READ,
/**
* The user can read and write the content but cannot make any modifications to the container of the content.
*/
WRITE,
/**
* The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions).
*/
MANAGE,
/**
* The user has complete control over the resource. There can only be a single owner user at any given time.
*/
OWNER;
fun isNotAtLeast(wanted: Permission): Boolean {
return ordinal < wanted.ordinal
}
}
@Entity
data class UserPermission(
@field:EmbeddedId
val id: UserPermissionKey? = null,
@field:JoinColumn(
nullable = false,
name = "budget_id"
)
@field:MapsId(
"budgetId"
)
@field:ManyToOne
val budget: Budget? = null,
@field:JoinColumn(
nullable = false,
name = "user_id"
)
@field:MapsId("userId")
@field:ManyToOne
val user: User? = null,
@field:Enumerated(
EnumType.STRING
)
@field:JoinColumn(
nullable = false
)
val permission: Permission = Permission.READ
) {
constructor(budget: Budget, user: User, permission: Permission) : this(
UserPermissionKey(budget.id, user.id),
budget,
user,
permission
)
}
@Embeddable
data class UserPermissionKey(
private val budgetId: String? = null,
private val userId: String? = null
) : Serializable
data class UserPermissionRequest(
val user: String? = null,
val permission: Permission = Permission.READ
)
data class UserPermissionResponse(val user: UserResponse, val permission: Permission?) {
constructor(userPermission: UserPermission) : this(
UserResponse(userPermission.user!!),
userPermission.permission
)
}

View file

@ -1,18 +0,0 @@
package com.wbrawner.twigs.server.session
import com.wbrawner.twigs.server.randomString
import com.wbrawner.twigs.server.twoWeeksFromNow
import javax.persistence.Entity
import javax.persistence.Id
@Entity
data class Session(val userId: String = "") {
@Id
val id = randomString()
val token = randomString(255)
var expiration = twoWeeksFromNow
}
data class SessionResponse(val token: String, val expiration: String) {
constructor(session: Session) : this(session.token, session.expiration.toInstant().toString())
}

View file

@ -1,6 +1,6 @@
package com.wbrawner.twigs.server.transaction package com.wbrawner.twigs.server.transaction
import com.wbrawner.twigs.server.ErrorResponse import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.category.Category import com.wbrawner.twigs.server.category.Category
import com.wbrawner.twigs.server.category.CategoryRepository import com.wbrawner.twigs.server.category.CategoryRepository
import com.wbrawner.twigs.server.currentUser import com.wbrawner.twigs.server.currentUser

View file

@ -1,66 +0,0 @@
package com.wbrawner.twigs.server.user
import com.wbrawner.twigs.server.randomString
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Transient
@Entity
data class User(
@Id
val id: String = randomString(),
@field:Column(name = "username")
var name: String = "",
@field:Column(name = "password")
var passphrase: String = "",
@Transient
private val _authorities: Collection<GrantedAuthority> = listOf(SimpleGrantedAuthority("USER")),
var email: String? = null
) : UserDetails {
override fun getUsername(): String = name
override fun getPassword(): String = passphrase
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
override fun getAuthorities(): Collection<GrantedAuthority> {
return _authorities
}
}
data class NewUserRequest(
val username: String,
val password: String,
val email: String? = null
)
data class UpdateUserRequest(
val username: String? = null,
val password: String? = null,
val email: String? = null
)
data class LoginRequest(val username: String? = null, val password: String? = null)
data class UserResponse(val id: String, val username: String, val email: String?) {
constructor(user: User) : this(user.id, user.username, user.email)
}

View file

@ -1,6 +1,6 @@
package com.wbrawner.twigs.server.user package com.wbrawner.twigs.server.user
import com.wbrawner.twigs.server.ErrorResponse import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.budget.BudgetRepository import com.wbrawner.twigs.server.budget.BudgetRepository
import com.wbrawner.twigs.server.currentUser import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.permission.UserPermission import com.wbrawner.twigs.server.permission.UserPermission

16
core/build.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
plugins {
kotlin("jvm")
`java-library`
}
val ktorVersion: String by rootProject.extra
dependencies {
implementation(kotlin("stdlib"))
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -0,0 +1,43 @@
package com.wbrawner.twigs
import java.util.*
private val CALENDAR_FIELDS = intArrayOf(
Calendar.MILLISECOND,
Calendar.SECOND,
Calendar.MINUTE,
Calendar.HOUR_OF_DAY,
Calendar.DATE
)
val firstOfMonth: Date
get() = GregorianCalendar().run {
for (calField in CALENDAR_FIELDS) {
set(calField, getActualMinimum(calField))
}
time
}
val endOfMonth: Date
get() = GregorianCalendar().run {
for (calField in CALENDAR_FIELDS) {
set(calField, getActualMaximum(calField))
}
time
}
val twoWeeksFromNow: Date
get() = GregorianCalendar().run {
add(Calendar.DATE, 14)
time
}
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
fun randomString(length: Int = 32): String {
val id = StringBuilder()
for (i in 0 until length) {
id.append(CHARACTERS.random())
}
return id.toString()
}

View file

@ -0,0 +1,10 @@
package com.wbrawner.twigs.model
import com.wbrawner.twigs.randomString
data class Budget(
var id: String = randomString(),
var name: String? = null,
var description: String? = null,
var currencyCode: String? = "USD",
)

View file

@ -0,0 +1,13 @@
package com.wbrawner.twigs.model
import com.wbrawner.twigs.randomString
data class Category(
val id: String = randomString(),
var title: String = "",
var description: String? = null,
var amount: Long = 0L,
var budget: Budget? = null,
var expense: Boolean = true,
var archived: Boolean = false
)

View file

@ -0,0 +1,16 @@
package com.wbrawner.twigs.model
import com.wbrawner.twigs.randomString
import java.time.Instant
data class Transaction(
val id: String = randomString(),
val title: String? = null,
val description: String? = null,
val date: Instant? = null,
val amount: Long? = null,
val category: Category? = null,
val expense: Boolean? = null,
val createdBy: User? = null,
val budget: Budget? = null
)

View file

@ -0,0 +1,42 @@
package com.wbrawner.twigs.model
import com.wbrawner.twigs.randomString
data class User(
val id: String = randomString(),
val name: String = "",
val password: String = "",
val email: String? = null
)
enum class Permission {
/**
* The user can read the content but cannot make any modifications.
*/
READ,
/**
* The user can read and write the content but cannot make any modifications to the container of the content.
*/
WRITE,
/**
* The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions).
*/
MANAGE,
/**
* The user has complete control over the resource. There can only be a single owner user at any given time.
*/
OWNER;
fun isNotAtLeast(wanted: Permission): Boolean {
return ordinal < wanted.ordinal
}
}
data class UserPermission(
val budgetId: String,
val userId: String,
val permission: Permission = Permission.READ
)

View file

@ -1,2 +1,3 @@
rootProject.name = "twigs" rootProject.name = "twigs"
include(":app") include("core", "api", "app")
include("storage")

17
storage/build.gradle.kts Normal file
View file

@ -0,0 +1,17 @@
plugins {
kotlin("jvm")
`java-library`
}
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":core"))
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
implementation("org.postgresql:postgresql:42.2.23")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -0,0 +1,5 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.Budget
interface BudgetRepository : Repository<Budget>

View file

@ -0,0 +1,8 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.UserPermission
interface PermissionRepository : Repository<UserPermission> {
fun findAllByBudgetId(budgetId: String): List<UserPermission>
fun findAllByUserId(userId: String): List<UserPermission>
}

View file

@ -0,0 +1,13 @@
package com.wbrawner.twigs.storage
/**
* Base interface for an entity repository that provides basic CRUD methods
*
* @param T The type of the object supported by this repository
*/
interface Repository<T> {
suspend fun findAll(): List<T>
suspend fun findAllByIds(id: List<String>): List<T>
suspend fun save(item: T): T
suspend fun delete(item: T): Boolean
}