diff --git a/build.gradle.kts b/build.gradle.kts index 9632b10..782b4e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ val ktor_version: String by project val kotlin_version: String by project val logback_version: String by project +val kmongo_version: String by project plugins { kotlin("jvm") version "1.8.22" @@ -32,4 +33,7 @@ dependencies { implementation("ch.qos.logback:logback-classic:$logback_version") testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + + implementation("org.litote.kmongo:kmongo:$kmongo_version") + implementation("org.litote.kmongo:kmongo-id-serialization:$kmongo_version") } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index c4d88ca..9c1c635 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,4 @@ ktor_version=2.3.1 kotlin_version=1.8.22 logback_version=1.2.11 kotlin.code.style=official +kmongo_version=4.5.0 diff --git a/src/main/kotlin/com/ray650128/JwtConfig.kt b/src/main/kotlin/com/ray650128/JwtConfig.kt new file mode 100644 index 0000000..63f7c95 --- /dev/null +++ b/src/main/kotlin/com/ray650128/JwtConfig.kt @@ -0,0 +1,36 @@ +package com.ray650128 + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.ray650128.model.User +import java.util.* + +object JwtConfig { + val secret = "my-secret" + val issuer = "com.ray650128" + val myRealm = "com.ray650128" + //private val validityInMs = 36_000_00 * 24 // 1 day + private val validityInMs = 300000 // 5 min + private val algorithm = Algorithm.HMAC512(secret) + + val verifier: JWTVerifier = JWT + .require(algorithm) + .withIssuer(issuer) + .build() + + /** + * Produce a token for this combination of name and password + */ + fun generateToken(user: User): String = JWT.create() + .withSubject("Authentication") + .withIssuer(issuer) + .withClaim("account", user.account) + .withExpiresAt(getExpiration()) // optional + .sign(algorithm) + + /** + * Calculate the expiration Date based on current time + the given validity + */ + private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ray650128/extensions/ResponseExtension.kt b/src/main/kotlin/com/ray650128/extensions/ResponseExtension.kt new file mode 100644 index 0000000..8d3c4ff --- /dev/null +++ b/src/main/kotlin/com/ray650128/extensions/ResponseExtension.kt @@ -0,0 +1,42 @@ +package com.ray650128.extensions + +import com.ray650128.model.ErrorResponse +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* + + +suspend fun ApplicationCall.sendCreated(data: T?) { + this.respond( + status = HttpStatusCode.Created, + message = data ?: mapOf("message" to "success.") + ) +} + +suspend fun ApplicationCall.sendSuccess(data: T?) { + this.respond( + status = HttpStatusCode.OK, + message = data ?: mapOf("message" to "success.") + ) +} + +suspend fun ApplicationCall.sendUnauthorized() { + this.respond( + status = HttpStatusCode.Unauthorized, + message = ErrorResponse.UNAUTHORIZED_RESPONSE + ) +} + +suspend fun ApplicationCall.sendBadRequest(errorResponse: ErrorResponse) { + this.respond( + status = HttpStatusCode.BadRequest, + message = errorResponse + ) +} + +suspend fun ApplicationCall.sendNotFound() { + this.respond( + status = HttpStatusCode.NotFound, + message = ErrorResponse.NOT_FOUND_RESPONSE + ) +} diff --git a/src/main/kotlin/com/ray650128/model/ErrorResponse.kt b/src/main/kotlin/com/ray650128/model/ErrorResponse.kt new file mode 100644 index 0000000..c88dc74 --- /dev/null +++ b/src/main/kotlin/com/ray650128/model/ErrorResponse.kt @@ -0,0 +1,12 @@ +package com.ray650128.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse(val message: String) { + companion object { + val NOT_FOUND_RESPONSE = ErrorResponse("Person was not found") + val BAD_REQUEST_RESPONSE = ErrorResponse("Invalid request") + val UNAUTHORIZED_RESPONSE = ErrorResponse("Unauthorized") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ray650128/model/LoginResult.kt b/src/main/kotlin/com/ray650128/model/LoginResult.kt new file mode 100644 index 0000000..c78d64f --- /dev/null +++ b/src/main/kotlin/com/ray650128/model/LoginResult.kt @@ -0,0 +1,9 @@ +package com.ray650128.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResult( + var account: String, + var token: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/ray650128/model/User.kt b/src/main/kotlin/com/ray650128/model/User.kt new file mode 100644 index 0000000..fa11b75 --- /dev/null +++ b/src/main/kotlin/com/ray650128/model/User.kt @@ -0,0 +1,17 @@ +package com.ray650128.model + +import io.ktor.server.auth.* +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import org.litote.kmongo.Id +import org.litote.kmongo.newId + +@Serializable +data class User( + @Contextual val _id: Id? = newId(), + val account: String, + val password: String, + var name: String? = null, + var createAt: Long? = null, + var updatedAt: Long? = null +): Principal diff --git a/src/main/kotlin/com/ray650128/plugins/Routing.kt b/src/main/kotlin/com/ray650128/plugins/Routing.kt index 0b109d6..3ca0c76 100644 --- a/src/main/kotlin/com/ray650128/plugins/Routing.kt +++ b/src/main/kotlin/com/ray650128/plugins/Routing.kt @@ -1,13 +1,105 @@ package com.ray650128.plugins -import io.ktor.server.routing.* -import io.ktor.server.response.* +import com.ray650128.JwtConfig +import com.ray650128.extensions.sendBadRequest +import com.ray650128.extensions.sendCreated +import com.ray650128.extensions.sendSuccess +import com.ray650128.extensions.sendUnauthorized +import com.ray650128.model.ErrorResponse +import com.ray650128.model.LoginResult +import com.ray650128.model.User +import com.ray650128.service.UserService import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.routing.* fun Application.configureRouting() { + routing { - get("/") { - call.respondText("Hello World!") + route("/api") { + route("/v1") { + post("/register") { + val request = call.receive() + if (UserService.findByAccount(request.account) != null) { + call.sendBadRequest(ErrorResponse("User has existed.")) + return@post + } + val newToken = JwtConfig.generateToken(request) + val user = request.apply { + createAt = System.currentTimeMillis() + } + UserService.create(user)?.let { userId -> + call.response.headers.append("My-User-Id-Header", userId.toString()) + call.sendCreated(LoginResult(request.account, newToken)) + } ?: call.sendBadRequest(ErrorResponse.BAD_REQUEST_RESPONSE) + } + + post("/login") { + val request = call.receive() + val user = UserService.findByLoginInfo(request.account, request.password) + if (user != null) { + val token = JwtConfig.generateToken(request) + call.sendSuccess(LoginResult(request.account, token)) + } else { + call.sendBadRequest(ErrorResponse("Account or Password wrong.")) + } + } + + authenticate { + post("/logout") { + val account = call.authentication.principal()?.account ?: run { + call.sendUnauthorized() + return@post + } + val user = UserService.findByAccount(account) ?: run { + call.sendUnauthorized() + return@post + } + UserService.updateById(user._id.toString(), user) + call.sendSuccess(null) + } + } + } + + /*get { + val peopleList = UserService.findAll() + call.respond(peopleList) + } + + get("/{id}") { + val id = call.parameters["id"].toString() + UserService.findById(id) + ?.let { foundPerson -> call.respond(foundPerson) } + ?: call.sendNotFound() + } + + get("/search") { + val name = call.request.queryParameters["name"].toString() + val foundPeople = UserService.findByName(name) + call.respond(foundPeople) + } + + put("/{id}") { + val id = call.parameters["id"].toString() + val userRequest = call.receive() + val updatedSuccessfully = UserService.updateById(id, userRequest) + if (updatedSuccessfully) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(HttpStatusCode.BadRequest, ErrorResponse.BAD_REQUEST_RESPONSE) + } + } + + delete("/{id}") { + val id = call.parameters["id"].toString() + val deletedSuccessfully = UserService.deleteById(id) + if (deletedSuccessfully) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(HttpStatusCode.NotFound, ErrorResponse.NOT_FOUND_RESPONSE) + } + }*/ } } } diff --git a/src/main/kotlin/com/ray650128/service/UserService.kt b/src/main/kotlin/com/ray650128/service/UserService.kt new file mode 100644 index 0000000..ffb74db --- /dev/null +++ b/src/main/kotlin/com/ray650128/service/UserService.kt @@ -0,0 +1,56 @@ +package com.ray650128.service + +import com.ray650128.model.User +import org.bson.types.ObjectId +import org.litote.kmongo.* +import org.litote.kmongo.id.toId + +object UserService { + private val client = KMongo.createClient("mongodb://ray650128:Zx650128!@www.ray650128.com:27017") + private val database = client.getDatabase("test_system") + private val userCollection = database.getCollection() + + fun create(user: User): Id? { + userCollection.insertOne(user) + return user._id + } + + fun findAll(): List = userCollection.find().toList() + + fun findById(id: String): User? { + val bsonId: Id = ObjectId(id).toId() + return userCollection.findOne(User::_id eq bsonId) + } + + fun findByName(name: String): List { + val caseSensitiveTypeSafeFilter = User::name regex name + return userCollection.find(caseSensitiveTypeSafeFilter).toList() + } + + fun findByAccount(account: String): User? { + return userCollection.findOne(User::account eq account) + } + + fun findByLoginInfo(account: String, password: String): User? { + return userCollection.findOne( + User::account eq account, + User::password eq password + ) + } + + fun updateById(id: String, request: User): Boolean = + findById(id)?.let { user -> + val updateResult = userCollection.replaceOne( + user.copy( + name = request.name, + updatedAt = request.updatedAt + ) + ) + updateResult.modifiedCount == 1L + } ?: false + + fun deleteById(id: String): Boolean { + val deleteResult = userCollection.deleteOneById(ObjectId(id)) + return deleteResult.deletedCount == 1L + } +} \ No newline at end of file