Add user routes

This commit is contained in:
William Brawner 2021-08-05 19:35:42 -06:00
parent 4de09a8f1c
commit f6e0157560
18 changed files with 309 additions and 231 deletions

View file

@ -10,7 +10,6 @@ dependencies {
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")

View file

@ -0,0 +1,58 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.Session
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.util.pipeline.*
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository,
userId: String,
budgetId: String,
permission: Permission,
otherwise: () -> Unit
) {
permissionRepository.findAll(
userId = userId,
budgetIds = listOf(budgetId)
).firstOrNull {
it.permission.isAtLeast(permission)
} ?: run {
errorResponse(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
otherwise()
}
}
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository,
budgetId: String,
permission: Permission,
block: suspend (Budget) -> Unit
) {
val session = call.principal<Session>()!!
val userPermission = permissionRepository.findAll(
userId = session.userId,
budgetIds = listOf(budgetId)
).firstOrNull()
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
errorResponse(HttpStatusCode.Forbidden)
return
}
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
}
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
httpStatusCode: HttpStatusCode = HttpStatusCode.NotFound,
message: String? = null
) {
message?.let {
call.respond(httpStatusCode, ErrorResponse(message))
}?: call.respond(httpStatusCode)
}

View file

@ -5,35 +5,18 @@ 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 com.wbrawner.twigs.storage.Session
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.findAll(
userId = session.userId,
budgetIds = listOf(budgetId)
).firstOrNull()
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
call.respond(HttpStatusCode.Forbidden)
return
}
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
}
routing {
route("/api/budgets") {
authenticate(optional = false) {
@ -47,7 +30,7 @@ fun Application.budgetRoutes(
}
get("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
budgetWithPermission(budgetRepository, permissionRepository, call.parameters["id"]!!, Permission.READ) { budget ->
val users = permissionRepository.findAll(budgetIds = listOf(budget.id))
call.respond(BudgetResponse(budget, users))
}
@ -57,7 +40,7 @@ fun Application.budgetRoutes(
val session = call.principal<Session>()!!
val request = call.receive<BudgetRequest>()
if (request.name.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, "Name cannot be empty or null")
errorResponse(HttpStatusCode.BadRequest, "Name cannot be empty or null")
return@post
}
val budget = budgetRepository.save(
@ -90,7 +73,7 @@ fun Application.budgetRoutes(
}
put("/{id}") {
budgetWithPermission(call.parameters["id"]!!, Permission.MANAGE) { budget ->
budgetWithPermission(budgetRepository, permissionRepository, call.parameters["id"]!!, Permission.MANAGE) { budget ->
val request = call.receive<BudgetRequest>()
val name = request.name ?: budget.name
val description = request.description ?: budget.description
@ -112,7 +95,7 @@ fun Application.budgetRoutes(
}
delete("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.OWNER) { budget ->
budgetWithPermission(budgetRepository, permissionRepository, budgetId = call.parameters["id"]!!, Permission.OWNER) { budget ->
budgetRepository.delete(budget)
call.respond(HttpStatusCode.NoContent)
}

View file

@ -25,7 +25,7 @@ data class CategoryResponse(
category.title,
category.description,
category.amount,
category.budget!!.id,
category.budgetId!!,
category.expense,
category.archived
)

View file

@ -4,13 +4,13 @@ import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.Session
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.categoryRoutes(
categoryRepository: CategoryRepository,
@ -45,11 +45,11 @@ fun Application.categoryRoutes(
val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>()
if (request.title.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Title cannot be null or empty"))
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
}
if (request.budgetId.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Budget ID cannot be null or empty"))
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
@ -111,7 +111,7 @@ fun Application.categoryRoutes(
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
call.respond(HttpStatusCode.NotFound)
errorResponse(HttpStatusCode.NotFound)
return@delete
}
requireBudgetWithPermission(
@ -128,21 +128,3 @@ fun Application.categoryRoutes(
}
}
}
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository,
userId: String,
budgetId: String,
permission: Permission,
otherwise: () -> Unit
) {
permissionRepository.findAll(
userId = userId,
budgetIds = listOf(budgetId)
).firstOrNull {
it.permission.isAtLeast(permission)
} ?: run {
call.respond(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
otherwise()
}
}

View file

@ -2,25 +2,18 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.storage.Session
import java.util.*
data class NewUserRequest(
val username: String,
val password: String,
val email: String? = null
)
data class UpdateUserRequest(
data class UserRequest(
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 LoginRequest(val username: String, val password: String)
data class UserResponse(val id: String, val username: String, val email: String?) {
constructor(user: User) : this(user.id, user.name, user.email)
}
data class UserResponse(val id: String, val username: String, val email: String?)
data class UserPermissionRequest(
val user: String,
@ -29,13 +22,15 @@ data class UserPermissionRequest(
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 SessionResponse(val token: String, val expiration: String)
data class PasswordResetRequest(
val userId: Long,
val id: String = randomString(),
private val date: Calendar = GregorianCalendar(),
private val token: String = randomString()
)
)
fun User.asResponse(): UserResponse = UserResponse(id, name, email)
fun Session.asResponse(): SessionResponse = SessionResponse(token, expiration.toInstant().toString())

View file

@ -0,0 +1,139 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.Session
import com.wbrawner.twigs.storage.SessionRepository
import com.wbrawner.twigs.storage.UserRepository
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.sessions.*
fun Application.userRoutes(
permissionRepository: PermissionRepository,
sessionRepository: SessionRepository,
userRepository: UserRepository
) {
routing {
route("/api/users") {
post("/login") {
val request = call.receive<LoginRequest>()
val user =
userRepository.find(name = request.username, password = request.password.hash()).firstOrNull()
?: userRepository.find(email = request.username, password = request.password.hash())
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
return@post
}
val session = sessionRepository.save(Session(userId = user.id))
call.sessions.set(session)
call.respond(session.asResponse())
}
post("/register") {
val request = call.receive<UserRequest>()
if (request.username.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
return@post
}
if (request.password.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
return@post
}
call.respond(
userRepository.save(
User(
name = request.username,
password = request.password,
email = request.email
)
).asResponse()
)
}
authenticate(optional = false) {
get("/") {
val query = call.request.queryParameters.getAll("query")
if (query?.firstOrNull()?.isNotBlank() == true) {
call.respond(userRepository.findAll(nameLike = query.first()).map{ it.asResponse() })
return@get
}
permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetId")
).mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}.run { call.respond(this) }
}
get("/{id}") {
userRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?.asResponse()
?: errorResponse(HttpStatusCode.NotFound)
}
post("/") {
val request = call.receive<UserRequest>()
if (request.username.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
return@post
}
if (request.password.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
return@post
}
call.respond(
userRepository.save(
User(
name = request.username,
password = request.password,
email = request.email
)
).asResponse()
)
}
put("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<UserRequest>()
// TODO: Add some kind of admin denotation to allow admins to edit other users
if (call.parameters["id"] != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@put
}
call.respond(
userRepository.save(
userRepository.findAll(ids = call.parameters.getAll("id"))
.first()
.run {
copy(
name = request.username ?: name,
password = request.password?.hash() ?: password,
email = request.email ?: email
)
}
).asResponse()
)
}
delete("/{id}") {
val session = call.principal<Session>()!!
// TODO: Add some kind of admin denotation to allow admins to delete other users
if (call.parameters["id"] != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@delete
}
userRepository.deleteById(session.userId)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}

View file

@ -28,6 +28,7 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-cio:$ktorVersion")
implementation("io.ktor:ktor-server-sessions:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
implementation("ch.qos.logback:logback-classic:1.2.3")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")

View file

@ -2,14 +2,52 @@ package com.wbrawner.twigs.server
import com.wbrawner.twigs.budgetRoutes
import com.wbrawner.twigs.categoryRoutes
import com.wbrawner.twigs.storage.*
import com.wbrawner.twigs.twoWeeksFromNow
import com.wbrawner.twigs.userRoutes
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.sessions.*
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
fun Application.module(budgetReposi) {
install(Authentication)
budgetRoutes()
categoryRoutes()
@ExperimentalTime
fun Application.module(
budgetRepository: BudgetRepository,
categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository,
sessionRepository: SessionRepository,
userRepository: UserRepository
) {
install(Sessions) {
header<String>("Authorization")
}
install(Authentication) {
session<String> {
validate { token ->
val session = sessionRepository.findAll(token).firstOrNull()
?: return@validate null
return@validate if (twoWeeksFromNow.after(session.expiration)) {
session
} else {
null
}
}
}
}
budgetRoutes(budgetRepository, permissionRepository)
categoryRoutes(categoryRepository, permissionRepository)
userRoutes(permissionRepository, sessionRepository, userRepository)
launch {
while (currentCoroutineContext().isActive) {
delay(Duration.hours(24))
sessionRepository.deleteExpired()
}
}
}

View file

@ -1,14 +0,0 @@
package com.wbrawner.twigs.server.session
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.*
@Component
open class SessionCleanupTask @Autowired constructor(private val sessionRepository: UserSessionRepository) {
@Scheduled(cron = "0 0 * * * *")
open fun cleanup() {
sessionRepository.deleteAllByExpirationBefore(Date())
}
}

View file

@ -1,140 +0,0 @@
package com.wbrawner.twigs.server.user
import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.budget.BudgetRepository
import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.permission.UserPermission
import com.wbrawner.twigs.server.permission.UserPermissionRepository
import com.wbrawner.twigs.server.permission.UserPermissionResponse
import com.wbrawner.twigs.server.session.Session
import com.wbrawner.twigs.server.session.SessionResponse
import com.wbrawner.twigs.server.session.UserSessionRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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.context.SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.*
import javax.transaction.Transactional
@RestController
@RequestMapping("/users")
@Transactional
open class UserController @Autowired constructor(
private val budgetRepository: BudgetRepository,
private val userRepository: UserRepository,
private val userSessionRepository: UserSessionRepository,
private val passwordEncoder: PasswordEncoder,
private val userPermissionsRepository: UserPermissionRepository,
private val authenticationProvider: DaoAuthenticationProvider
) {
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getUsers(budgetId: String): ResponseEntity<List<UserPermissionResponse>> {
val budget = budgetRepository.findById(budgetId).orElse(null)
?: return ResponseEntity.notFound().build()
val userPermissions = userPermissionsRepository.findAllByBudget(budget, null)
val userInBudget = userPermissions.stream()
.anyMatch { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id }
return if (!userInBudget) {
ResponseEntity.notFound().build()
} else ResponseEntity.ok(
userPermissions.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) }
)
}
@PostMapping(path = ["/login"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun login(@RequestBody request: LoginRequest): ResponseEntity<SessionResponse> {
val authReq = UsernamePasswordAuthenticationToken(request.username, request.password)
val auth: Authentication
auth = try {
authenticationProvider.authenticate(authReq)
} catch (e: AuthenticationException) {
return ResponseEntity.notFound().build()
}
SecurityContextHolder.getContext().authentication = auth
val session = userSessionRepository.save(Session(currentUser!!.id))
return ResponseEntity.ok(SessionResponse(session))
}
@GetMapping(path = ["/me"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getProfile(): ResponseEntity<UserResponse> {
val user = currentUser
?: return ResponseEntity.status(401).build()
return ResponseEntity.ok(UserResponse(user))
}
@GetMapping(path = ["/search"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun searchUsers(query: String?): ResponseEntity<List<UserResponse>> {
return ResponseEntity.ok(
userRepository.findByNameContains(query)
.map { user: User -> UserResponse(user) }
)
}
@GetMapping(path = ["/{id}"])
open fun getUser(@PathVariable id: String): ResponseEntity<UserResponse> {
val user = userRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(UserResponse(user))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
fun newUser(@RequestBody request: NewUserRequest): ResponseEntity<Any> {
if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Username taken"))
if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Email taken"))
return if (request.password.isBlank()) ResponseEntity.badRequest()
.body(ErrorResponse("Invalid password")) else ResponseEntity.ok(
UserResponse(
userRepository.save(
User(
name = request.username,
passphrase = passwordEncoder.encode(request.password),
email = request.email
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
fun updateUser(@PathVariable id: String, @RequestBody request: UpdateUserRequest): ResponseEntity<Any> {
if (currentUser?.id != id) return ResponseEntity.status(403).build()
var user = userRepository.findById(currentUser!!.id).orElse(null)
?: return ResponseEntity.notFound().build()
if (request.username != null) {
if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Username taken"))
user = user.copy(name = request.username)
}
if (request.email != null) {
if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Email taken"))
user = user.copy(email = request.email)
}
if (request.password != null) {
if (request.password.isBlank()) return ResponseEntity.badRequest().body(ErrorResponse("Invalid password"))
user = user.copy(passphrase = passwordEncoder.encode(request.password))
}
return ResponseEntity.ok(UserResponse(userRepository.save(user)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
fun deleteUser(@PathVariable id: String): ResponseEntity<Void> {
if (currentUser?.id != id) return ResponseEntity.status(403).build()
userRepository.deleteById(id)
return ResponseEntity.ok().build()
}
}

View file

@ -1,10 +0,0 @@
package com.wbrawner.twigs.server.user
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserRepository : PagingAndSortingRepository<User, String> {
fun findByName(name: String?): Optional<User>
fun findByNameContains(name: String?): List<User>
fun findByEmail(email: String?): Optional<User>
}

View file

@ -1,8 +1,19 @@
ktor {
deployment {
port = 8080
port = ${?TWIGS_PORT}
}
application {
modules = [ com.wbrawner.twigs.server.ApplicationKt.module ]
}
database {
host = localhost
host = ${?TWIGS_DB_HOST}
port = 5432
port = ${?TWIGS_DB_PORT}
user = twigs
user = ${?TWIGS_DB_USER}
password = twigs
password = ${?TWIGS_DB_PASS}
}
}

View file

@ -41,3 +41,6 @@ fun randomString(length: Int = 32): String {
}
return id.toString()
}
// TODO: Use bcrypt to hash strings
fun String.hash(): String = this

View file

@ -3,9 +3,12 @@ plugins {
`java-library`
}
val ktorVersion: String by rootProject.extra
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":core"))
api("io.ktor:ktor-auth:$ktorVersion")
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")

View file

@ -1,5 +1,7 @@
package com.wbrawner.twigs
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.randomString
import com.wbrawner.twigs.twoWeeksFromNow
import io.ktor.auth.*
import java.util.*

View file

@ -0,0 +1,9 @@
package com.wbrawner.twigs.storage
interface SessionRepository : Repository<Session> {
fun findAll(
token: String
): List<Session>
fun deleteExpired()
}

View file

@ -0,0 +1,19 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.User
interface UserRepository : Repository<User> {
fun findAll(
ids: List<String>? = null,
): List<User>
fun find(
name: String? = null,
email: String? = null,
password: String? = null
): List<User>
fun findAll(nameLike: String): List<User>
fun deleteById(id: String): Boolean
}