Skip to content

Commit

Permalink
Add token to session store, implement login handler
Browse files Browse the repository at this point in the history
  • Loading branch information
IRus committed Jan 7, 2025
1 parent d01818a commit 745156c
Show file tree
Hide file tree
Showing 29 changed files with 389 additions and 113 deletions.
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package io.heapy.komok.business

import io.heapy.komok.business.healthcheck.HealthCheckRoute
import io.heapy.komok.business.login.LoginModule
import io.heapy.komok.server.common.KomokRoute
import io.heapy.komok.server.common.KomokRoutes
import io.heapy.komok.tech.di.lib.Module

@Module
open class AnonymousRoutesModule {
open class AnonymousRoutesModule(
private val loginModule: LoginModule,
) {
open val anonymousRoutes: KomokRoute by lazy {
KomokRoutes(
routes = listOf(
healthCheckRoute,
loginModule.loginRoute,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.heapy.komok.business.login

import io.heapy.komok.business.user.UserDaoModule
import io.heapy.komok.business.user.session.UserSessionDaoModule
import io.heapy.komok.infra.argon2.PasswordHasherModule
import io.heapy.komok.infra.session_token.SessionTokenServiceModule
import io.heapy.komok.infra.totp.TimeBasedOneTimePasswordModule
import io.heapy.komok.tech.di.lib.Module

@Module
open class LoginModule(
private val userDaoModule: UserDaoModule,
private val userSessionDaoModule: UserSessionDaoModule,
private val sessionTokenServiceModule: SessionTokenServiceModule,
private val timeBasedOneTimePasswordModule: TimeBasedOneTimePasswordModule,
private val passwordHasherModule: PasswordHasherModule,
) {
open val loginService by lazy {
LoginService(
userDao = userDaoModule.userDao,
userSessionDao = userSessionDaoModule.userSessionDao,
sessionTokenService = sessionTokenServiceModule.sessionTokenService,
timeBasedOneTimePasswordService = timeBasedOneTimePasswordModule.timeBasedOneTimePasswordService,
passwordHasherModule.passwordHasher,
)
}

open val loginRoute by lazy {
LoginRoute(
loginService = loginService,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.heapy.komok.business.login

import kotlinx.serialization.Serializable

@Serializable
data class LoginRequest(
val email: String,
val password: String,
val otp: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.heapy.komok.business.login

import kotlinx.serialization.Serializable
import kotlin.time.Duration

@Serializable
data class LoginResponse(
val sessionToken: String,
val maxAge: Duration,
)
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
package io.heapy.komok.business.login

import io.heapy.komok.infra.jwt.JwtService
import io.heapy.komok.server.common.KomokRoute
import io.ktor.http.Cookie
import io.ktor.http.HttpStatusCode
import io.ktor.server.plugins.origin
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable

class LoginRoute(
private val jwtService: JwtService,
private val loginService: LoginService,
) : KomokRoute {
@Serializable
data class LoginRequest(
val email: String,
val password: String,
)

override fun Routing.install() {
post("/login") {
val req = call.receive<LoginRequest>()
// jwtService.createToken(User(id = "1"))
// call.respond(hashMapOf("token" to token))
val loginRequest = call.receive<LoginRequest>()
val forwardedFor = call.request.headers["X-Forwarded-For"]
val clientIp = forwardedFor ?: call.request.origin.remoteHost

val loginResponse = loginService
.login(
loginRequest = loginRequest,
ip = clientIp,
userAgent = call.request.headers["User-Agent"] ?: "Unknown",
)

call.response.cookies.append(
Cookie(
name = "JSESSIONID",
value = loginResponse.sessionToken,
maxAge = loginResponse.maxAge.inWholeSeconds.toInt(),
path = "/",
secure = true,
httpOnly = true,
)
)
call.respond(HttpStatusCode.OK)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
package io.heapy.komok.business.login

import io.heapy.komok.business.user.UserDao
import io.heapy.komok.business.user.session.SessionTokenGenerator
import io.heapy.komok.business.user.session.UserSessionDao
import io.heapy.komok.infra.argon2.PasswordHasher
import io.heapy.komok.infra.http.server.errors.badRequestError
import io.heapy.komok.infra.session_token.SessionTokenService
import io.heapy.komok.infra.totp.TimeBasedOneTimePasswordService
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds

class LoginService(
private val userDao: UserDao,
private val sessionTokenGenerator: SessionTokenGenerator
private val userSessionDao: UserSessionDao,
private val sessionTokenService: SessionTokenService,
private val timeBasedOneTimePasswordService: TimeBasedOneTimePasswordService,
private val passwordHasher: PasswordHasher,
) {
suspend fun login(
email: String,
password: String,
totp: String,
): String {
val user = userDao.getUser(email)
?: throw IllegalArgumentException("User not found")
loginRequest: LoginRequest,
ip: String,
userAgent: String,
): LoginResponse {
val user = userDao
.getUser(
email = loginRequest.email,
)
?: badRequestError("email", "User not found")

val passwordValid = passwordHasher.verify(
password = loginRequest.password,
hash = user.hash,
)

if (!passwordValid) {
badRequestError("password", "Invalid password")
}

val sessionToken = sessionTokenGenerator.generate()
val isTotpValid = timeBasedOneTimePasswordService.validate(
secret = user.authenticatorKey,
totp = loginRequest.otp,
)

return sessionToken
if (!isTotpValid) {
badRequestError("otp", "Invalid OTP")
}

val sessionToken = sessionTokenService.generate()

userSessionDao.createSession(
userId = user.id,
maxAge = sessionTokenMaxAge,
ip = ip,
device = userAgent,
token = sessionToken,
)

return LoginResponse(
sessionToken = sessionToken,
// Make sure that browser will invalidate session before server
maxAge = sessionTokenMaxAge - 10.seconds,
)
}

private companion object {
private val sessionTokenMaxAge = 24.hours
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ class UserDao(
*/
suspend fun insertUser(
email: String,
password: String,
hash: String,
): String {
val objectId = ObjectId()
database.getCollection<User>(User.COLLECTION)
.insertOne(
User(
id = objectId,
email = email,
password = password,
hash = hash,
authenticatorKey = "",
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.heapy.komok.business.user

import io.heapy.komok.dao.mg.MongoModule
import io.heapy.komok.tech.di.lib.Module

@Module
open class UserDaoModule(
private val mongoModule: MongoModule,
) {
open val userDao by lazy {
UserDao(
database = mongoModule.komokDatabase,
)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.heapy.komok.business.user.session

import com.mongodb.client.model.Filters.and
import com.mongodb.client.model.Filters.eq
import com.mongodb.kotlin.client.coroutine.MongoDatabase
import io.heapy.komok.dao.mg.MongoV1.Session
import io.heapy.komok.infra.http.server.errors.AuthenticationError
import io.heapy.komok.infra.http.server.errors.authenticationError
import io.heapy.komok.infra.time.TimeSource
import kotlinx.coroutines.flow.firstOrNull
import org.bson.types.ObjectId
import kotlin.time.Duration

class UserSessionDao(
private val database: MongoDatabase,
private val timeSource: TimeSource,
) {
suspend fun createSession(
userId: ObjectId,
maxAge: Duration,
ip: String,
device: String,
token: String,
) {
database.getCollection<Session>(Session.COLLECTION)
.insertOne(
Session(
id = ObjectId(),
userId = userId,
expiration = timeSource.instant().epochSecond + maxAge.inWholeSeconds,
ip = ip,
device = device,
token = token,
)
)
}

suspend fun verifySession(
token: String,
ip: String,
) {
val session = database
.getCollection<Session>(Session.COLLECTION)
.find(
and(
eq(
Session::token.name,
token,
),
eq(
Session::ip.name,
ip,
),
),
)
.firstOrNull()

if (session == null) {
authenticationError(AuthenticationError.INVALID_SESSION)
}

if (session.expiration < timeSource.instant().epochSecond) {
authenticationError(AuthenticationError.SESSION_EXPIRED)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.heapy.komok.business.user.session

import io.heapy.komok.dao.mg.MongoModule
import io.heapy.komok.infra.time.TimeSourceModule
import io.heapy.komok.tech.di.lib.Module

@Module
open class UserSessionDaoModule(
private val mongoModule: MongoModule,
private val timeSourceModule: TimeSourceModule,
) {
open val userSessionDao by lazy {
UserSessionDao(
timeSource = timeSourceModule.timeSource,
database = mongoModule.komokDatabase,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.heapy.komok.business.user.argon2
package io.heapy.komok.infra.argon2

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
Expand All @@ -11,22 +11,15 @@ import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

interface PasswordHasher {
fun hash(password: String): String
fun hash(
password: String,
): String
fun verify(
password: String,
hash: String,
): Boolean
}

fun main() {
val hasher = createPasswordHasherModule {}
.passwordHasher

val hash = hasher.hash("password")

println(hash)
}

@OptIn(ExperimentalEncodingApi::class)
class Argon2idPasswordHasher(
private val json: Json,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.heapy.komok.business.user.argon2
package io.heapy.komok.infra.argon2

import io.heapy.komok.tech.di.lib.Module
import kotlinx.serialization.json.Json
Expand Down
Loading

0 comments on commit 745156c

Please sign in to comment.